Compare commits
23 Commits
b166013707
...
f2b23ace8b
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
f2b23ace8b | ||
|
|
1f2048e270 | ||
|
|
b16a23243e | ||
|
|
653f1268a6 | ||
|
|
56db6d0add | ||
|
|
cf2152dcfc | ||
|
|
a95bd245cf | ||
|
|
92fd315505 | ||
|
|
2225c2d173 | ||
|
|
f8a76bcd7f | ||
|
|
3efa8bb7ee | ||
|
|
5c155ef503 | ||
|
|
41d199e24a | ||
|
|
e1f2b54de3 | ||
|
|
d6c5757dfa | ||
|
|
9a489801c5 | ||
|
|
9fcdcd087b | ||
|
|
af1304022e | ||
|
|
7972676eb8 | ||
|
|
bef205aec7 | ||
|
|
49fdbbf7ae | ||
|
|
dd69a52273 | ||
|
|
c681b4d684 |
@ -303,12 +303,14 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
|
|||||||
|
|
||||||
Новые `op`, относящиеся к этому сценарию:
|
Новые `op`, относящиеся к этому сценарию:
|
||||||
|
|
||||||
- `UpsertEspPairingSettings`
|
- `GetTrustedDeviceLoginSettings`
|
||||||
- `StartEspPairing`
|
- `UpsertTrustedDeviceLoginSettings`
|
||||||
- `ListEspPairingRequests`
|
- `StartTrustedDeviceLogin`
|
||||||
- `ApproveEspPairing`
|
- `ListTrustedDeviceLoginRequests`
|
||||||
- `RejectEspPairing`
|
- `ApproveTrustedDeviceLogin`
|
||||||
- `GetEspPairingStatus`
|
- `RejectTrustedDeviceLogin`
|
||||||
|
- `CancelTrustedDeviceLogin`
|
||||||
|
- `GetTrustedDeviceLoginStatus`
|
||||||
|
|
||||||
В этом потоке:
|
В этом потоке:
|
||||||
|
|
||||||
|
|||||||
@ -9,15 +9,17 @@
|
|||||||
|
|
||||||
Дополнительно в этом же слое управления сессиями появился сценарий pairing через доверенную уже авторизованную сессию пользователя:
|
Дополнительно в этом же слое управления сессиями появился сценарий pairing через доверенную уже авторизованную сессию пользователя:
|
||||||
|
|
||||||
- `UpsertEspPairingSettings`
|
- `GetTrustedDeviceLoginSettings`
|
||||||
- `ListEspPairingRequests`
|
- `UpsertTrustedDeviceLoginSettings`
|
||||||
- `ApproveEspPairing`
|
- `ListTrustedDeviceLoginRequests`
|
||||||
- `RejectEspPairing`
|
- `ApproveTrustedDeviceLogin`
|
||||||
|
- `RejectTrustedDeviceLogin`
|
||||||
|
- `CancelTrustedDeviceLogin`
|
||||||
|
|
||||||
Анонимное новое устройство работает с двумя связанными операциями:
|
Анонимное новое устройство работает с двумя связанными операциями:
|
||||||
|
|
||||||
- `StartEspPairing`
|
- `StartTrustedDeviceLogin`
|
||||||
- `GetEspPairingStatus`
|
- `GetTrustedDeviceLoginStatus`
|
||||||
|
|
||||||
Логика раздела такая:
|
Логика раздела такая:
|
||||||
|
|
||||||
@ -166,11 +168,11 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. ESP pairing через доверенную сессию
|
## 5. TrustedDeviceLogin через доверенную сессию
|
||||||
|
|
||||||
Этот блок относится к сценарию добавления новой сессии через доверенное устройство пользователя.
|
Этот блок относится к сценарию добавления новой сессии через доверенное устройство пользователя.
|
||||||
|
|
||||||
### 5.1. `UpsertEspPairingSettings`
|
### 5.1. `GetTrustedDeviceLoginSettings`
|
||||||
|
|
||||||
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||||
|
|
||||||
@ -178,12 +180,9 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "UpsertEspPairingSettings",
|
"op": "GetTrustedDeviceLoginSettings",
|
||||||
"requestId": "esp-set-001",
|
"requestId": "trusted-login-get-001",
|
||||||
"payload": {
|
"payload": {
|
||||||
"enabled": true,
|
|
||||||
"passwordHash": "argon2id$...",
|
|
||||||
"ttlSeconds": 180
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -192,23 +191,73 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "UpsertEspPairingSettings",
|
"op": "GetTrustedDeviceLoginSettings",
|
||||||
|
"requestId": "trusted-login-get-001",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"enabled": true,
|
||||||
|
"hasPassword": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Если отдельной записи настроек на сервере ещё нет, сервер считает состояние по умолчанию таким:
|
||||||
|
|
||||||
|
- `enabled = true`
|
||||||
|
- `hasPassword = false`
|
||||||
|
|
||||||
|
### Ошибки
|
||||||
|
|
||||||
|
- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя.
|
||||||
|
|
||||||
|
### 5.2. `UpsertTrustedDeviceLoginSettings`
|
||||||
|
|
||||||
|
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||||
|
|
||||||
|
### Запрос
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "UpsertTrustedDeviceLoginSettings",
|
||||||
|
"requestId": "esp-set-001",
|
||||||
|
"payload": {
|
||||||
|
"enabled": true,
|
||||||
|
"passwordHash": "sha256$0123abcd..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Если вход через доверенное устройство должен работать **без доп. пароля**, клиент включает его с пустым `passwordHash`.
|
||||||
|
|
||||||
|
Если `enabled = false`, сервер автоматически удаляет пароль и запрещает вход через другое устройство.
|
||||||
|
|
||||||
|
Формат непустого `passwordHash`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Успешный ответ
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "UpsertTrustedDeviceLoginSettings",
|
||||||
"requestId": "esp-set-001",
|
"requestId": "esp-set-001",
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"payload": {
|
"payload": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"ttlSeconds": 180
|
"hasPassword": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ошибки
|
### Ошибки
|
||||||
|
|
||||||
- `400 / EMPTY_PASSWORD_HASH` — попытка включить pairing без `passwordHash`.
|
|
||||||
- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя.
|
- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя.
|
||||||
|
|
||||||
### 5.2. `StartEspPairing`
|
### 5.3. `StartTrustedDeviceLogin`
|
||||||
|
|
||||||
Эта операция доступна без уже существующей пользовательской сессии.
|
Эта операция доступна без уже существующей пользовательской сессии.
|
||||||
|
|
||||||
@ -216,11 +265,11 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "StartEspPairing",
|
"op": "StartTrustedDeviceLogin",
|
||||||
"requestId": "esp-start-001",
|
"requestId": "esp-start-001",
|
||||||
"payload": {
|
"payload": {
|
||||||
"login": "alice",
|
"login": "alice",
|
||||||
"passwordHash": "argon2id$...",
|
"passwordHash": "sha256$0123abcd...",
|
||||||
"requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY",
|
"requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY",
|
||||||
"requesterSessionType": 1,
|
"requesterSessionType": 1,
|
||||||
"requesterClientPlatform": "Android",
|
"requesterClientPlatform": "Android",
|
||||||
@ -229,13 +278,17 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Если на доверённом устройстве вход включён **без доп. пароля**, новое устройство может отправить пустой `passwordHash`.
|
||||||
|
|
||||||
Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку.
|
Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку.
|
||||||
|
|
||||||
|
TTL заявки фиксирован на сервере и сейчас всегда равен `300` секундам.
|
||||||
|
|
||||||
### Успешный ответ
|
### Успешный ответ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "StartEspPairing",
|
"op": "StartTrustedDeviceLogin",
|
||||||
"requestId": "esp-start-001",
|
"requestId": "esp-start-001",
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@ -253,24 +306,25 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
### Ошибки
|
### Ошибки
|
||||||
|
|
||||||
- `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`
|
- `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля.
|
||||||
|
- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` — сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся.
|
||||||
- `429 / PAIRING_RATE_LIMITED`
|
- `429 / PAIRING_RATE_LIMITED`
|
||||||
|
|
||||||
### 5.3. `ListEspPairingRequests`
|
### 5.4. `ListTrustedDeviceLoginRequests`
|
||||||
|
|
||||||
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||||
|
Возвращает только реально активные pending-заявки со `state = created`. Уже `approved` и `rejected` заявки в этот список больше не попадают.
|
||||||
|
|
||||||
### Успешный ответ
|
### Успешный ответ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "ListEspPairingRequests",
|
"op": "ListTrustedDeviceLoginRequests",
|
||||||
"requestId": "esp-list-001",
|
"requestId": "esp-list-001",
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@ -298,7 +352,7 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
|
|
||||||
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
|
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
|
||||||
|
|
||||||
### 5.4. `ApproveEspPairing`
|
### 5.5. `ApproveTrustedDeviceLogin`
|
||||||
|
|
||||||
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||||
|
|
||||||
@ -306,7 +360,7 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "ApproveEspPairing",
|
"op": "ApproveTrustedDeviceLogin",
|
||||||
"requestId": "esp-approve-001",
|
"requestId": "esp-approve-001",
|
||||||
"payload": {
|
"payload": {
|
||||||
"pairingId": "base64url",
|
"pairingId": "base64url",
|
||||||
@ -319,7 +373,7 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "ApproveEspPairing",
|
"op": "ApproveTrustedDeviceLogin",
|
||||||
"requestId": "esp-approve-001",
|
"requestId": "esp-approve-001",
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@ -340,11 +394,11 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
- `422 / PAIRING_EXPIRED`
|
- `422 / PAIRING_EXPIRED`
|
||||||
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
|
- `463 / PAIRING_REQUIRES_AUTH_SESSION`
|
||||||
|
|
||||||
### 5.5. `RejectEspPairing`
|
### 5.6. `RejectTrustedDeviceLogin`
|
||||||
|
|
||||||
Доступно для любой уже авторизованной доверенной сессии пользователя. Похоже на approve, но переводит заявку в `state=rejected`.
|
Доступно для любой уже авторизованной доверенной сессии пользователя. Похоже на approve, но переводит заявку в `state=rejected`.
|
||||||
|
|
||||||
### 5.6. `GetEspPairingStatus`
|
### 5.7. `GetTrustedDeviceLoginStatus`
|
||||||
|
|
||||||
Операция для нового устройства.
|
Операция для нового устройства.
|
||||||
|
|
||||||
@ -352,7 +406,7 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "GetEspPairingStatus",
|
"op": "GetTrustedDeviceLoginStatus",
|
||||||
"requestId": "esp-status-001",
|
"requestId": "esp-status-001",
|
||||||
"payload": {
|
"payload": {
|
||||||
"pairingId": "base64url"
|
"pairingId": "base64url"
|
||||||
@ -364,7 +418,7 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "GetEspPairingStatus",
|
"op": "GetTrustedDeviceLoginStatus",
|
||||||
"requestId": "esp-status-001",
|
"requestId": "esp-status-001",
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@ -385,4 +439,46 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
|
|||||||
- `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,12 +19,14 @@
|
|||||||
| `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` | вход в существующую сессию |
|
||||||
| `UpsertEspPairingSettings` | `03_Session_Management_API.md` | включение/обновление pairing-настроек доверенной сессией |
|
| `GetTrustedDeviceLoginSettings` | `03_Session_Management_API.md` | чтение текущего режима входа через доверенное устройство |
|
||||||
| `StartEspPairing` | `03_Session_Management_API.md` | создание pairing-заявки для нового устройства |
|
| `UpsertTrustedDeviceLoginSettings` | `03_Session_Management_API.md` | включение/обновление pairing-настроек доверенной сессией |
|
||||||
| `ListEspPairingRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии |
|
| `StartTrustedDeviceLogin` | `03_Session_Management_API.md` | создание pairing-заявки для нового устройства |
|
||||||
| `ApproveEspPairing` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией |
|
| `ListTrustedDeviceLoginRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии |
|
||||||
| `RejectEspPairing` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией |
|
| `ApproveTrustedDeviceLogin` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией |
|
||||||
| `GetEspPairingStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки |
|
| `RejectTrustedDeviceLogin` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией |
|
||||||
|
| `CancelTrustedDeviceLogin` | `03_Session_Management_API.md` | отмена pairing-заявки со стороны нового ожидающего устройства |
|
||||||
|
| `GetTrustedDeviceLoginStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки |
|
||||||
| `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` | добавление блока в блокчейн |
|
||||||
@ -60,5 +62,6 @@
|
|||||||
## Важные замечания
|
## Важные замечания
|
||||||
|
|
||||||
- `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,8 +1,10 @@
|
|||||||
# API для разработчиков: DM, push и сигналы звонков
|
# API для разработчиков: DM, push и сигналы звонков
|
||||||
|
|
||||||
Документ описывает WebSocket-операции для подписанных личных сообщений, WebPush и realtime-сигналов звонков.
|
Документ описывает публичные операции, связанные с личными сообщениями, WebPush и сигналами звонков.
|
||||||
|
|
||||||
Логика личных сообщений дополнительно описана в `Dev_Docs/Personal_Messages/README.md`; этот файл фиксирует именно публичные `op`, поля запросов и поля ответов.
|
Подробная логика DM и бинарного формата:
|
||||||
|
|
||||||
|
- `Dev_Docs/Personal_Messages/README.md`
|
||||||
|
|
||||||
## 1. `UpsertPushToken`
|
## 1. `UpsertPushToken`
|
||||||
|
|
||||||
@ -40,11 +42,9 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. `SendTestWebPush`
|
## 2. `SendTestWebPush`
|
||||||
|
|
||||||
Требует авторизации. Если `login` передан, он должен совпадать с логином текущей сессии.
|
Требует авторизации.
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
@ -61,65 +61,18 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Успешный ответ
|
## 3. `SendMessagePair` и `ReceiveOutcomingMessage`
|
||||||
|
|
||||||
```json
|
`ReceiveOutcomingMessage` — алиас `SendMessagePair`.
|
||||||
{
|
|
||||||
"op": "SendTestWebPush",
|
|
||||||
"requestId": "push-test-001",
|
|
||||||
"status": 200,
|
|
||||||
"ok": true,
|
|
||||||
"payload": {
|
|
||||||
"targetLogin": "alice",
|
|
||||||
"attemptedSessions": 1,
|
|
||||||
"sessionsWithPushConfig": 1,
|
|
||||||
"delivered": 1,
|
|
||||||
"failed": 0,
|
|
||||||
"sentAtMs": 1774700000123
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
### Назначение
|
||||||
|
|
||||||
## 3. `SendDirectMessage`
|
Передаёт пару signed DM-блоков:
|
||||||
|
|
||||||
Отправляет один подписанный DM-пакет.
|
- `incomingBlobB64` — блок `type=1` или `type=3`
|
||||||
|
- `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.
|
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
@ -143,20 +96,31 @@
|
|||||||
"status": 200,
|
"status": 200,
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"payload": {
|
"payload": {
|
||||||
"baseKey": "base-key",
|
"baseKey": "from|to|time|nonce",
|
||||||
"incomingKey": "incoming-key",
|
"incomingKey": "from|to|time|nonce|1",
|
||||||
"outgoingKey": "outgoing-key",
|
"outgoingKey": "from|to|time|nonce|2",
|
||||||
"deliveredWsSessions": 1,
|
"deliveredWsSessions": 1,
|
||||||
"deliveredWebPushSessions": 0
|
"deliveredWebPushSessions": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Ошибки
|
||||||
|
|
||||||
## 5. `ReceiveIncomingMessage`
|
- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64`
|
||||||
|
- `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён
|
||||||
|
- `400 / BAD_CONTENT_FORMAT` — для контентного сообщения пришёл не `SHiNE_DM`
|
||||||
|
- `400 / ATTACHMENTS_DISABLED` — в `SHiNE_DM` пришёл `attachmentsCount != 0`
|
||||||
|
- `404 / USER_NOT_FOUND` — один из логинов не найден
|
||||||
|
- `460 / BAD_SIGNATURE` — подпись блока не прошла проверку
|
||||||
|
|
||||||
Принимает входящий подписанный DM-блок.
|
## 4. `ReceiveIncomingMessage`
|
||||||
|
|
||||||
|
Принимает только один входящий signed DM-блок.
|
||||||
|
|
||||||
|
### Назначение
|
||||||
|
|
||||||
|
Используется там, где нужно принять только incoming-вариант сообщения.
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
@ -170,28 +134,9 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Успешный ответ
|
## 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`
|
|
||||||
|
|
||||||
Требует авторизации. Подтверждает доставку сообщения в текущую сессию.
|
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
@ -200,107 +145,46 @@
|
|||||||
"op": "AckSessionDelivery",
|
"op": "AckSessionDelivery",
|
||||||
"requestId": "ack-001",
|
"requestId": "ack-001",
|
||||||
"payload": {
|
"payload": {
|
||||||
"messageKey": "incoming-key"
|
"messageKey": "from|to|time|nonce|1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Успешный ответ
|
## 6. Событие `SignedMessageArrived`
|
||||||
|
|
||||||
|
Сервер присылает его по WebSocket в активные сессии адресата.
|
||||||
|
|
||||||
|
### Payload события
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "AckSessionDelivery",
|
"messageKey": "from|to|time|nonce|1",
|
||||||
"requestId": "ack-001",
|
"baseKey": "from|to|time|nonce",
|
||||||
"status": 200,
|
"fromLogin": "alice",
|
||||||
"ok": true,
|
"toLogin": "bob",
|
||||||
"payload": {
|
"targetLogin": "bob",
|
||||||
"messageKey": "incoming-key"
|
"messageType": 1,
|
||||||
}
|
"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. Замечания
|
||||||
|
|
||||||
```json
|
- read-receipt `type=3/4` пока остаются в legacy-формате `SHiNE_dm2`
|
||||||
{
|
- контентные DM `type=1/2` используют `SHiNE_DM`
|
||||||
"op": "CallSignalToSession",
|
- сервер хранит только последнюю версию контентного сообщения по `messageKey`
|
||||||
"requestId": "call-signal-001",
|
- удаление сообщения реализуется новой ревизией с пустым телом и `attachmentsCount = 0`
|
||||||
"payload": {
|
- HTTP endpoints для DM-файлов сейчас отсутствуют
|
||||||
"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` — повторное сообщение заблокировано.
|
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
# 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`
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# 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
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Закрытие сессий и сортировка устройств
|
||||||
|
|
||||||
|
- краткое описание фичи:
|
||||||
|
- добровольный выход и переключение устройства/аккаунта теперь сначала пытаются закрыть текущую серверную сессию, а затем очищают локальные данные;
|
||||||
|
- на экране `Устройства` сессии сортируются так, чтобы онлайн-сессии шли раньше оффлайн;
|
||||||
|
- статус онлайн-сессии выделяется зелёным.
|
||||||
|
|
||||||
|
- что именно проверять:
|
||||||
|
- в `Настройки` нажать выход из текущей сессии и убедиться, что запись исчезает из списка сессий после повторного входа;
|
||||||
|
- в `Устройства` нажать `Завершить текущую сессию` и убедиться, что локальные данные очищены, а серверная сессия удалена;
|
||||||
|
- выполнить вход/переключение через `Подключить устройство` или QR и убедиться, что старая сессия не остаётся висеть на сервере;
|
||||||
|
- открыть `Устройства` при наличии нескольких сессий и убедиться, что сначала показаны `Online now`, затем `Offline`;
|
||||||
|
- проверить, что строки со статусом `Online now` визуально выделены зелёным.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
- при добровольном завершении сессии серверная запись удаляется;
|
||||||
|
- при локальном переключении на другой аккаунт старая текущая сессия не остаётся в `active_sessions`;
|
||||||
|
- порядок сессий в UI соответствует онлайн-статусу сервера;
|
||||||
|
- зелёный статус виден и не ломает верстку на экране `Устройства`.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
- pending
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
# Автоопределение 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
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# 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
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
# TrustedDeviceLogin settings и новый режим по умолчанию
|
||||||
|
|
||||||
|
- краткое описание:
|
||||||
|
серверный API сценария входа через доверенное устройство переименован в `TrustedDeviceLogin`, добавлен `GetTrustedDeviceLoginSettings`, а отсутствие серверной записи настроек теперь трактуется как `enabled = true` и `hasPassword = false`. В UI вынесен отдельный экран настроек входа через доверенное устройство.
|
||||||
|
|
||||||
|
- что проверять:
|
||||||
|
1. Для логина без записи в `esp_pairing_settings` `StartTrustedDeviceLogin` работает без предварительного ручного включения.
|
||||||
|
2. Экран `Подключить по коду` показывает один из трёх статусов:
|
||||||
|
- вход запрещён;
|
||||||
|
- вход разрешён без пароля;
|
||||||
|
- вход разрешён только с паролем.
|
||||||
|
3. Кнопка `Изменить настройки входа` открывает отдельный экран.
|
||||||
|
4. На отдельном экране:
|
||||||
|
- можно запретить вход;
|
||||||
|
- можно разрешить вход;
|
||||||
|
- можно задать новый пароль;
|
||||||
|
- можно сделать вход без пароля.
|
||||||
|
5. `Войти через другое устройство` в основном UI и в browser wallet работает через новые `TrustedDeviceLogin`-операции.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
вход через доверенное устройство по умолчанию доступен без лишнего ручного включения, а текущий режим и пароль управляются с отдельного экрана настроек.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
pending
|
||||||
18
Dev_Docs/Personal_Messages/AGENTS.md
Normal file
18
Dev_Docs/Personal_Messages/AGENTS.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# AGENTS
|
||||||
|
|
||||||
|
## Документация DM в этой папке
|
||||||
|
|
||||||
|
- Основной актуальный документ по личным сообщениям:
|
||||||
|
- `README.md`
|
||||||
|
- Его считать единственным источником истины по текущей реализованной логике DM.
|
||||||
|
|
||||||
|
## Черновик будущих вложений
|
||||||
|
|
||||||
|
- Файл `Черновик_будущих_DM_вложений.md` не является актуальной спецификацией.
|
||||||
|
- В нём описан только ранний черновик того, как когда-то планировались:
|
||||||
|
- формат вложений в DM;
|
||||||
|
- внешние и внутренние поля вложения;
|
||||||
|
- предполагаемая механика загрузки файлов.
|
||||||
|
- Эта схема не была реализована в таком виде и может существенно измениться в будущем.
|
||||||
|
- Любые решения по текущему коду, протоколу и UI нельзя принимать по этому черновику.
|
||||||
|
- Если есть расхождение между `README.md` и черновиком вложений, верным всегда считается `README.md`.
|
||||||
@ -1,269 +1,203 @@
|
|||||||
# Личные сообщения (DM): как это устроено
|
# Личные сообщения (DM)
|
||||||
|
|
||||||
## Коротко (для быстрого понимания)
|
## Текущее состояние
|
||||||
|
|
||||||
Личные сообщения в SHiNE сейчас работают как пара **подписанных клиентом блоков** в формате `SHiNE_dm2`:
|
Сейчас в проекте реализованы:
|
||||||
|
|
||||||
- тип `1` — входящее сообщение для собеседника;
|
- новый формат контентных личных сообщений `SHiNE_DM`;
|
||||||
- тип `2` — исходящая копия того же сообщения для автора.
|
- ревизии сообщений через `revisionTimeMs`;
|
||||||
|
- редактирование сообщения через повторную отправку той же логической пары;
|
||||||
|
- удаление сообщения через пустую ревизию;
|
||||||
|
- `upsert` последней версии сообщения на сервере.
|
||||||
|
|
||||||
Оба блока отправляются вместе одной операцией (`SendMessagePair` / `ReceiveOutcomingMessage`) и либо сохраняются оба, либо не сохраняются вовсе.
|
Сейчас в проекте **не реализованы**:
|
||||||
Дальше сервер доставляет их по активным сессиям целевого логина событием `SignedMessageArrived`, а клиент подтверждает доставку на конкретную сессию через `AckSessionDelivery`.
|
|
||||||
|
|
||||||
Подтверждение прочтения также идёт парой блоков:
|
- вложения в DM;
|
||||||
|
- upload/download файлов для DM;
|
||||||
|
- UI-кнопка прикрепления файла;
|
||||||
|
- серверное хранение файловых связей для DM.
|
||||||
|
|
||||||
- тип `3` — «прочитано» для исходящего сообщения автора;
|
Черновик будущих вложений вынесен отдельно:
|
||||||
- тип `4` — зеркальная копия для второй стороны.
|
|
||||||
|
|
||||||
UI чата строится на этих типах: текстовые сообщения (1/2), read-receipt (3/4), непрочитанные, галочки и история.
|
- `Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md`
|
||||||
|
|
||||||
---
|
## Общая схема
|
||||||
|
|
||||||
## Подробно
|
Личное сообщение по-прежнему отправляется парой signed-блоков:
|
||||||
|
|
||||||
## 1) Общая схема потока
|
- `type=1` — входящий блок для получателя;
|
||||||
|
- `type=2` — исходящая копия для отправителя.
|
||||||
|
|
||||||
1. Клиент формирует текст сообщения и строит **2 подписанных блока** (`type=1` и `type=2`) с одинаковыми `fromLogin/toLogin/timeMs/nonce`.
|
Read-receipt пока остаются в legacy-формате:
|
||||||
2. Клиент отправляет оба блока в одном RPC: `SendMessagePair` (алиас: `ReceiveOutcomingMessage`).
|
|
||||||
3. Сервер:
|
|
||||||
- парсит оба блока;
|
|
||||||
- валидирует пару;
|
|
||||||
- проверяет существование `from/to` пользователей и подписи;
|
|
||||||
- атомарно сохраняет пару в `signed_messages_v2`.
|
|
||||||
4. Сервер доставляет блоки в активные сессии целевого логина событием `SignedMessageArrived`.
|
|
||||||
5. Клиент, получив событие, кладёт сообщение в локальный чат и отправляет `AckSessionDelivery(messageKey)`.
|
|
||||||
6. При открытии чата клиент отправляет read-receipt (пара `type=3/4`) для непрочитанных входящих.
|
|
||||||
|
|
||||||
## 2) Формат signed DM-блока (`SHiNE_dm2`)
|
- `type=3` — входящее подтверждение прочтения;
|
||||||
|
- `type=4` — исходящая копия подтверждения.
|
||||||
|
|
||||||
Префикс: `SHiNE_dm2` (ASCII).
|
Ключи сообщения:
|
||||||
|
|
||||||
Далее поля (big-endian):
|
- `baseKey = fromLogin|toLogin|timeMs|nonce`
|
||||||
|
|
||||||
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`
|
||||||
- для связи read-receipt с исходным сообщением;
|
- `nonce`
|
||||||
- для ACK доставки по сессии.
|
|
||||||
|
|
||||||
## 5) RPC и события
|
Эти поля не меняются при редактировании или удалении. Меняется только:
|
||||||
|
|
||||||
## `SendMessagePair` (алиас `ReceiveOutcomingMessage`)
|
- `revisionTimeMs`
|
||||||
|
- содержимое `encryptedBody`
|
||||||
|
|
||||||
Запрос:
|
Сервер хранит только последнюю версию записи для каждого `messageKey`.
|
||||||
|
|
||||||
```json
|
## Формат контентного DM: `SHiNE_DM`
|
||||||
{
|
|
||||||
"op": "SendMessagePair",
|
|
||||||
"requestId": "req-1",
|
|
||||||
"payload": {
|
|
||||||
"incomingBlobB64": "<base64 signed block type 1 or 3>",
|
|
||||||
"outgoingBlobB64": "<base64 signed block type 2 or 4>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Успешный ответ:
|
Префикс бинарного блока:
|
||||||
|
|
||||||
```json
|
- `SHiNE_DM`
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## `SignedMessageArrived` (server event)
|
Поля идут в big-endian порядке:
|
||||||
|
|
||||||
Событие в сессию получателя содержит:
|
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` (признак догрузки из очереди).
|
|
||||||
|
|
||||||
## `AckSessionDelivery`
|
- `attachmentsCount` сейчас всегда должен быть `0`
|
||||||
|
- `encryptedBodyLen` сейчас ограничен сервером до `16384` байт
|
||||||
|
- `revisionTimeMs` не может быть отрицательным
|
||||||
|
|
||||||
Запрос:
|
Если приходит `attachmentsCount != 0`, сервер отклоняет такой DM как:
|
||||||
|
|
||||||
```json
|
- `ATTACHMENTS_DISABLED`
|
||||||
{
|
|
||||||
"op": "AckSessionDelivery",
|
|
||||||
"requestId": "ack-1",
|
|
||||||
"payload": {
|
|
||||||
"messageKey": "from|to|time|nonce|1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ответ: `status=200`, echo `messageKey`.
|
## Legacy read-receipt: `SHiNE_dm2`
|
||||||
|
|
||||||
## 6) Хранение на сервере (SQLite)
|
Подтверждения прочтения `type=3/4` пока используют старый контейнер `SHiNE_dm2`:
|
||||||
|
|
||||||
Основные таблицы:
|
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`.
|
Редактирование делается новой отправкой той же логической пары сообщения:
|
||||||
|
|
||||||
## 7) Доставка и backlog
|
- `timeMs` и `nonce` остаются теми же;
|
||||||
|
- `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`.
|
|
||||||
|
|
||||||
Сервер для read-receipt хранит ссылку на исходное сообщение:
|
- `timeMs` и `nonce` остаются прежними;
|
||||||
|
- `revisionTimeMs` увеличивается;
|
||||||
|
- `attachmentsCount = 0`;
|
||||||
|
- `encryptedBodyLen = 0`;
|
||||||
|
- `encryptedBody` пустой.
|
||||||
|
|
||||||
- `receipt_ref_base_key`;
|
В UI такое сообщение не показывается.
|
||||||
- `receipt_ref_type`.
|
|
||||||
|
|
||||||
Есть уникальность, чтобы не плодить дубликаты receipt на один и тот же `baseKey` для одного `target_login`.
|
На сервере это не отдельный тип сообщения, а просто последняя пустая ревизия того же `messageKey`.
|
||||||
|
|
||||||
## 9) Логика UI-клиента
|
## Поведение сервера
|
||||||
|
|
||||||
### Хранилище сообщений
|
Для контентных DM сервер:
|
||||||
|
|
||||||
- In-memory: `state.chats[chatId]` — массив сообщений по каждому диалогу.
|
1. принимает пару signed-блоков `type=1/2`;
|
||||||
- Персистентно: IndexedDB база `shine-ui-messages-v1`, object store `messages`, ключ `messageKey`.
|
2. валидирует формат, подпись и совпадение ключевых полей пары;
|
||||||
- `chatId` для `type=1` — `fromLogin`, для `type=2` — `toLogin`.
|
3. проверяет, что для обеих сторон пары совпадают:
|
||||||
|
- `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-событиями.
|
|
||||||
|
|
||||||
### Очистка при выходе и смене пользователя
|
Основная таблица:
|
||||||
|
|
||||||
- При любом логауте (`terminateCurrentSession`) IndexedDB с сообщениями **удаляется полностью**.
|
- `signed_messages_v2`
|
||||||
- При входе нового пользователя через QR — IndexedDB удаляется явно до вызова `terminateCurrentSession`.
|
|
||||||
- При входе нового пользователя через логин/пароль — IndexedDB удаляется в `registration-keys-view.js` прямо перед `authorizeSession()`.
|
|
||||||
- Это гарантирует: при любом способе входа старые сообщения предыдущего пользователя не попадут к следующему.
|
|
||||||
|
|
||||||
### UI-поведение
|
Для контентных DM в ней используются:
|
||||||
|
|
||||||
- непрочитанные считаются по `from='in' && unread=true`;
|
- `message_key`
|
||||||
- доставка/прочтение исходящих:
|
- `base_key`
|
||||||
- `firstTick` — сообщение принято сервером,
|
- `target_login`
|
||||||
- `secondTick` — пришло подтверждение прочтения;
|
- `from_login`
|
||||||
- при открытии диалога UI автопрокручивает ленту в самый низ;
|
- `to_login`
|
||||||
- после отправки нового сообщения UI сразу прокручивает ленту вниз.
|
- `time_ms`
|
||||||
|
- `nonce`
|
||||||
|
- `message_type`
|
||||||
|
- `revision_time_ms`
|
||||||
|
- `raw_block`
|
||||||
|
- `created_at_ms`
|
||||||
|
|
||||||
## 10) Синхронизация личных сообщений между серверами
|
Отдельных таблиц файлов для DM сейчас нет.
|
||||||
|
|
||||||
Когда пользователи зарегистрированы на разных серверах SHiNE, серверы должны синхронизировать DM между собой.
|
## События и доставка
|
||||||
|
|
||||||
### Общий принцип
|
Запрос на отправку по WebSocket остаётся прежним:
|
||||||
|
|
||||||
- Сервер A получает DM-блок, адресованный пользователю на сервере B.
|
- `SendMessagePair`
|
||||||
- Сервер A пересылает этот блок серверу B (межсерверный relay).
|
- `ReceiveOutcomingMessage` как алиас
|
||||||
- Сервер B сохраняет блок и доставляет его в активные сессии получателя.
|
|
||||||
- Серверы, между которыми идёт синхронизация, задаются списком `sync_servers` в PDA пользователя-сервера.
|
|
||||||
|
|
||||||
### Что синхронизируется
|
Клиент отправляет:
|
||||||
|
|
||||||
- Все DM-блоки типов `1/2` (текстовые сообщения) и `3/4` (read-receipt).
|
- `incomingBlobB64`
|
||||||
- Синхронизация двусторонняя: оба сервера должны уметь принимать и пересылать блоки.
|
- `outgoingBlobB64`
|
||||||
|
|
||||||
### Идемпотентность
|
Событие в активные сессии:
|
||||||
|
|
||||||
- Блоки имеют уникальный `message_key` (`from|to|timeMs|nonce|type`).
|
- `SignedMessageArrived`
|
||||||
- Повторная доставка одного и того же блока безопасна — дедупликация происходит по `message_key`.
|
|
||||||
|
|
||||||
### Статус реализации
|
Если пришла новая ревизия того же сообщения, `messageKey` остаётся прежним, а внутри `blobB64` будет более новый `revisionTimeMs`.
|
||||||
|
|
||||||
Межсерверная синхронизация DM **пока не реализована**. Текущая версия работает только в рамках одного сервера. Это задача для следующего этапа.
|
Подтверждение доставки в сессию:
|
||||||
|
|
||||||
---
|
- `AckSessionDelivery`
|
||||||
|
|
||||||
## 11) Инварианты (обязательно соблюдать при доработках)
|
## Правила UI
|
||||||
|
|
||||||
1. Пара блоков (1/2 или 3/4) должна оставаться атомарной.
|
UI сейчас работает так:
|
||||||
2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt.
|
|
||||||
3. Доставка должна оставаться **по сессиям** с явным `AckSessionDelivery`.
|
|
||||||
4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`.
|
|
||||||
5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе.
|
|
||||||
|
|
||||||
## 12) Ключевые файлы реализации
|
- показывает только текст `encryptedBody`;
|
||||||
|
- умеет обновлять уже существующее сообщение по тому же `messageKey`;
|
||||||
|
- не показывает удалённые сообщения;
|
||||||
|
- позволяет владельцу сообщения вызвать меню `Скопировать как текст / Прочесть / Изменить / Удалить`;
|
||||||
|
- при редактировании показывает над полем ввода полоску `Редактируем сообщение: ...` с кнопкой отмены;
|
||||||
|
- после редактирования показывает под временем отдельную строку `изменено: <дата время>`;
|
||||||
|
- не показывает и не принимает вложения.
|
||||||
|
|
||||||
- UI:
|
## Что обязательно помнить
|
||||||
- `shine-UI/js/services/auth-service.js`
|
|
||||||
- `shine-UI/js/app.js`
|
- вложения в DM сейчас отключены на уровне протокола и UI;
|
||||||
- `shine-UI/js/state.js`
|
- любые старые описания `/f/...`, `/upload` и файловых таблиц для DM больше не актуальны;
|
||||||
- `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`
|
|
||||||
|
|||||||
73
Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md
Normal file
73
Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Черновик будущих вложений в 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 и opaque `passwordHash`;
|
- хранит включённость pairing и optional `passwordHash` в формате `sha256$<hex>`;
|
||||||
- хранит pending/approved/rejected pairing-заявки;
|
- хранит pairing-заявки всех статусов, но в список активных для доверённого устройства отдаёт только pending `created`;
|
||||||
- рассчитывает короткий код `shortCode` из `7` цифр;
|
- рассчитывает короткий код `shortCode` из `7` цифр;
|
||||||
- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки;
|
- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки;
|
||||||
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
|
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
|
||||||
@ -101,6 +101,12 @@
|
|||||||
|
|
||||||
Эта схема даёт нужное разделение доверия:
|
Эта схема даёт нужное разделение доверия:
|
||||||
|
|
||||||
- пароль на сервере только отсеивает лишних;
|
- пароль на сервере, если он включён, только отсеивает лишних;
|
||||||
- онлайн доверенная сессия решает, добавлять ли новую сессию;
|
- онлайн доверенная сессия решает, добавлять ли новую сессию;
|
||||||
- сервер остаётся маршрутизатором и хранилищем состояния, а не владельцем секретов.
|
- сервер остаётся маршрутизатором и хранилищем состояния, а не владельцем секретов.
|
||||||
|
|
||||||
|
Текущий формат pairing-пароля:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
|
||||||
|
```
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
rootProject.name = 'ESP-wallet'
|
|
||||||
@ -27,6 +27,7 @@
|
|||||||
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
|
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
|
||||||
- Истории диалогов хранятся в 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` — архивировать текущую историю и начать новый диалог.
|
- `/new` — архивировать текущую историю, сбросить продолжение Codex-сессии для этого пользователя и начать новый диалог.
|
||||||
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
|
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
|
||||||
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
|
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
|
||||||
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
|
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
.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/
|
||||||
10
SHiNE-browser-plugin-wallet/.idea/.gitignore
generated
vendored
Normal file
10
SHiNE-browser-plugin-wallet/.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# 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
Normal file
1
SHiNE-browser-plugin-wallet/.idea/.name
generated
Normal file
@ -0,0 +1 @@
|
|||||||
|
ESP-wallet
|
||||||
17
SHiNE-browser-plugin-wallet/.idea/gradle.xml
generated
Normal file
17
SHiNE-browser-plugin-wallet/.idea/gradle.xml
generated
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?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
Normal file
7
SHiNE-browser-plugin-wallet/.idea/misc.xml
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?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
Normal file
6
SHiNE-browser-plugin-wallet/.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
40
SHiNE-browser-plugin-wallet/README.md
Normal file
40
SHiNE-browser-plugin-wallet/README.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
567
SHiNE-browser-plugin-wallet/background.js
Normal file
567
SHiNE-browser-plugin-wallet/background.js
Normal file
@ -0,0 +1,567 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
78
SHiNE-browser-plugin-wallet/js/lib/crypto-utils.js
Normal file
78
SHiNE-browser-plugin-wallet/js/lib/crypto-utils.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
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('');
|
||||||
|
}
|
||||||
90
SHiNE-browser-plugin-wallet/js/lib/device-pairing.js
Normal file
90
SHiNE-browser-plugin-wallet/js/lib/device-pairing.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
152
SHiNE-browser-plugin-wallet/js/lib/session-store.js
Normal file
152
SHiNE-browser-plugin-wallet/js/lib/session-store.js
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
117
SHiNE-browser-plugin-wallet/js/lib/shine-api.js
Normal file
117
SHiNE-browser-plugin-wallet/js/lib/shine-api.js
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
244
SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js
Normal file
244
SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
995
SHiNE-browser-plugin-wallet/js/lib/vendor/noble-ed25519-bundle.js
vendored
Normal file
995
SHiNE-browser-plugin-wallet/js/lib/vendor/noble-ed25519-bundle.js
vendored
Normal file
@ -0,0 +1,995 @@
|
|||||||
|
// 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) *)
|
||||||
|
*/
|
||||||
3
SHiNE-browser-plugin-wallet/js/lib/vendor/noble-ed25519-entry.js
vendored
Normal file
3
SHiNE-browser-plugin-wallet/js/lib/vendor/noble-ed25519-entry.js
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { edwardsToMontgomeryPriv, x25519 } from '../../../node_modules/@noble/curves/esm/ed25519.js';
|
||||||
|
|
||||||
|
export { edwardsToMontgomeryPriv, x25519 };
|
||||||
16323
SHiNE-browser-plugin-wallet/js/lib/vendor/solana-publickey-bundle.js
vendored
Normal file
16323
SHiNE-browser-plugin-wallet/js/lib/vendor/solana-publickey-bundle.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3
SHiNE-browser-plugin-wallet/js/lib/vendor/solana-publickey-entry.js
vendored
Normal file
3
SHiNE-browser-plugin-wallet/js/lib/vendor/solana-publickey-entry.js
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { PublicKey } from '@solana/web3.js';
|
||||||
|
|
||||||
|
export { PublicKey };
|
||||||
101
SHiNE-browser-plugin-wallet/js/lib/ws-client.js
Normal file
101
SHiNE-browser-plugin-wallet/js/lib/ws-client.js
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
SHiNE-browser-plugin-wallet/manifest.json
Normal file
20
SHiNE-browser-plugin-wallet/manifest.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"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
Normal file
1177
SHiNE-browser-plugin-wallet/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
SHiNE-browser-plugin-wallet/package.json
Normal file
19
SHiNE-browser-plugin-wallet/package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
211
SHiNE-browser-plugin-wallet/popup.css
Normal file
211
SHiNE-browser-plugin-wallet/popup.css
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
* {
|
||||||
|
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;
|
||||||
|
}
|
||||||
97
SHiNE-browser-plugin-wallet/popup.html
Normal file
97
SHiNE-browser-plugin-wallet/popup.html
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<!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>
|
||||||
360
SHiNE-browser-plugin-wallet/popup.js
Normal file
360
SHiNE-browser-plugin-wallet/popup.js
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
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
SHiNE-browser-plugin-wallet/settings.gradle
Normal file
1
SHiNE-browser-plugin-wallet/settings.gradle
Normal file
@ -0,0 +1 @@
|
|||||||
|
rootProject.name = 'SHiNE-browser-plugin-wallet'
|
||||||
9
SHiNE-promo-solana-devnet/.gitignore
vendored
9
SHiNE-promo-solana-devnet/.gitignore
vendored
@ -1,9 +0,0 @@
|
|||||||
.gradle
|
|
||||||
/build
|
|
||||||
.idea
|
|
||||||
out
|
|
||||||
*.log
|
|
||||||
|
|
||||||
config/devnet-wallet.json
|
|
||||||
|
|
||||||
!gradle/wrapper/gradle-wrapper.jar
|
|
||||||
@ -1,198 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
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')
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
[
|
|
||||||
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
|
|
||||||
]
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=SHiNE Promo Solana Devnet
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=player
|
|
||||||
Group=player
|
|
||||||
WorkingDirectory=/home/player/SHiNE/SHiNE-promo-solana-devnet
|
|
||||||
ExecStart=/usr/bin/java -jar /home/player/SHiNE/SHiNE-promo-solana-devnet/SHiNE-promo-solana-devnet.jar --spring.config.location=/home/player/SHiNE/SHiNE-promo-solana-devnet/application.properties
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
SuccessExitStatus=143
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
Binary file not shown.
@ -1,6 +0,0 @@
|
|||||||
#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
234
SHiNE-promo-solana-devnet/gradlew
vendored
@ -1,234 +0,0 @@
|
|||||||
#!/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
89
SHiNE-promo-solana-devnet/gradlew.bat
vendored
@ -1,89 +0,0 @@
|
|||||||
@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 +0,0 @@
|
|||||||
rootProject.name = 'SHiNE-promo-solana-devnet'
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
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,}", " ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
: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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
(() => {
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
<!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,6 +618,7 @@ 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 = 5;
|
private static final int LATEST_SCHEMA_VERSION = 7;
|
||||||
|
|
||||||
private final String jdbcUrl;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
@ -88,6 +88,8 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -209,6 +211,44 @@ 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 (
|
||||||
@ -329,6 +369,20 @@ 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 IN ('created', 'approved', 'rejected')
|
AND status = 'created'
|
||||||
ORDER BY created_at_ms DESC
|
ORDER BY created_at_ms DESC
|
||||||
""";
|
""";
|
||||||
List<EspPairingRequestEntry> list = new ArrayList<>();
|
List<EspPairingRequestEntry> list = new ArrayList<>();
|
||||||
@ -199,6 +199,22 @@ 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,6 +8,7 @@ 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 {
|
||||||
@ -30,36 +31,17 @@ 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, raw_block, created_at_ms,
|
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
|
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)) {
|
||||||
ps.setString(1, e.getMessageKey());
|
bindSignedMessage(ps, e);
|
||||||
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();
|
||||||
@ -85,37 +67,45 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException {
|
public boolean upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
|
||||||
String sql = """
|
try (Connection c = db.getConnection()) {
|
||||||
INSERT INTO signed_messages_v2 (
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
c.setAutoCommit(false);
|
||||||
time_ms, nonce, message_type, raw_block, created_at_ms,
|
try {
|
||||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey());
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey());
|
||||||
""";
|
long currentRevision = Math.max(
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
currentIncomingRevision != null ? currentIncomingRevision : Long.MIN_VALUE,
|
||||||
ps.setString(1, e.getMessageKey());
|
currentOutgoingRevision != null ? currentOutgoingRevision : Long.MIN_VALUE
|
||||||
ps.setString(2, e.getBaseKey());
|
);
|
||||||
ps.setString(3, e.getTargetLogin());
|
long nextRevision = incoming.getRevisionTimeMs();
|
||||||
ps.setString(4, e.getFromLogin());
|
|
||||||
ps.setString(5, e.getToLogin());
|
if (currentRevision != Long.MIN_VALUE && nextRevision < currentRevision) {
|
||||||
ps.setLong(6, e.getTimeMs());
|
c.rollback();
|
||||||
ps.setLong(7, e.getNonce());
|
return false;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isConstraintViolation(SQLException ex) {
|
upsertMessage(c, incoming);
|
||||||
String msg = String.valueOf(ex.getMessage()).toLowerCase();
|
upsertMessage(c, outgoing);
|
||||||
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
resetDeliveryRows(c, incoming.getMessageKey());
|
||||||
|
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||||
|
|
||||||
|
c.commit();
|
||||||
|
return true;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw ex;
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(prevAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
||||||
@ -123,7 +113,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, raw_block, created_at_ms,
|
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
|
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 = ?
|
||||||
@ -203,13 +193,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.raw_block, m.created_at_ms,
|
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, 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.created_at_ms ASC
|
ORDER BY m.time_ms ASC, m.revision_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)) {
|
||||||
@ -222,6 +212,106 @@ 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"));
|
||||||
@ -232,6 +322,7 @@ 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,6 +9,7 @@ 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;
|
||||||
@ -32,6 +33,8 @@ 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,7 +9,9 @@ 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 ---
|
||||||
@ -27,7 +29,9 @@ 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;
|
||||||
@ -138,7 +142,16 @@ 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()),
|
||||||
@ -202,7 +215,16 @@ 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,6 +4,7 @@ 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;
|
||||||
@ -27,7 +28,10 @@ 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();
|
||||||
@ -76,6 +80,30 @@ 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();
|
||||||
@ -148,5 +176,14 @@ 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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,60 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
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,9 +54,10 @@ 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 passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
|
String rawPasswordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
|
||||||
if (passwordHash == null) {
|
String passwordHash = EspPairingSupport.normalizePasswordHash(rawPasswordHash);
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Пустой passwordHash");
|
if (rawPasswordHash != null && passwordHash == null) {
|
||||||
|
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);
|
||||||
@ -66,7 +67,8 @@ 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);
|
||||||
if (settings == null || !settings.isEnabled() || settings.getPasswordHash() == null || settings.getPasswordHash().isBlank()) {
|
boolean enabled = settings == null || settings.isEnabled();
|
||||||
|
if (!enabled) {
|
||||||
return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен");
|
return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,12 +86,27 @@ 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-запросов за короткое время");
|
||||||
}
|
}
|
||||||
if (!settings.getPasswordHash().equals(passwordHash)) {
|
String configuredPasswordHash = settings == null || settings.getPasswordHash() == null
|
||||||
|
? ""
|
||||||
|
: settings.getPasswordHash().trim();
|
||||||
|
boolean requiresPassword = !configuredPasswordHash.isBlank();
|
||||||
|
boolean suppliedPassword = passwordHash != null && !passwordHash.isBlank();
|
||||||
|
if ((requiresPassword && !configuredPasswordHash.equals(passwordHash))
|
||||||
|
|| (!requiresPassword && suppliedPassword)) {
|
||||||
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.normalizeTtlSeconds(settings.getTtlSeconds());
|
int ttlSeconds = EspPairingSupport.DEFAULT_TTL_SECONDS;
|
||||||
|
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
|
||||||
|
if (approverConnections.isEmpty()) {
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
422,
|
||||||
|
"PAIRING_NO_TRUSTED_SESSION_ONLINE",
|
||||||
|
"Нет ни одной активной доверенной сессии пользователя в сети"
|
||||||
|
);
|
||||||
|
}
|
||||||
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
|
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
|
||||||
canonicalLogin,
|
canonicalLogin,
|
||||||
requesterSessionKey,
|
requesterSessionKey,
|
||||||
@ -115,7 +132,6 @@ 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");
|
||||||
@ -130,7 +146,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, "IncomingEspPairingRequest", eventId, payload);
|
delivered |= WsEventSender.sendEvent(targetCtx, "IncomingTrustedDeviceLoginRequest", eventId, payload);
|
||||||
}
|
}
|
||||||
if (delivered) {
|
if (delivered) {
|
||||||
EspPairingRequestsDAO.getInstance().updateDeliveryFlag(entry.getPairingId(), true, System.currentTimeMillis());
|
EspPairingRequestsDAO.getInstance().updateDeliveryFlag(entry.getPairingId(), true, System.currentTimeMillis());
|
||||||
|
|||||||
@ -27,18 +27,22 @@ public class Net_UpsertEspPairingSettings_Handler implements JsonMessageHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
boolean enabled = req.getEnabled() != null && req.getEnabled();
|
boolean enabled = req.getEnabled() != null && req.getEnabled();
|
||||||
String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
|
String rawPasswordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash());
|
||||||
int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(req.getTtlSeconds());
|
String passwordHash = EspPairingSupport.normalizePasswordHash(rawPasswordHash);
|
||||||
if (enabled && (passwordHash == null || passwordHash.isBlank())) {
|
if (rawPasswordHash != null && passwordHash == null) {
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Для включения pairing нужен passwordHash");
|
return NetExceptionResponseFactory.error(
|
||||||
|
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(passwordHash == null ? "" : passwordHash);
|
entry.setPasswordHash(enabled && passwordHash != null ? passwordHash : "");
|
||||||
entry.setTtlSeconds(ttlSeconds);
|
entry.setTtlSeconds(EspPairingSupport.DEFAULT_TTL_SECONDS);
|
||||||
entry.setFailedAttempts(0);
|
entry.setFailedAttempts(0);
|
||||||
entry.setFirstFailedAtMs(0L);
|
entry.setFirstFailedAtMs(0L);
|
||||||
entry.setBlockedUntilMs(0L);
|
entry.setBlockedUntilMs(0L);
|
||||||
@ -50,7 +54,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.setTtlSeconds(ttlSeconds);
|
resp.setHasPassword(enabled && passwordHash != null && !passwordHash.isBlank());
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
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 {
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
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 int ttlSeconds;
|
private boolean hasPassword;
|
||||||
|
|
||||||
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 int getTtlSeconds() {
|
public boolean isHasPassword() {
|
||||||
return ttlSeconds;
|
return hasPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setTtlSeconds(int ttlSeconds) {
|
public void setHasPassword(boolean hasPassword) {
|
||||||
this.ttlSeconds = ttlSeconds;
|
this.hasPassword = hasPassword;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ 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 {
|
||||||
@ -43,7 +44,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 = SignedMessagesCore.saveIfAbsent(entry);
|
boolean inserted = SignedMessagesV2DAO.getInstance().insertIfAbsent(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,11 +49,18 @@ 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 = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);
|
boolean pairInserted;
|
||||||
|
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, null);
|
inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, incoming);
|
||||||
}
|
}
|
||||||
|
|
||||||
String excludeSessionId = null;
|
String excludeSessionId = null;
|
||||||
@ -62,7 +69,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, excludeSessionId);
|
outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, outgoing, excludeSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
||||||
|
|||||||
@ -6,7 +6,8 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
final class SignedMessageBlock {
|
final class SignedMessageBlock {
|
||||||
static final byte[] PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
|
static final byte[] LEGACY_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;
|
||||||
@ -17,10 +18,15 @@ 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,
|
||||||
@ -28,37 +34,53 @@ 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 maxPayloadBytes) {
|
static SignedMessageBlock parse(byte[] raw, int maxEncryptedBodyBytes) {
|
||||||
if (raw == null || raw.length < PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) {
|
if (raw == null || raw.length < 64) {
|
||||||
throw new IllegalArgumentException("BAD_LEN");
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
}
|
}
|
||||||
if (raw.length > 8192) {
|
|
||||||
throw new IllegalArgumentException("PAYLOAD_TOO_LARGE");
|
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
if (startsWith(raw, LEGACY_PREFIX)) {
|
||||||
byte[] prefix = new byte[PREFIX.length];
|
return parseLegacy(raw, maxEncryptedBodyBytes);
|
||||||
bb.get(prefix);
|
}
|
||||||
if (!Arrays.equals(prefix, PREFIX)) {
|
if (startsWith(raw, V1_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");
|
||||||
|
|
||||||
@ -67,9 +89,7 @@ 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());
|
||||||
if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) {
|
ensureMessageType(messageType);
|
||||||
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) {
|
||||||
@ -86,7 +106,82 @@ 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, fromLogin, timeMs, nonce, messageType, payload, signedBody, signature64, raw
|
toLogin,
|
||||||
|
fromLogin,
|
||||||
|
timeMs,
|
||||||
|
nonce,
|
||||||
|
messageType,
|
||||||
|
0L,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
payload,
|
||||||
|
payload,
|
||||||
|
signedBody,
|
||||||
|
signature64,
|
||||||
|
raw,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SignedMessageBlock parseV1(byte[] raw, int maxEncryptedBodyBytes) {
|
||||||
|
if (raw.length < V1_PREFIX.length + 2 + 1 + 1 + 8 + 4 + 2 + 8 + 1 + 4 + 64) {
|
||||||
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
|
}
|
||||||
|
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.position(V1_PREFIX.length);
|
||||||
|
|
||||||
|
int major = Byte.toUnsignedInt(bb.get());
|
||||||
|
int minor = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (major != 1 || minor != 0) {
|
||||||
|
throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
||||||
|
}
|
||||||
|
|
||||||
|
String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN");
|
||||||
|
String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN");
|
||||||
|
long timeMs = bb.getLong();
|
||||||
|
if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
|
||||||
|
long nonce = Integer.toUnsignedLong(bb.getInt());
|
||||||
|
int messageType = Short.toUnsignedInt(bb.getShort());
|
||||||
|
ensureMessageType(messageType);
|
||||||
|
long revisionTimeMs = bb.getLong();
|
||||||
|
if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||||
|
|
||||||
|
int attachmentsCount = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (attachmentsCount != 0) {
|
||||||
|
throw new IllegalArgumentException("ATTACHMENTS_DISABLED");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bb.remaining() < 4 + 64) {
|
||||||
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
|
}
|
||||||
|
long encryptedBodyLen = Integer.toUnsignedLong(bb.getInt());
|
||||||
|
if (encryptedBodyLen > maxEncryptedBodyBytes) {
|
||||||
|
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
||||||
|
}
|
||||||
|
if (bb.remaining() != encryptedBodyLen + 64) {
|
||||||
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
|
}
|
||||||
|
byte[] encryptedBody = new byte[(int) encryptedBodyLen];
|
||||||
|
bb.get(encryptedBody);
|
||||||
|
byte[] signature64 = new byte[64];
|
||||||
|
bb.get(signature64);
|
||||||
|
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
||||||
|
|
||||||
|
return new SignedMessageBlock(
|
||||||
|
toLogin,
|
||||||
|
fromLogin,
|
||||||
|
timeMs,
|
||||||
|
nonce,
|
||||||
|
messageType,
|
||||||
|
revisionTimeMs,
|
||||||
|
major,
|
||||||
|
minor,
|
||||||
|
encryptedBody,
|
||||||
|
encryptedBody,
|
||||||
|
signedBody,
|
||||||
|
signature64,
|
||||||
|
raw,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,10 +193,36 @@ 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,25 +1,41 @@
|
|||||||
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_PAYLOAD_BYTES = 4096;
|
private static final int MAX_ENCRYPTED_BODY_BYTES = 16384;
|
||||||
|
|
||||||
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_PAYLOAD_BYTES);
|
return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_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 {
|
||||||
@ -42,7 +58,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.messageType == SignedMessageBlock.TYPE_READ_INCOMING) {
|
if (incoming.isReadReceiptType()) {
|
||||||
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)
|
||||||
@ -52,6 +68,27 @@ 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +105,7 @@ 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);
|
||||||
@ -83,7 +121,10 @@ final class SignedMessagesCore {
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
static boolean saveIfAbsent(SignedMessageV2Entry entry) throws Exception {
|
static String previewTextForPush(SignedMessageBlock block) {
|
||||||
return SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
|
if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,8 +21,13 @@ 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();
|
||||||
@ -39,8 +44,11 @@ public final class SignedMessagesRealtime {
|
|||||||
counters.wsDelivered++;
|
counters.wsDelivered++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT) {
|
if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT
|
||||||
boolean pushed = pushNewMessageNotification(s, message);
|
&& block != null
|
||||||
|
&& block.revisionTimeMs == 0
|
||||||
|
&& !block.isDeletedContent()) {
|
||||||
|
boolean pushed = pushNewMessageNotification(s, message, block);
|
||||||
if (pushed) counters.pushDelivered++;
|
if (pushed) counters.pushDelivered++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -89,13 +97,21 @@ public final class SignedMessagesRealtime {
|
|||||||
return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
|
return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) {
|
private static boolean pushNewMessageNotification(
|
||||||
|
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 text = "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения.";
|
String preview = SignedMessagesCore.previewTextForPush(block).replace('\n', ' ').trim();
|
||||||
|
if (preview.length() > 80) preview = preview.substring(0, 80) + "...";
|
||||||
|
String text = preview.isBlank()
|
||||||
|
? "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения."
|
||||||
|
: preview;
|
||||||
String 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,9 +9,11 @@ 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.*;
|
||||||
|
|
||||||
@ -38,7 +40,7 @@ public class IT_07_EspPairing {
|
|||||||
|
|
||||||
sessionLogin2Steps(clientWs, clientSession, 1, "Web", t, r);
|
sessionLogin2Steps(clientWs, clientSession, 1, "Web", t, r);
|
||||||
|
|
||||||
String passwordHash = "argon2id$v=19$m=65536,t=2,p=1$test$esp_pairing_hash";
|
String passwordHash = derivePairingHash(LOGIN, "test-pairing-password");
|
||||||
String upsertResp = clientWs.call(
|
String upsertResp = clientWs.call(
|
||||||
"UpsertEspPairingSettings",
|
"UpsertEspPairingSettings",
|
||||||
JsonBuilders.upsertEspPairingSettings(true, passwordHash, 180),
|
JsonBuilders.upsertEspPairingSettings(true, passwordHash, 180),
|
||||||
@ -79,6 +81,59 @@ 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(),
|
||||||
@ -86,7 +141,7 @@ public class IT_07_EspPairing {
|
|||||||
);
|
);
|
||||||
assertErrorFormat(forbiddenResp, "ListEspPairingRequests", "PAIRING_REQUIRES_AUTH_SESSION");
|
assertErrorFormat(forbiddenResp, "ListEspPairingRequests", "PAIRING_REQUIRES_AUTH_SESSION");
|
||||||
|
|
||||||
r.ok("ESP pairing: обычная доверенная сессия увидела запрос и подтвердила зашифрованный payload");
|
r.ok("ESP pairing: доверенная сессия принимает заявки как с доп. паролем, так и без него");
|
||||||
}
|
}
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
r.fail("IT_07_EspPairing упал: " + e.getMessage());
|
r.fail("IT_07_EspPairing упал: " + e.getMessage());
|
||||||
@ -165,6 +220,17 @@ 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,6 +333,20 @@ 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.192
|
client.version=1.2.216
|
||||||
server.version=1.2.181
|
server.version=1.2.204
|
||||||
|
|||||||
11
build.gradle
11
build.gradle
@ -292,17 +292,6 @@ 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')
|
||||||
}
|
}
|
||||||
|
|||||||
31
codex-agent-VPS/.env.example
Normal file
31
codex-agent-VPS/.env.example
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
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
Normal file
5
codex-agent-VPS/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
run/
|
||||||
|
__pycache__/
|
||||||
86
codex-agent-VPS/AGENTS.md
Normal file
86
codex-agent-VPS/AGENTS.md
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# 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