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`, относящиеся к этому сценарию:
|
Новые `op`, относящиеся к этому сценарию:
|
||||||
|
|
||||||
- `GetTrustedDeviceLoginSettings`
|
- `UpsertEspPairingSettings`
|
||||||
- `UpsertTrustedDeviceLoginSettings`
|
- `StartEspPairing`
|
||||||
- `StartTrustedDeviceLogin`
|
- `ListEspPairingRequests`
|
||||||
- `ListTrustedDeviceLoginRequests`
|
- `ApproveEspPairing`
|
||||||
- `ApproveTrustedDeviceLogin`
|
- `RejectEspPairing`
|
||||||
- `RejectTrustedDeviceLogin`
|
- `GetEspPairingStatus`
|
||||||
- `CancelTrustedDeviceLogin`
|
|
||||||
- `GetTrustedDeviceLoginStatus`
|
|
||||||
|
|
||||||
В этом потоке:
|
В этом потоке:
|
||||||
|
|
||||||
|
|||||||
@ -9,17 +9,15 @@
|
|||||||
|
|
||||||
Дополнительно в этом же слое управления сессиями появился сценарий pairing через доверенную уже авторизованную сессию пользователя:
|
Дополнительно в этом же слое управления сессиями появился сценарий pairing через доверенную уже авторизованную сессию пользователя:
|
||||||
|
|
||||||
- `GetTrustedDeviceLoginSettings`
|
- `UpsertEspPairingSettings`
|
||||||
- `UpsertTrustedDeviceLoginSettings`
|
- `ListEspPairingRequests`
|
||||||
- `ListTrustedDeviceLoginRequests`
|
- `ApproveEspPairing`
|
||||||
- `ApproveTrustedDeviceLogin`
|
- `RejectEspPairing`
|
||||||
- `RejectTrustedDeviceLogin`
|
|
||||||
- `CancelTrustedDeviceLogin`
|
|
||||||
|
|
||||||
Анонимное новое устройство работает с двумя связанными операциями:
|
Анонимное новое устройство работает с двумя связанными операциями:
|
||||||
|
|
||||||
- `StartTrustedDeviceLogin`
|
- `StartEspPairing`
|
||||||
- `GetTrustedDeviceLoginStatus`
|
- `GetEspPairingStatus`
|
||||||
|
|
||||||
Логика раздела такая:
|
Логика раздела такая:
|
||||||
|
|
||||||
@ -168,11 +166,11 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. TrustedDeviceLogin через доверенную сессию
|
## 5. ESP pairing через доверенную сессию
|
||||||
|
|
||||||
Этот блок относится к сценарию добавления новой сессии через доверенное устройство пользователя.
|
Этот блок относится к сценарию добавления новой сессии через доверенное устройство пользователя.
|
||||||
|
|
||||||
### 5.1. `GetTrustedDeviceLoginSettings`
|
### 5.1. `UpsertEspPairingSettings`
|
||||||
|
|
||||||
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||||
|
|
||||||
@ -180,9 +178,12 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "GetTrustedDeviceLoginSettings",
|
"op": "UpsertEspPairingSettings",
|
||||||
"requestId": "trusted-login-get-001",
|
"requestId": "esp-set-001",
|
||||||
"payload": {
|
"payload": {
|
||||||
|
"enabled": true,
|
||||||
|
"passwordHash": "argon2id$...",
|
||||||
|
"ttlSeconds": 180
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -191,73 +192,23 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "GetTrustedDeviceLoginSettings",
|
"op": "UpsertEspPairingSettings",
|
||||||
"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",
|
|
||||||
"requestId": "esp-set-001",
|
"requestId": "esp-set-001",
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"payload": {
|
"payload": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"hasPassword": true
|
"ttlSeconds": 180
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ошибки
|
### Ошибки
|
||||||
|
|
||||||
|
- `400 / EMPTY_PASSWORD_HASH` — попытка включить pairing без `passwordHash`.
|
||||||
- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя.
|
- `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
|
```json
|
||||||
{
|
{
|
||||||
"op": "StartTrustedDeviceLogin",
|
"op": "StartEspPairing",
|
||||||
"requestId": "esp-start-001",
|
"requestId": "esp-start-001",
|
||||||
"payload": {
|
"payload": {
|
||||||
"login": "alice",
|
"login": "alice",
|
||||||
"passwordHash": "sha256$0123abcd...",
|
"passwordHash": "argon2id$...",
|
||||||
"requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY",
|
"requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY",
|
||||||
"requesterSessionType": 1,
|
"requesterSessionType": 1,
|
||||||
"requesterClientPlatform": "Android",
|
"requesterClientPlatform": "Android",
|
||||||
@ -278,17 +229,13 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Если на доверённом устройстве вход включён **без доп. пароля**, новое устройство может отправить пустой `passwordHash`.
|
|
||||||
|
|
||||||
Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку.
|
Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку.
|
||||||
|
|
||||||
TTL заявки фиксирован на сервере и сейчас всегда равен `300` секундам.
|
|
||||||
|
|
||||||
### Успешный ответ
|
### Успешный ответ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "StartTrustedDeviceLogin",
|
"op": "StartEspPairing",
|
||||||
"requestId": "esp-start-001",
|
"requestId": "esp-start-001",
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@ -306,25 +253,24 @@ TTL заявки фиксирован на сервере и сейчас все
|
|||||||
### Ошибки
|
### Ошибки
|
||||||
|
|
||||||
- `400 / EMPTY_LOGIN`
|
- `400 / EMPTY_LOGIN`
|
||||||
|
- `400 / EMPTY_PASSWORD_HASH`
|
||||||
- `400 / EMPTY_REQUESTER_SESSION_KEY`
|
- `400 / EMPTY_REQUESTER_SESSION_KEY`
|
||||||
- `400 / BAD_REQUESTER_SESSION_KEY`
|
- `400 / BAD_REQUESTER_SESSION_KEY`
|
||||||
- `400 / BAD_SESSION_TYPE`
|
- `400 / BAD_SESSION_TYPE`
|
||||||
- `400 / BAD_PAYLOAD_TYPE`
|
- `400 / BAD_PAYLOAD_TYPE`
|
||||||
- `422 / PAIRING_NOT_AVAILABLE`
|
- `422 / PAIRING_NOT_AVAILABLE`
|
||||||
- `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля.
|
- `422 / PAIRING_PASSWORD_INVALID`
|
||||||
- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` — сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся.
|
|
||||||
- `429 / PAIRING_RATE_LIMITED`
|
- `429 / PAIRING_RATE_LIMITED`
|
||||||
|
|
||||||
### 5.4. `ListTrustedDeviceLoginRequests`
|
### 5.3. `ListEspPairingRequests`
|
||||||
|
|
||||||
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||||
Возвращает только реально активные pending-заявки со `state = created`. Уже `approved` и `rejected` заявки в этот список больше не попадают.
|
|
||||||
|
|
||||||
### Успешный ответ
|
### Успешный ответ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "ListTrustedDeviceLoginRequests",
|
"op": "ListEspPairingRequests",
|
||||||
"requestId": "esp-list-001",
|
"requestId": "esp-list-001",
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@ -352,7 +298,7 @@ TTL заявки фиксирован на сервере и сейчас все
|
|||||||
|
|
||||||
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
|
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
|
||||||
|
|
||||||
### 5.5. `ApproveTrustedDeviceLogin`
|
### 5.4. `ApproveEspPairing`
|
||||||
|
|
||||||
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||||
|
|
||||||
@ -360,7 +306,7 @@ TTL заявки фиксирован на сервере и сейчас все
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "ApproveTrustedDeviceLogin",
|
"op": "ApproveEspPairing",
|
||||||
"requestId": "esp-approve-001",
|
"requestId": "esp-approve-001",
|
||||||
"payload": {
|
"payload": {
|
||||||
"pairingId": "base64url",
|
"pairingId": "base64url",
|
||||||
@ -373,7 +319,7 @@ TTL заявки фиксирован на сервере и сейчас все
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "ApproveTrustedDeviceLogin",
|
"op": "ApproveEspPairing",
|
||||||
"requestId": "esp-approve-001",
|
"requestId": "esp-approve-001",
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@ -394,11 +340,11 @@ TTL заявки фиксирован на сервере и сейчас все
|
|||||||
- `422 / PAIRING_EXPIRED`
|
- `422 / PAIRING_EXPIRED`
|
||||||
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
|
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
|
||||||
|
|
||||||
### 5.6. `RejectTrustedDeviceLogin`
|
### 5.5. `RejectEspPairing`
|
||||||
|
|
||||||
Доступно для любой уже авторизованной доверенной сессии пользователя. Похоже на approve, но переводит заявку в `state=rejected`.
|
Доступно для любой уже авторизованной доверенной сессии пользователя. Похоже на approve, но переводит заявку в `state=rejected`.
|
||||||
|
|
||||||
### 5.7. `GetTrustedDeviceLoginStatus`
|
### 5.6. `GetEspPairingStatus`
|
||||||
|
|
||||||
Операция для нового устройства.
|
Операция для нового устройства.
|
||||||
|
|
||||||
@ -406,7 +352,7 @@ TTL заявки фиксирован на сервере и сейчас все
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "GetTrustedDeviceLoginStatus",
|
"op": "GetEspPairingStatus",
|
||||||
"requestId": "esp-status-001",
|
"requestId": "esp-status-001",
|
||||||
"payload": {
|
"payload": {
|
||||||
"pairingId": "base64url"
|
"pairingId": "base64url"
|
||||||
@ -418,7 +364,7 @@ TTL заявки фиксирован на сервере и сейчас все
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "GetTrustedDeviceLoginStatus",
|
"op": "GetEspPairingStatus",
|
||||||
"requestId": "esp-status-001",
|
"requestId": "esp-status-001",
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@ -439,46 +385,4 @@ TTL заявки фиксирован на сервере и сейчас все
|
|||||||
- `created`
|
- `created`
|
||||||
- `approved`
|
- `approved`
|
||||||
- `rejected`
|
- `rejected`
|
||||||
- `canceled`
|
|
||||||
- `expired`
|
- `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` | создание новой авторизованной сессии |
|
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
|
||||||
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
|
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
|
||||||
| `SessionLogin` | `02_Authentication_API.md` | вход в существующую сессию |
|
| `SessionLogin` | `02_Authentication_API.md` | вход в существующую сессию |
|
||||||
| `GetTrustedDeviceLoginSettings` | `03_Session_Management_API.md` | чтение текущего режима входа через доверенное устройство |
|
| `UpsertEspPairingSettings` | `03_Session_Management_API.md` | включение/обновление pairing-настроек доверенной сессией |
|
||||||
| `UpsertTrustedDeviceLoginSettings` | `03_Session_Management_API.md` | включение/обновление pairing-настроек доверенной сессией |
|
| `StartEspPairing` | `03_Session_Management_API.md` | создание pairing-заявки для нового устройства |
|
||||||
| `StartTrustedDeviceLogin` | `03_Session_Management_API.md` | создание pairing-заявки для нового устройства |
|
| `ListEspPairingRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии |
|
||||||
| `ListTrustedDeviceLoginRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии |
|
| `ApproveEspPairing` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией |
|
||||||
| `ApproveTrustedDeviceLogin` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией |
|
| `RejectEspPairing` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией |
|
||||||
| `RejectTrustedDeviceLogin` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией |
|
| `GetEspPairingStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки |
|
||||||
| `CancelTrustedDeviceLogin` | `03_Session_Management_API.md` | отмена pairing-заявки со стороны нового ожидающего устройства |
|
|
||||||
| `GetTrustedDeviceLoginStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки |
|
|
||||||
| `ListSessions` | `03_Session_Management_API.md` | список активных сессий |
|
| `ListSessions` | `03_Session_Management_API.md` | список активных сессий |
|
||||||
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
||||||
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
|
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
|
||||||
@ -62,6 +60,5 @@
|
|||||||
## Важные замечания
|
## Важные замечания
|
||||||
|
|
||||||
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
|
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
|
||||||
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
|
|
||||||
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
|
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
|
||||||
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
|
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
# API для разработчиков: DM, push и сигналы звонков
|
# API для разработчиков: DM, push и сигналы звонков
|
||||||
|
|
||||||
Документ описывает публичные операции, связанные с личными сообщениями, WebPush и сигналами звонков.
|
Документ описывает WebSocket-операции для подписанных личных сообщений, WebPush и realtime-сигналов звонков.
|
||||||
|
|
||||||
Подробная логика DM и бинарного формата:
|
Логика личных сообщений дополнительно описана в `Dev_Docs/Personal_Messages/README.md`; этот файл фиксирует именно публичные `op`, поля запросов и поля ответов.
|
||||||
|
|
||||||
- `Dev_Docs/Personal_Messages/README.md`
|
|
||||||
|
|
||||||
## 1. `UpsertPushToken`
|
## 1. `UpsertPushToken`
|
||||||
|
|
||||||
@ -42,9 +40,11 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2. `SendTestWebPush`
|
## 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`
|
Отправляет один подписанный DM-пакет.
|
||||||
- `outgoingBlobB64` — блок `type=2` или `type=4`
|
|
||||||
|
|
||||||
Для контентных сообщений `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,
|
"status": 200,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"payload": {
|
"payload": {
|
||||||
"baseKey": "from|to|time|nonce",
|
"baseKey": "base-key",
|
||||||
"incomingKey": "from|to|time|nonce|1",
|
"incomingKey": "incoming-key",
|
||||||
"outgoingKey": "from|to|time|nonce|2",
|
"outgoingKey": "outgoing-key",
|
||||||
"deliveredWsSessions": 1,
|
"deliveredWsSessions": 1,
|
||||||
"deliveredWebPushSessions": 0
|
"deliveredWebPushSessions": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ошибки
|
---
|
||||||
|
|
||||||
- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64`
|
## 5. `ReceiveIncomingMessage`
|
||||||
- `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` — подпись блока не прошла проверку
|
|
||||||
|
|
||||||
## 4. `ReceiveIncomingMessage`
|
Принимает входящий подписанный DM-блок.
|
||||||
|
|
||||||
Принимает только один входящий signed DM-блок.
|
|
||||||
|
|
||||||
### Назначение
|
|
||||||
|
|
||||||
Используется там, где нужно принять только incoming-вариант сообщения.
|
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
@ -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",
|
"op": "AckSessionDelivery",
|
||||||
"requestId": "ack-001",
|
"requestId": "ack-001",
|
||||||
"payload": {
|
"payload": {
|
||||||
"messageKey": "from|to|time|nonce|1"
|
"messageKey": "incoming-key"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6. Событие `SignedMessageArrived`
|
### Успешный ответ
|
||||||
|
|
||||||
Сервер присылает его по WebSocket в активные сессии адресата.
|
|
||||||
|
|
||||||
### Payload события
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"messageKey": "from|to|time|nonce|1",
|
"op": "AckSessionDelivery",
|
||||||
"baseKey": "from|to|time|nonce",
|
"requestId": "ack-001",
|
||||||
"fromLogin": "alice",
|
"status": 200,
|
||||||
"toLogin": "bob",
|
"ok": true,
|
||||||
"targetLogin": "bob",
|
"payload": {
|
||||||
"messageType": 1,
|
"messageKey": "incoming-key"
|
||||||
"timeMs": 1774700000123,
|
}
|
||||||
"nonce": 123456789,
|
|
||||||
"blobB64": "BASE64_SIGNED_BLOCK",
|
|
||||||
"backlog": false
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока.
|
---
|
||||||
|
|
||||||
## 7. `CallInviteBroadcast`
|
## 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`
|
## 8. `CallSignalToSession`
|
||||||
|
|
||||||
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
|
Требует авторизации. Отправляет сигнал звонка в конкретную сессию получателя.
|
||||||
|
|
||||||
## 9. Замечания
|
### Запрос
|
||||||
|
|
||||||
- read-receipt `type=3/4` пока остаются в legacy-формате `SHiNE_dm2`
|
```json
|
||||||
- контентные DM `type=1/2` используют `SHiNE_DM`
|
{
|
||||||
- сервер хранит только последнюю версию контентного сообщения по `messageKey`
|
"op": "CallSignalToSession",
|
||||||
- удаление сообщения реализуется новой ревизией с пустым телом и `attachmentsCount = 0`
|
"requestId": "call-signal-001",
|
||||||
- HTTP endpoints для DM-файлов сейчас отсутствуют
|
"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`;
|
- тип `1` — входящее сообщение для собеседника;
|
||||||
- ревизии сообщений через `revisionTimeMs`;
|
- тип `2` — исходящая копия того же сообщения для автора.
|
||||||
- редактирование сообщения через повторную отправку той же логической пары;
|
|
||||||
- удаление сообщения через пустую ревизию;
|
|
||||||
- `upsert` последней версии сообщения на сервере.
|
|
||||||
|
|
||||||
Сейчас в проекте **не реализованы**:
|
Оба блока отправляются вместе одной операцией (`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` — входящий блок для получателя;
|
## 1) Общая схема потока
|
||||||
- `type=2` — исходящая копия для отправителя.
|
|
||||||
|
|
||||||
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` — входящее подтверждение прочтения;
|
## 2) Формат signed DM-блока (`SHiNE_dm2`)
|
||||||
- `type=4` — исходящая копия подтверждения.
|
|
||||||
|
|
||||||
Ключи сообщения:
|
Префикс: `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`
|
- `messageKey = baseKey|messageType`
|
||||||
|
|
||||||
Логический идентификатор письма задаётся парой:
|
Эти ключи используются:
|
||||||
|
|
||||||
- `timeMs`
|
- для дедупликации;
|
||||||
- `nonce`
|
- для связи read-receipt с исходным сообщением;
|
||||||
|
- для ACK доставки по сессии.
|
||||||
|
|
||||||
Эти поля не меняются при редактировании или удалении. Меняется только:
|
## 5) RPC и события
|
||||||
|
|
||||||
- `revisionTimeMs`
|
## `SendMessagePair` (алиас `ReceiveOutcomingMessage`)
|
||||||
- содержимое `encryptedBody`
|
|
||||||
|
|
||||||
Сервер хранит только последнюю версию записи для каждого `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`
|
## `AckSessionDelivery`
|
||||||
- `encryptedBodyLen` сейчас ограничен сервером до `16384` байт
|
|
||||||
- `revisionTimeMs` не может быть отрицательным
|
|
||||||
|
|
||||||
Если приходит `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` остаются теми же;
|
## 7) Доставка и backlog
|
||||||
- `messageType` остаётся `1/2`;
|
|
||||||
- `revisionTimeMs` становится больше;
|
|
||||||
- `encryptedBody` содержит новую версию текста.
|
|
||||||
|
|
||||||
Если на сервер приходит более старая ревизия, она игнорируется.
|
- При сохранении пары сервер пытается сразу доставить в онлайн-сессии.
|
||||||
|
- Для офлайн/недоступных сессий остаётся 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` остаются прежними;
|
Сервер для read-receipt хранит ссылку на исходное сообщение:
|
||||||
- `revisionTimeMs` увеличивается;
|
|
||||||
- `attachmentsCount = 0`;
|
|
||||||
- `encryptedBodyLen = 0`;
|
|
||||||
- `encryptedBody` пустой.
|
|
||||||
|
|
||||||
В UI такое сообщение не показывается.
|
- `receipt_ref_base_key`;
|
||||||
|
- `receipt_ref_type`.
|
||||||
|
|
||||||
На сервере это не отдельный тип сообщения, а просто последняя пустая ревизия того же `messageKey`.
|
Есть уникальность, чтобы не плодить дубликаты receipt на один и тот же `baseKey` для одного `target_login`.
|
||||||
|
|
||||||
## Поведение сервера
|
## 9) Логика UI-клиента
|
||||||
|
|
||||||
Для контентных DM сервер:
|
### Хранилище сообщений
|
||||||
|
|
||||||
1. принимает пару signed-блоков `type=1/2`;
|
- In-memory: `state.chats[chatId]` — массив сообщений по каждому диалогу.
|
||||||
2. валидирует формат, подпись и совпадение ключевых полей пары;
|
- Персистентно: IndexedDB база `shine-ui-messages-v1`, object store `messages`, ключ `messageKey`.
|
||||||
3. проверяет, что для обеих сторон пары совпадают:
|
- `chatId` для `type=1` — `fromLogin`, для `type=2` — `toLogin`.
|
||||||
- `fromLogin`
|
|
||||||
- `toLogin`
|
|
||||||
- `timeMs`
|
|
||||||
- `nonce`
|
|
||||||
- `revisionTimeMs`
|
|
||||||
- `encryptedBody`
|
|
||||||
4. делает `upsert` последней версии в `signed_messages_v2`;
|
|
||||||
5. сбрасывает pending-доставку по сессиям для новой ревизии;
|
|
||||||
6. рассылает актуальную версию адресатам через `SignedMessageArrived`.
|
|
||||||
|
|
||||||
История старых ревизий сейчас не хранится отдельно: в таблице остаётся только последняя версия по каждому `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`
|
- непрочитанные считаются по `from='in' && unread=true`;
|
||||||
- `base_key`
|
- доставка/прочтение исходящих:
|
||||||
- `target_login`
|
- `firstTick` — сообщение принято сервером,
|
||||||
- `from_login`
|
- `secondTick` — пришло подтверждение прочтения;
|
||||||
- `to_login`
|
- при открытии диалога UI автопрокручивает ленту в самый низ;
|
||||||
- `time_ms`
|
- после отправки нового сообщения UI сразу прокручивает ленту вниз.
|
||||||
- `nonce`
|
|
||||||
- `message_type`
|
|
||||||
- `revision_time_ms`
|
|
||||||
- `raw_block`
|
|
||||||
- `created_at_ms`
|
|
||||||
|
|
||||||
Отдельных таблиц файлов для DM сейчас нет.
|
## 10) Синхронизация личных сообщений между серверами
|
||||||
|
|
||||||
## События и доставка
|
Когда пользователи зарегистрированы на разных серверах SHiNE, серверы должны синхронизировать DM между собой.
|
||||||
|
|
||||||
Запрос на отправку по WebSocket остаётся прежним:
|
### Общий принцип
|
||||||
|
|
||||||
- `SendMessagePair`
|
- Сервер A получает DM-блок, адресованный пользователю на сервере B.
|
||||||
- `ReceiveOutcomingMessage` как алиас
|
- Сервер A пересылает этот блок серверу B (межсерверный relay).
|
||||||
|
- Сервер B сохраняет блок и доставляет его в активные сессии получателя.
|
||||||
|
- Серверы, между которыми идёт синхронизация, задаются списком `sync_servers` в PDA пользователя-сервера.
|
||||||
|
|
||||||
Клиент отправляет:
|
### Что синхронизируется
|
||||||
|
|
||||||
- `incomingBlobB64`
|
- Все DM-блоки типов `1/2` (текстовые сообщения) и `3/4` (read-receipt).
|
||||||
- `outgoingBlobB64`
|
- Синхронизация двусторонняя: оба сервера должны уметь принимать и пересылать блоки.
|
||||||
|
|
||||||
Событие в активные сессии:
|
### Идемпотентность
|
||||||
|
|
||||||
- `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`;
|
## 12) Ключевые файлы реализации
|
||||||
- умеет обновлять уже существующее сообщение по тому же `messageKey`;
|
|
||||||
- не показывает удалённые сообщения;
|
|
||||||
- позволяет владельцу сообщения вызвать меню `Скопировать как текст / Прочесть / Изменить / Удалить`;
|
|
||||||
- при редактировании показывает над полем ввода полоску `Редактируем сообщение: ...` с кнопкой отмены;
|
|
||||||
- после редактирования показывает под временем отдельную строку `изменено: <дата время>`;
|
|
||||||
- не показывает и не принимает вложения.
|
|
||||||
|
|
||||||
## Что обязательно помнить
|
- UI:
|
||||||
|
- `shine-UI/js/services/auth-service.js`
|
||||||
- вложения в DM сейчас отключены на уровне протокола и UI;
|
- `shine-UI/js/app.js`
|
||||||
- любые старые описания `/f/...`, `/upload` и файловых таблиц для DM больше не актуальны;
|
- `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. Что именно делает сервер
|
## 3. Что именно делает сервер
|
||||||
|
|
||||||
- хранит включённость pairing и optional `passwordHash` в формате `sha256$<hex>`;
|
- хранит включённость pairing и opaque `passwordHash`;
|
||||||
- хранит pairing-заявки всех статусов, но в список активных для доверённого устройства отдаёт только pending `created`;
|
- хранит pending/approved/rejected pairing-заявки;
|
||||||
- рассчитывает короткий код `shortCode` из `7` цифр;
|
- рассчитывает короткий код `shortCode` из `7` цифр;
|
||||||
- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки;
|
- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки;
|
||||||
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
|
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
|
||||||
@ -101,12 +101,6 @@
|
|||||||
|
|
||||||
Эта схема даёт нужное разделение доверия:
|
Эта схема даёт нужное разделение доверия:
|
||||||
|
|
||||||
- пароль на сервере, если он включён, только отсеивает лишних;
|
- пароль на сервере только отсеивает лишних;
|
||||||
- онлайн доверенная сессия решает, добавлять ли новую сессию;
|
- онлайн доверенная сессия решает, добавлять ли новую сессию;
|
||||||
- сервер остаётся маршрутизатором и хранилищем состояния, а не владельцем секретов.
|
- сервер остаётся маршрутизатором и хранилищем состояния, а не владельцем секретов.
|
||||||
|
|
||||||
Текущий формат pairing-пароля:
|
|
||||||
|
|
||||||
```text
|
|
||||||
sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
|
|
||||||
```
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
.gradle
|
.gradle
|
||||||
build/
|
build/
|
||||||
node_modules/
|
|
||||||
!gradle/wrapper/gradle-wrapper.jar
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
!**/src/main/**/build/
|
!**/src/main/**/build/
|
||||||
!**/src/test/**/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>/`.
|
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
|
||||||
- Архив истории после `/new`: `data/history/<username>/archive/`.
|
- Архив истории после `/new`: `data/history/<username>/archive/`.
|
||||||
- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume.
|
|
||||||
- Для просмотра истории игрока открывать файлы в его папке истории по username.
|
- Для просмотра истории игрока открывать файлы в его папке истории по username.
|
||||||
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
|
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
|
||||||
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
|
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
|
||||||
|
|||||||
@ -89,7 +89,7 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
|
|||||||
- `/queue` — список задач в очереди.
|
- `/queue` — список задач в очереди.
|
||||||
- `/stop` — остановить текущую задачу.
|
- `/stop` — остановить текущую задачу.
|
||||||
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
||||||
- `/new` — архивировать текущую историю, сбросить продолжение Codex-сессии для этого пользователя и начать новый диалог.
|
- `/new` — архивировать текущую историю и начать новый диалог.
|
||||||
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
|
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
|
||||||
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
|
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
|
||||||
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
|
- `/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,
|
time_ms INTEGER NOT NULL,
|
||||||
nonce INTEGER NOT NULL,
|
nonce INTEGER NOT NULL,
|
||||||
message_type INTEGER NOT NULL,
|
message_type INTEGER NOT NULL,
|
||||||
revision_time_ms INTEGER NOT NULL DEFAULT 0,
|
|
||||||
raw_block BLOB NOT NULL,
|
raw_block BLOB NOT NULL,
|
||||||
created_at_ms INTEGER NOT NULL,
|
created_at_ms INTEGER NOT NULL,
|
||||||
source_api TEXT NOT NULL,
|
source_api TEXT NOT NULL,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import java.sql.Statement;
|
|||||||
public final class SqliteDbController {
|
public final class SqliteDbController {
|
||||||
|
|
||||||
private static volatile SqliteDbController instance;
|
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;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
@ -88,8 +88,6 @@ public final class SqliteDbController {
|
|||||||
case 3 -> migrateToV3();
|
case 3 -> migrateToV3();
|
||||||
case 4 -> migrateToV4();
|
case 4 -> migrateToV4();
|
||||||
case 5 -> migrateToV5();
|
case 5 -> migrateToV5();
|
||||||
case 6 -> migrateToV6();
|
|
||||||
case 7 -> migrateToV7();
|
|
||||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
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 {
|
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
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 {
|
private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException {
|
||||||
try (Statement probe = c.createStatement();
|
try (Statement probe = c.createStatement();
|
||||||
ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) {
|
ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) {
|
||||||
|
|||||||
@ -112,7 +112,7 @@ public final class EspPairingRequestsDAO {
|
|||||||
FROM esp_pairing_requests
|
FROM esp_pairing_requests
|
||||||
WHERE login = ? COLLATE NOCASE
|
WHERE login = ? COLLATE NOCASE
|
||||||
AND expires_at_ms > ?
|
AND expires_at_ms > ?
|
||||||
AND status = 'created'
|
AND status IN ('created', 'approved', 'rejected')
|
||||||
ORDER BY created_at_ms DESC
|
ORDER BY created_at_ms DESC
|
||||||
""";
|
""";
|
||||||
List<EspPairingRequestEntry> list = new ArrayList<>();
|
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 {
|
public int expirePending(long nowMs) throws SQLException {
|
||||||
try (Connection c = db.getConnection();
|
try (Connection c = db.getConnection();
|
||||||
PreparedStatement ps = c.prepareStatement("""
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import java.sql.PreparedStatement;
|
|||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public final class SignedMessagesV2DAO {
|
public final class SignedMessagesV2DAO {
|
||||||
@ -31,17 +30,36 @@ public final class SignedMessagesV2DAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
INSERT OR IGNORE INTO signed_messages_v2 (
|
INSERT OR IGNORE INTO signed_messages_v2 (
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
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
|
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
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;
|
return ps.executeUpdate() > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Атомарная вставка пары блоков: либо вставляются оба, либо не вставляется ни один.
|
||||||
|
* Возвращает true только если обе записи добавлены в БД.
|
||||||
|
* Если хотя бы одна запись уже существует (или конфликтует по уникальности), возвращает false.
|
||||||
|
*/
|
||||||
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
@ -67,45 +85,37 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
|
private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException {
|
||||||
try (Connection c = db.getConnection()) {
|
String sql = """
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
INSERT INTO signed_messages_v2 (
|
||||||
c.setAutoCommit(false);
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
try {
|
time_ms, nonce, message_type, raw_block, created_at_ms,
|
||||||
Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey());
|
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||||
Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey());
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
long currentRevision = Math.max(
|
""";
|
||||||
currentIncomingRevision != null ? currentIncomingRevision : Long.MIN_VALUE,
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
currentOutgoingRevision != null ? currentOutgoingRevision : Long.MIN_VALUE
|
ps.setString(1, e.getMessageKey());
|
||||||
);
|
ps.setString(2, e.getBaseKey());
|
||||||
long nextRevision = incoming.getRevisionTimeMs();
|
ps.setString(3, e.getTargetLogin());
|
||||||
|
ps.setString(4, e.getFromLogin());
|
||||||
if (currentRevision != Long.MIN_VALUE && nextRevision < currentRevision) {
|
ps.setString(5, e.getToLogin());
|
||||||
c.rollback();
|
ps.setLong(6, e.getTimeMs());
|
||||||
return false;
|
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);
|
private boolean isConstraintViolation(SQLException ex) {
|
||||||
upsertMessage(c, outgoing);
|
String msg = String.valueOf(ex.getMessage()).toLowerCase();
|
||||||
resetDeliveryRows(c, incoming.getMessageKey());
|
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
||||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
|
||||||
|
|
||||||
c.commit();
|
|
||||||
return true;
|
|
||||||
} catch (Exception ex) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw ex;
|
|
||||||
} finally {
|
|
||||||
c.setAutoCommit(prevAutoCommit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
||||||
@ -113,7 +123,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
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
|
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||||
FROM signed_messages_v2
|
FROM signed_messages_v2
|
||||||
WHERE message_key = ?
|
WHERE message_key = ?
|
||||||
@ -193,13 +203,13 @@ public final class SignedMessagesV2DAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
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
|
m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type
|
||||||
FROM signed_messages_v2 m
|
FROM signed_messages_v2 m
|
||||||
JOIN signed_message_session_delivery d
|
JOIN signed_message_session_delivery d
|
||||||
ON d.message_key = m.message_key
|
ON d.message_key = m.message_key
|
||||||
WHERE d.session_id = ? AND d.delivered = 0
|
WHERE d.session_id = ? AND d.delivered = 0
|
||||||
ORDER BY m.time_ms ASC, m.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<>();
|
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
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 {
|
private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception {
|
||||||
SignedMessageV2Entry e = new SignedMessageV2Entry();
|
SignedMessageV2Entry e = new SignedMessageV2Entry();
|
||||||
e.setMessageKey(rs.getString("message_key"));
|
e.setMessageKey(rs.getString("message_key"));
|
||||||
@ -322,7 +232,6 @@ public final class SignedMessagesV2DAO {
|
|||||||
e.setTimeMs(rs.getLong("time_ms"));
|
e.setTimeMs(rs.getLong("time_ms"));
|
||||||
e.setNonce(rs.getLong("nonce"));
|
e.setNonce(rs.getLong("nonce"));
|
||||||
e.setMessageType(rs.getInt("message_type"));
|
e.setMessageType(rs.getInt("message_type"));
|
||||||
e.setRevisionTimeMs(rs.getLong("revision_time_ms"));
|
|
||||||
e.setRawBlock(rs.getBytes("raw_block"));
|
e.setRawBlock(rs.getBytes("raw_block"));
|
||||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||||
e.setSourceApi(rs.getString("source_api"));
|
e.setSourceApi(rs.getString("source_api"));
|
||||||
|
|||||||
@ -9,7 +9,6 @@ public class SignedMessageV2Entry {
|
|||||||
private long timeMs;
|
private long timeMs;
|
||||||
private long nonce;
|
private long nonce;
|
||||||
private int messageType;
|
private int messageType;
|
||||||
private long revisionTimeMs;
|
|
||||||
private byte[] rawBlock;
|
private byte[] rawBlock;
|
||||||
private long createdAtMs;
|
private long createdAtMs;
|
||||||
private String sourceApi;
|
private String sourceApi;
|
||||||
@ -33,8 +32,6 @@ public class SignedMessageV2Entry {
|
|||||||
public void setNonce(long nonce) { this.nonce = nonce; }
|
public void setNonce(long nonce) { this.nonce = nonce; }
|
||||||
public int getMessageType() { return messageType; }
|
public int getMessageType() { return messageType; }
|
||||||
public void setMessageType(int messageType) { this.messageType = 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 byte[] getRawBlock() { return rawBlock; }
|
||||||
public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; }
|
public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; }
|
||||||
public long getCreatedAtMs() { return createdAtMs; }
|
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_ListSessions_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListEspPairingRequests_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_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_GetEspPairingStatus_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_GetTrustedDeviceLoginSettings_Handler;
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_RejectEspPairing_Handler;
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_RejectEspPairing_Handler;
|
||||||
|
|
||||||
// --- NEW v2 session login ---
|
// --- NEW v2 session login ---
|
||||||
@ -29,9 +27,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRe
|
|||||||
|
|
||||||
// --- NEW v2 entities ---
|
// --- 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_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_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_RejectEspPairing_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_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;
|
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("ListEspPairingRequests", new Net_ListEspPairingRequests_Handler()),
|
||||||
Map.entry("ApproveEspPairing", new Net_ApproveEspPairing_Handler()),
|
Map.entry("ApproveEspPairing", new Net_ApproveEspPairing_Handler()),
|
||||||
Map.entry("RejectEspPairing", new Net_RejectEspPairing_Handler()),
|
Map.entry("RejectEspPairing", new Net_RejectEspPairing_Handler()),
|
||||||
Map.entry("CancelEspPairing", new Net_CancelEspPairing_Handler()),
|
|
||||||
Map.entry("GetEspPairingStatus", new Net_GetEspPairingStatus_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 ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||||
@ -215,16 +202,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ListEspPairingRequests", Net_ListEspPairingRequests_Request.class),
|
Map.entry("ListEspPairingRequests", Net_ListEspPairingRequests_Request.class),
|
||||||
Map.entry("ApproveEspPairing", Net_ApproveEspPairing_Request.class),
|
Map.entry("ApproveEspPairing", Net_ApproveEspPairing_Request.class),
|
||||||
Map.entry("RejectEspPairing", Net_RejectEspPairing_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("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 ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
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.ActiveConnectionsRegistry;
|
||||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
import shine.db.entities.ActiveSessionEntry;
|
import shine.db.entities.ActiveSessionEntry;
|
||||||
import utils.crypto.HashSHA256Util;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
@ -28,10 +27,7 @@ final class EspPairingSupport {
|
|||||||
static final String STATE_CREATED = "created";
|
static final String STATE_CREATED = "created";
|
||||||
static final String STATE_APPROVED = "approved";
|
static final String STATE_APPROVED = "approved";
|
||||||
static final String STATE_REJECTED = "rejected";
|
static final String STATE_REJECTED = "rejected";
|
||||||
static final String STATE_CANCELED = "canceled";
|
|
||||||
static final String STATE_EXPIRED = "expired";
|
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 SecureRandom RANDOM = new SecureRandom();
|
||||||
private static final char[] BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray();
|
private static final char[] BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray();
|
||||||
@ -80,30 +76,6 @@ final class EspPairingSupport {
|
|||||||
return value;
|
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) {
|
static String normalizeEncryptedPayload(String raw) {
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
String value = raw.trim();
|
String value = raw.trim();
|
||||||
@ -176,14 +148,5 @@ final class EspPairingSupport {
|
|||||||
return remainder;
|
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) {}
|
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)) {
|
if (!EspPairingSupport.isSupportedPayloadType(payloadType)) {
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PAYLOAD_TYPE", "payloadType должен быть 1, 2 или 3");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PAYLOAD_TYPE", "payloadType должен быть 1, 2 или 3");
|
||||||
}
|
}
|
||||||
String rawPasswordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
|
String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
|
||||||
String passwordHash = EspPairingSupport.normalizePasswordHash(rawPasswordHash);
|
if (passwordHash == null) {
|
||||||
if (rawPasswordHash != null && passwordHash == null) {
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Пустой passwordHash");
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PASSWORD_HASH_FORMAT", "passwordHash должен быть пустым или иметь формат sha256$<64 hex>");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login);
|
SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login);
|
||||||
@ -67,8 +66,7 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
|
|||||||
String canonicalLogin = user.getLogin();
|
String canonicalLogin = user.getLogin();
|
||||||
|
|
||||||
EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin);
|
EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin);
|
||||||
boolean enabled = settings == null || settings.isEnabled();
|
if (settings == null || !settings.isEnabled() || settings.getPasswordHash() == null || settings.getPasswordHash().isBlank()) {
|
||||||
if (!enabled) {
|
|
||||||
return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен");
|
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) {
|
if (recentAttempts >= EspPairingSupport.REQUEST_RATE_LIMIT) {
|
||||||
return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Слишком много pairing-запросов за короткое время");
|
return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Слишком много pairing-запросов за короткое время");
|
||||||
}
|
}
|
||||||
String configuredPasswordHash = settings == null || settings.getPasswordHash() == null
|
if (!settings.getPasswordHash().equals(passwordHash)) {
|
||||||
? ""
|
|
||||||
: settings.getPasswordHash().trim();
|
|
||||||
boolean requiresPassword = !configuredPasswordHash.isBlank();
|
|
||||||
boolean suppliedPassword = passwordHash != null && !passwordHash.isBlank();
|
|
||||||
if ((requiresPassword && !configuredPasswordHash.equals(passwordHash))
|
|
||||||
|| (!requiresPassword && suppliedPassword)) {
|
|
||||||
return NetExceptionResponseFactory.error(req, 422, "PAIRING_PASSWORD_INVALID", "Неверный pairing-пароль");
|
return NetExceptionResponseFactory.error(req, 422, "PAIRING_PASSWORD_INVALID", "Неверный pairing-пароль");
|
||||||
}
|
}
|
||||||
|
|
||||||
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
|
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
|
||||||
int ttlSeconds = EspPairingSupport.DEFAULT_TTL_SECONDS;
|
int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(settings.getTtlSeconds());
|
||||||
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
|
|
||||||
if (approverConnections.isEmpty()) {
|
|
||||||
return NetExceptionResponseFactory.error(
|
|
||||||
req,
|
|
||||||
422,
|
|
||||||
"PAIRING_NO_TRUSTED_SESSION_ONLINE",
|
|
||||||
"Нет ни одной активной доверенной сессии пользователя в сети"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
|
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
|
||||||
canonicalLogin,
|
canonicalLogin,
|
||||||
requesterSessionKey,
|
requesterSessionKey,
|
||||||
@ -132,6 +115,7 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
|
|||||||
entry.setDeliveredToHomeserver(false);
|
entry.setDeliveredToHomeserver(false);
|
||||||
EspPairingRequestsDAO.getInstance().insert(entry);
|
EspPairingRequestsDAO.getInstance().insert(entry);
|
||||||
|
|
||||||
|
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
|
||||||
boolean delivered = false;
|
boolean delivered = false;
|
||||||
for (ConnectionContext targetCtx : approverConnections) {
|
for (ConnectionContext targetCtx : approverConnections) {
|
||||||
String eventId = NetIdGenerator.eventId("pair");
|
String eventId = NetIdGenerator.eventId("pair");
|
||||||
@ -146,7 +130,7 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
|
|||||||
payload.put("fingerprintB58", entry.getFingerprintB58());
|
payload.put("fingerprintB58", entry.getFingerprintB58());
|
||||||
payload.put("createdAtMs", entry.getCreatedAtMs());
|
payload.put("createdAtMs", entry.getCreatedAtMs());
|
||||||
payload.put("expiresAtMs", entry.getExpiresAtMs());
|
payload.put("expiresAtMs", entry.getExpiresAtMs());
|
||||||
delivered |= WsEventSender.sendEvent(targetCtx, "IncomingTrustedDeviceLoginRequest", eventId, payload);
|
delivered |= WsEventSender.sendEvent(targetCtx, "IncomingEspPairingRequest", eventId, payload);
|
||||||
}
|
}
|
||||||
if (delivered) {
|
if (delivered) {
|
||||||
EspPairingRequestsDAO.getInstance().updateDeliveryFlag(entry.getPairingId(), true, System.currentTimeMillis());
|
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();
|
boolean enabled = req.getEnabled() != null && req.getEnabled();
|
||||||
String rawPasswordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
|
String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
|
||||||
String passwordHash = EspPairingSupport.normalizePasswordHash(rawPasswordHash);
|
int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(req.getTtlSeconds());
|
||||||
if (rawPasswordHash != null && passwordHash == null) {
|
if (enabled && (passwordHash == null || passwordHash.isBlank())) {
|
||||||
return NetExceptionResponseFactory.error(
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Для включения pairing нужен passwordHash");
|
||||||
req,
|
|
||||||
WireCodes.Status.BAD_REQUEST,
|
|
||||||
"BAD_PASSWORD_HASH_FORMAT",
|
|
||||||
"passwordHash должен быть пустым или иметь формат sha256$<64 hex>"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
EspPairingSettingsEntry entry = new EspPairingSettingsEntry();
|
EspPairingSettingsEntry entry = new EspPairingSettingsEntry();
|
||||||
entry.setLogin(ctx.getLogin());
|
entry.setLogin(ctx.getLogin());
|
||||||
entry.setEnabled(enabled);
|
entry.setEnabled(enabled);
|
||||||
entry.setPasswordHash(enabled && passwordHash != null ? passwordHash : "");
|
entry.setPasswordHash(passwordHash == null ? "" : passwordHash);
|
||||||
entry.setTtlSeconds(EspPairingSupport.DEFAULT_TTL_SECONDS);
|
entry.setTtlSeconds(ttlSeconds);
|
||||||
entry.setFailedAttempts(0);
|
entry.setFailedAttempts(0);
|
||||||
entry.setFirstFailedAtMs(0L);
|
entry.setFirstFailedAtMs(0L);
|
||||||
entry.setBlockedUntilMs(0L);
|
entry.setBlockedUntilMs(0L);
|
||||||
@ -54,7 +50,7 @@ public class Net_UpsertEspPairingSettings_Handler implements JsonMessageHandler
|
|||||||
resp.setRequestId(req.getRequestId());
|
resp.setRequestId(req.getRequestId());
|
||||||
resp.setStatus(WireCodes.Status.OK);
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
resp.setEnabled(enabled);
|
resp.setEnabled(enabled);
|
||||||
resp.setHasPassword(enabled && passwordHash != null && !passwordHash.isBlank());
|
resp.setTtlSeconds(ttlSeconds);
|
||||||
return resp;
|
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 {
|
public class Net_UpsertEspPairingSettings_Response extends Net_Response {
|
||||||
private boolean enabled;
|
private boolean enabled;
|
||||||
private boolean hasPassword;
|
private int ttlSeconds;
|
||||||
|
|
||||||
public boolean isEnabled() {
|
public boolean isEnabled() {
|
||||||
return enabled;
|
return enabled;
|
||||||
@ -14,11 +14,11 @@ public class Net_UpsertEspPairingSettings_Response extends Net_Response {
|
|||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isHasPassword() {
|
public int getTtlSeconds() {
|
||||||
return hasPassword;
|
return ttlSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setHasPassword(boolean hasPassword) {
|
public void setTtlSeconds(int ttlSeconds) {
|
||||||
this.hasPassword = hasPassword;
|
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.messages.entyties.Net_ReceiveIncomingMessage_Response;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import shine.db.dao.SignedMessagesV2DAO;
|
|
||||||
import shine.db.entities.SignedMessageV2Entry;
|
import shine.db.entities.SignedMessageV2Entry;
|
||||||
|
|
||||||
public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
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 подтверждения");
|
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();
|
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (inserted) {
|
if (inserted) {
|
||||||
counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null);
|
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 подтверждения");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean pairInserted;
|
boolean pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);
|
||||||
if (incoming.isContentType()) {
|
|
||||||
pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPair(
|
|
||||||
incomingEntry, outgoingEntry
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (pairInserted) {
|
if (pairInserted) {
|
||||||
inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, incoming);
|
inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
String excludeSessionId = null;
|
String excludeSessionId = null;
|
||||||
@ -69,7 +62,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (pairInserted) {
|
if (pairInserted) {
|
||||||
outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, outgoing, excludeSessionId);
|
outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, excludeSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
||||||
|
|||||||
@ -6,8 +6,7 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
final class SignedMessageBlock {
|
final class SignedMessageBlock {
|
||||||
static final byte[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
|
static final byte[] PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
|
||||||
static final byte[] V1_PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII);
|
|
||||||
static final int TYPE_INCOMING_TEXT = 1;
|
static final int TYPE_INCOMING_TEXT = 1;
|
||||||
static final int TYPE_OUTGOING_COPY = 2;
|
static final int TYPE_OUTGOING_COPY = 2;
|
||||||
static final int TYPE_READ_INCOMING = 3;
|
static final int TYPE_READ_INCOMING = 3;
|
||||||
@ -18,15 +17,10 @@ final class SignedMessageBlock {
|
|||||||
final long timeMs;
|
final long timeMs;
|
||||||
final long nonce;
|
final long nonce;
|
||||||
final int messageType;
|
final int messageType;
|
||||||
final long revisionTimeMs;
|
|
||||||
final int formatVersionMajor;
|
|
||||||
final int formatVersionMinor;
|
|
||||||
final byte[] payloadBytes;
|
final byte[] payloadBytes;
|
||||||
final byte[] encryptedBodyBytes;
|
|
||||||
final byte[] signedBody;
|
final byte[] signedBody;
|
||||||
final byte[] signature64;
|
final byte[] signature64;
|
||||||
final byte[] rawPacket;
|
final byte[] rawPacket;
|
||||||
final boolean legacyFormat;
|
|
||||||
|
|
||||||
private SignedMessageBlock(
|
private SignedMessageBlock(
|
||||||
String toLogin,
|
String toLogin,
|
||||||
@ -34,53 +28,37 @@ final class SignedMessageBlock {
|
|||||||
long timeMs,
|
long timeMs,
|
||||||
long nonce,
|
long nonce,
|
||||||
int messageType,
|
int messageType,
|
||||||
long revisionTimeMs,
|
|
||||||
int formatVersionMajor,
|
|
||||||
int formatVersionMinor,
|
|
||||||
byte[] payloadBytes,
|
byte[] payloadBytes,
|
||||||
byte[] encryptedBodyBytes,
|
|
||||||
byte[] signedBody,
|
byte[] signedBody,
|
||||||
byte[] signature64,
|
byte[] signature64,
|
||||||
byte[] rawPacket,
|
byte[] rawPacket
|
||||||
boolean legacyFormat
|
|
||||||
) {
|
) {
|
||||||
this.toLogin = toLogin;
|
this.toLogin = toLogin;
|
||||||
this.fromLogin = fromLogin;
|
this.fromLogin = fromLogin;
|
||||||
this.timeMs = timeMs;
|
this.timeMs = timeMs;
|
||||||
this.nonce = nonce;
|
this.nonce = nonce;
|
||||||
this.messageType = messageType;
|
this.messageType = messageType;
|
||||||
this.revisionTimeMs = revisionTimeMs;
|
|
||||||
this.formatVersionMajor = formatVersionMajor;
|
|
||||||
this.formatVersionMinor = formatVersionMinor;
|
|
||||||
this.payloadBytes = payloadBytes;
|
this.payloadBytes = payloadBytes;
|
||||||
this.encryptedBodyBytes = encryptedBodyBytes;
|
|
||||||
this.signedBody = signedBody;
|
this.signedBody = signedBody;
|
||||||
this.signature64 = signature64;
|
this.signature64 = signature64;
|
||||||
this.rawPacket = rawPacket;
|
this.rawPacket = rawPacket;
|
||||||
this.legacyFormat = legacyFormat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static SignedMessageBlock parse(byte[] raw, int maxEncryptedBodyBytes) {
|
static SignedMessageBlock parse(byte[] raw, int maxPayloadBytes) {
|
||||||
if (raw == null || raw.length < 64) {
|
if (raw == null || raw.length < PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) {
|
||||||
throw new IllegalArgumentException("BAD_LEN");
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
}
|
}
|
||||||
|
if (raw.length > 8192) {
|
||||||
|
throw new IllegalArgumentException("PAYLOAD_TOO_LARGE");
|
||||||
|
}
|
||||||
|
|
||||||
if (startsWith(raw, LEGACY_PREFIX)) {
|
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
||||||
return parseLegacy(raw, maxEncryptedBodyBytes);
|
byte[] prefix = new byte[PREFIX.length];
|
||||||
}
|
bb.get(prefix);
|
||||||
if (startsWith(raw, V1_PREFIX)) {
|
if (!Arrays.equals(prefix, PREFIX)) {
|
||||||
return parseV1(raw, maxEncryptedBodyBytes);
|
|
||||||
}
|
|
||||||
throw new IllegalArgumentException("BAD_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 toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN");
|
||||||
String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN");
|
String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN");
|
||||||
|
|
||||||
@ -89,7 +67,9 @@ final class SignedMessageBlock {
|
|||||||
|
|
||||||
long nonce = Integer.toUnsignedLong(bb.getInt());
|
long nonce = Integer.toUnsignedLong(bb.getInt());
|
||||||
int messageType = Short.toUnsignedInt(bb.getShort());
|
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());
|
int payloadLen = Short.toUnsignedInt(bb.getShort());
|
||||||
if (payloadLen < 1 || payloadLen > maxPayloadBytes) {
|
if (payloadLen < 1 || payloadLen > maxPayloadBytes) {
|
||||||
@ -106,82 +86,7 @@ final class SignedMessageBlock {
|
|||||||
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
||||||
|
|
||||||
return new SignedMessageBlock(
|
return new SignedMessageBlock(
|
||||||
toLogin,
|
toLogin, fromLogin, timeMs, nonce, messageType, payload, signedBody, signature64, raw
|
||||||
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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,36 +98,10 @@ final class SignedMessageBlock {
|
|||||||
return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY;
|
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() {
|
String targetLogin() {
|
||||||
return isIncomingType() ? toLogin : fromLogin;
|
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) {
|
private static String readAscii(ByteBuffer bb, int minLen, int maxLen, String code) {
|
||||||
if (!bb.hasRemaining()) throw new IllegalArgumentException(code);
|
if (!bb.hasRemaining()) throw new IllegalArgumentException(code);
|
||||||
int len = Byte.toUnsignedInt(bb.get());
|
int len = Byte.toUnsignedInt(bb.get());
|
||||||
|
|||||||
@ -1,41 +1,25 @@
|
|||||||
package server.logic.ws_protocol.JSON.messages;
|
package server.logic.ws_protocol.JSON.messages;
|
||||||
|
|
||||||
|
import shine.db.dao.SignedMessagesV2DAO;
|
||||||
import shine.db.dao.SolanaUsersDAO;
|
import shine.db.dao.SolanaUsersDAO;
|
||||||
import shine.db.entities.SignedMessageV2Entry;
|
import shine.db.entities.SignedMessageV2Entry;
|
||||||
import shine.db.entities.SolanaUserEntry;
|
import shine.db.entities.SolanaUserEntry;
|
||||||
import utils.crypto.Ed25519Util;
|
import utils.crypto.Ed25519Util;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
|
||||||
final class SignedMessagesCore {
|
final class SignedMessagesCore {
|
||||||
private static final int MAX_ENCRYPTED_BODY_BYTES = 16384;
|
private static final int MAX_PAYLOAD_BYTES = 4096;
|
||||||
|
|
||||||
private SignedMessagesCore() {}
|
private SignedMessagesCore() {}
|
||||||
|
|
||||||
static SignedMessageBlock parseFromB64(String blobB64) {
|
static SignedMessageBlock parseFromB64(String blobB64) {
|
||||||
try {
|
try {
|
||||||
byte[] raw = Base64.getDecoder().decode(blobB64.trim());
|
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) {
|
} catch (IllegalArgumentException e) {
|
||||||
String code = e.getMessage();
|
|
||||||
if (code == null || code.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
|
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 {
|
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.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
||||||
if (incoming.nonce != outgoing.nonce) 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 inRef = ReadReceiptPayload.parse(incoming.payloadBytes);
|
||||||
ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes);
|
ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes);
|
||||||
if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin)
|
if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin)
|
||||||
@ -68,27 +52,6 @@ final class SignedMessagesCore {
|
|||||||
|| inRef.refType != outRef.refType) {
|
|| inRef.refType != outRef.refType) {
|
||||||
throw new IllegalArgumentException("BAD_RECEIPT_REF");
|
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.setTimeMs(block.timeMs);
|
||||||
entry.setNonce(block.nonce);
|
entry.setNonce(block.nonce);
|
||||||
entry.setMessageType(block.messageType);
|
entry.setMessageType(block.messageType);
|
||||||
entry.setRevisionTimeMs(block.revisionTimeMs);
|
|
||||||
entry.setRawBlock(block.rawPacket);
|
entry.setRawBlock(block.rawPacket);
|
||||||
entry.setCreatedAtMs(System.currentTimeMillis());
|
entry.setCreatedAtMs(System.currentTimeMillis());
|
||||||
entry.setSourceApi(sourceApi);
|
entry.setSourceApi(sourceApi);
|
||||||
@ -121,10 +83,7 @@ final class SignedMessagesCore {
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String previewTextForPush(SignedMessageBlock block) {
|
static boolean saveIfAbsent(SignedMessageV2Entry entry) throws Exception {
|
||||||
if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) {
|
return SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,13 +21,8 @@ public final class SignedMessagesRealtime {
|
|||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private SignedMessagesRealtime() {}
|
private SignedMessagesRealtime() {}
|
||||||
|
|
||||||
static DeliveryCounters deliverToTargetSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception {
|
|
||||||
return deliverToTargetSessions(message, block, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
static DeliveryCounters deliverToTargetSessions(
|
static DeliveryCounters deliverToTargetSessions(
|
||||||
SignedMessageV2Entry message,
|
SignedMessageV2Entry message,
|
||||||
SignedMessageBlock block,
|
|
||||||
String excludeSessionId
|
String excludeSessionId
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
DeliveryCounters counters = new DeliveryCounters();
|
DeliveryCounters counters = new DeliveryCounters();
|
||||||
@ -44,11 +39,8 @@ public final class SignedMessagesRealtime {
|
|||||||
counters.wsDelivered++;
|
counters.wsDelivered++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT
|
if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT) {
|
||||||
&& block != null
|
boolean pushed = pushNewMessageNotification(s, message);
|
||||||
&& block.revisionTimeMs == 0
|
|
||||||
&& !block.isDeletedContent()) {
|
|
||||||
boolean pushed = pushNewMessageNotification(s, message, block);
|
|
||||||
if (pushed) counters.pushDelivered++;
|
if (pushed) counters.pushDelivered++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -97,21 +89,13 @@ public final class SignedMessagesRealtime {
|
|||||||
return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
|
return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean pushNewMessageNotification(
|
private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) {
|
||||||
ActiveSessionEntry session,
|
|
||||||
SignedMessageV2Entry message,
|
|
||||||
SignedMessageBlock block
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
if (session == null) return false;
|
if (session == null) return false;
|
||||||
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
String preview = SignedMessagesCore.previewTextForPush(block).replace('\n', ' ').trim();
|
String text = "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения.";
|
||||||
if (preview.length() > 80) preview = preview.substring(0, 80) + "...";
|
|
||||||
String text = preview.isBlank()
|
|
||||||
? "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения."
|
|
||||||
: preview;
|
|
||||||
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
|
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
|
||||||
return WebPushSender.sendBase64Payload(
|
return WebPushSender.sendBase64Payload(
|
||||||
session.getPushEndpoint(),
|
session.getPushEndpoint(),
|
||||||
|
|||||||
@ -9,11 +9,9 @@ import test.it.utils.ws.WsSession;
|
|||||||
import utils.crypto.Ed25519Util;
|
import utils.crypto.Ed25519Util;
|
||||||
import shine.db.dao.SolanaUsersDAO;
|
import shine.db.dao.SolanaUsersDAO;
|
||||||
import shine.db.entities.SolanaUserEntry;
|
import shine.db.entities.SolanaUserEntry;
|
||||||
import utils.crypto.HashSHA256Util;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
@ -40,7 +38,7 @@ public class IT_07_EspPairing {
|
|||||||
|
|
||||||
sessionLogin2Steps(clientWs, clientSession, 1, "Web", t, r);
|
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(
|
String upsertResp = clientWs.call(
|
||||||
"UpsertEspPairingSettings",
|
"UpsertEspPairingSettings",
|
||||||
JsonBuilders.upsertEspPairingSettings(true, passwordHash, 180),
|
JsonBuilders.upsertEspPairingSettings(true, passwordHash, 180),
|
||||||
@ -81,59 +79,6 @@ public class IT_07_EspPairing {
|
|||||||
assertEquals("approved", JsonParsers.payloadText(statusResp, "state"));
|
assertEquals("approved", JsonParsers.payloadText(statusResp, "state"));
|
||||||
assertEquals("AQIDBA==", JsonParsers.payloadText(statusResp, "encryptedPayload"));
|
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(
|
String forbiddenResp = requesterWs.call(
|
||||||
"ListEspPairingRequests#anonymous",
|
"ListEspPairingRequests#anonymous",
|
||||||
JsonBuilders.listEspPairingRequests(),
|
JsonBuilders.listEspPairingRequests(),
|
||||||
@ -141,7 +86,7 @@ public class IT_07_EspPairing {
|
|||||||
);
|
);
|
||||||
assertErrorFormat(forbiddenResp, "ListEspPairingRequests", "PAIRING_REQUIRES_AUTH_SESSION");
|
assertErrorFormat(forbiddenResp, "ListEspPairingRequests", "PAIRING_REQUIRES_AUTH_SESSION");
|
||||||
|
|
||||||
r.ok("ESP pairing: доверенная сессия принимает заявки как с доп. паролем, так и без него");
|
r.ok("ESP pairing: обычная доверенная сессия увидела запрос и подтвердила зашифрованный payload");
|
||||||
}
|
}
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
r.fail("IT_07_EspPairing упал: " + e.getMessage());
|
r.fail("IT_07_EspPairing упал: " + e.getMessage());
|
||||||
@ -220,17 +165,6 @@ public class IT_07_EspPairing {
|
|||||||
SolanaUsersDAO.getInstance().insert(entry);
|
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 Session(String sessionId, String sessionKey, byte[] sessionPrivKey, String storagePwd) {}
|
||||||
private record SessionMaterial(String sessionKey, byte[] sessionPrivKey) {}
|
private record SessionMaterial(String sessionKey, byte[] sessionPrivKey) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -333,20 +333,6 @@ public final class JsonBuilders {
|
|||||||
""".formatted(requestId, pairingId, reason == null ? "" : reason);
|
""".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) {
|
public static String getEspPairingStatus(String pairingId) {
|
||||||
String requestId = TestIds.next("esp-status");
|
String requestId = TestIds.next("esp-status");
|
||||||
return """
|
return """
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.216
|
client.version=1.2.192
|
||||||
server.version=1.2.204
|
server.version=1.2.181
|
||||||
|
|||||||
11
build.gradle
11
build.gradle
@ -292,6 +292,17 @@ tasks.register('startLocalWithBuild') {
|
|||||||
dependsOn tasks.named('startLocal')
|
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 {
|
tasks.named('startLocal').configure {
|
||||||
mustRunAfter tasks.named('build')
|
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