Solana-first регистрация: lazy-import пользователя при входе, AddUser отключен, UI ожидание 15с
This commit is contained in:
parent
6f0bb01b61
commit
101fd2eaa4
@ -1,14 +1,14 @@
|
|||||||
# API для разработчиков: Регистрация пользователя
|
# API для разработчиков: Регистрация пользователя
|
||||||
|
|
||||||
Этот файл описывает временный раздел API, связанный с заведением пользователя на сервере и проверкой, существует ли пользователь.
|
Этот файл описывает раздел API, связанный с проверкой наличия пользователя на сервере и dev/test операциями.
|
||||||
|
|
||||||
Сейчас здесь три метода:
|
Сейчас здесь три метода:
|
||||||
|
|
||||||
- `AddUser` — временная серверная регистрация пользователя;
|
- `AddUser` — операция отключена (регистрация только через Solana);
|
||||||
- `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных;
|
- `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных;
|
||||||
- `SearchUsers` — dev/test поиск логинов по префиксу.
|
- `SearchUsers` — dev/test поиск логинов по префиксу.
|
||||||
|
|
||||||
Их логика пока вспомогательная и dev-oriented: сервер сам хранит эти данные локально и сам отвечает на existence-check. В будущем оба сценария должны быть заменены на нормальную работу напрямую через Solana, но пока этот контракт нужен клиентам для разработки и интеграции.
|
Регистрация выполняется через Solana (`shine_users`). Сервер при входе может лениво импортировать пользователя из Solana PDA в локальную БД, если записи ещё нет.
|
||||||
|
|
||||||
## Статус документа
|
## Статус документа
|
||||||
|
|
||||||
@ -22,12 +22,7 @@
|
|||||||
|
|
||||||
### Назначение
|
### Назначение
|
||||||
|
|
||||||
Временная регистрация локального пользователя на сервере.
|
Операция отключена. Используется только как явный ответ клиентам старых версий.
|
||||||
|
|
||||||
Сервер:
|
|
||||||
|
|
||||||
- создаёт запись в `solana_users`;
|
|
||||||
- создаёт стартовое состояние в `blockchain_state`.
|
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
@ -46,29 +41,16 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Успешный ответ
|
### Пример ответа
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"op": "AddUser",
|
"op": "AddUser",
|
||||||
"requestId": "reg-001",
|
"requestId": "reg-001",
|
||||||
"status": 200,
|
"status": 410,
|
||||||
"ok": true,
|
|
||||||
"payload": {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пример ошибки
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "AddUser",
|
|
||||||
"requestId": "reg-001",
|
|
||||||
"status": 409,
|
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"error": "USER_ALREADY_EXISTS",
|
"error": "ADD_USER_DISABLED",
|
||||||
"message": "Пользователь с таким login уже существует",
|
"message": "Серверная регистрация AddUser отключена. Используйте регистрацию через Solana.",
|
||||||
"payload": {
|
"payload": {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -76,14 +58,7 @@
|
|||||||
|
|
||||||
### Специфические коды ошибок `AddUser`
|
### Специфические коды ошибок `AddUser`
|
||||||
|
|
||||||
- `400 / BAD_FIELDS` — не переданы обязательные поля регистрации.
|
- `410 / ADD_USER_DISABLED` — серверная регистрация отключена, используйте Solana-first flow.
|
||||||
- `400 / BAD_BLOCKCHAIN_NAME` — `blockchainName` не соответствует формату `<login>-NNN`.
|
|
||||||
- `400 / BAD_KEY_FORMAT` — один из ключей не является корректным `Base64(32 bytes)`.
|
|
||||||
- `409 / USER_ALREADY_EXISTS` — пользователь с таким `login` уже есть.
|
|
||||||
- `409 / BLOCKCHAIN_ALREADY_EXISTS` — такой `blockchainName` уже занят.
|
|
||||||
- `409 / BLOCKCHAIN_STATE_ALREADY_EXISTS` — стартовое состояние blockchain уже существует.
|
|
||||||
- `501 / DB_ERROR` — ошибка БД при создании пользователя.
|
|
||||||
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -95,9 +70,8 @@
|
|||||||
|
|
||||||
Важно:
|
Важно:
|
||||||
|
|
||||||
- это временное решение;
|
- это server-side existence-check;
|
||||||
- позже клиент должен проверять existence/identity напрямую через Solana;
|
- если пользователя нет в локальной БД, он может быть импортирован при авторизации из Solana PDA.
|
||||||
- на финальный production flow не стоит жёстко завязывать архитектуру клиента на `GetUser`.
|
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
@ -209,7 +183,7 @@
|
|||||||
|
|
||||||
## 4. Короткое резюме
|
## 4. Короткое резюме
|
||||||
|
|
||||||
- `AddUser` — временная регистрация пользователя на сервере.
|
- `AddUser` — отключен (`410 / ADD_USER_DISABLED`).
|
||||||
- `GetUser` — временная проверка существования пользователя на сервере.
|
- `GetUser` — проверка существования пользователя на сервере.
|
||||||
- `SearchUsers` — временный поиск пользователей по префиксу.
|
- `SearchUsers` — временный поиск пользователей по префиксу.
|
||||||
- И регистрация, и existence-check позже должны быть переведены на Solana.
|
- Регистрация выполняется только через Solana.
|
||||||
|
|||||||
@ -73,6 +73,7 @@ ed25519/BASE64_PUBLIC_KEY
|
|||||||
- `400 / EMPTY_LOGIN` — пустой `login`.
|
- `400 / EMPTY_LOGIN` — пустой `login`.
|
||||||
- `400 / ALREADY_AUTHED` — по текущему соединению уже выполнена авторизация.
|
- `400 / ALREADY_AUTHED` — по текущему соединению уже выполнена авторизация.
|
||||||
- `422 / UNKNOWN_USER` — пользователь с таким `login` не найден.
|
- `422 / UNKNOWN_USER` — пользователь с таким `login` не найден.
|
||||||
|
- `501 / SOLANA_IMPORT_FAILED` — сервер не смог проверить/импортировать пользователя из Solana при lazy-import.
|
||||||
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера, если появится вне штатного сценария.
|
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера, если появится вне штатного сценария.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
| Операция | Раздел документации | Кратко |
|
| Операция | Раздел документации | Кратко |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `AddUser` | `01_User_Registration_API.md` | временная регистрация пользователя |
|
| `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) |
|
||||||
| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя |
|
| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя |
|
||||||
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
|
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
|
||||||
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
|
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.93
|
client.version=1.2.94
|
||||||
server.version=1.2.87
|
server.version=1.2.88
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { registerUserOnSolana } from '../services/solana-register-service.js';
|
|||||||
|
|
||||||
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
|
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
|
||||||
const MIN_REQUIRED_SOL = 0.01;
|
const MIN_REQUIRED_SOL = 0.01;
|
||||||
|
const SOLANA_SYNC_WAIT_SEC = 15;
|
||||||
|
|
||||||
function parseBalanceSol(value) {
|
function parseBalanceSol(value) {
|
||||||
const parsed = Number.parseFloat(String(value || '').replace(',', '.'));
|
const parsed = Number.parseFloat(String(value || '').replace(',', '.'));
|
||||||
@ -198,18 +199,7 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Регистрация на сервере SHiNE
|
renderSolanaDoneStage({ navigate, status, keyBundle });
|
||||||
submitButton.textContent = 'Регистрация на сервере...';
|
|
||||||
await authService.reconnect(state.entrySettings.shineServer);
|
|
||||||
const result = await authService.registerUserWithKeyBundle(state.registrationDraft.login, keyBundle);
|
|
||||||
state.registrationDraft.flowType = 'registration';
|
|
||||||
state.registrationDraft.sessionId = result.sessionId;
|
|
||||||
state.registrationDraft.storagePwd = result.storagePwd;
|
|
||||||
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
|
||||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
|
||||||
|
|
||||||
setAuthInfo(`Регистрация завершена. Вы вошли как @${result.login}. Далее откройте вкладку «Каналы».`);
|
|
||||||
navigate('registration-keys-view');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = toUserMessage(error, 'Не удалось завершить регистрацию.');
|
const message = toUserMessage(error, 'Не удалось завершить регистрацию.');
|
||||||
setAuthError(message);
|
setAuthError(message);
|
||||||
@ -254,3 +244,94 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSolanaDoneStage({ navigate, status, keyBundle }) {
|
||||||
|
const screen = document.querySelector('section.stack');
|
||||||
|
if (!screen) return;
|
||||||
|
const card = screen.querySelector('.card.stack');
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
let remainingSec = SOLANA_SYNC_WAIT_SEC;
|
||||||
|
let canTryLogin = false;
|
||||||
|
let timerId = null;
|
||||||
|
|
||||||
|
const info = document.createElement('p');
|
||||||
|
info.className = 'auth-copy';
|
||||||
|
info.textContent = 'Регистрация в Solana прошла успешно.';
|
||||||
|
|
||||||
|
const hint = document.createElement('p');
|
||||||
|
hint.className = 'meta-muted';
|
||||||
|
hint.textContent = 'Подождите 10–15 секунд, пока запись обновится в блокчейне. После этого можно входить на сервер.';
|
||||||
|
|
||||||
|
const timer = document.createElement('p');
|
||||||
|
timer.className = 'meta-muted';
|
||||||
|
timer.textContent = `До попытки входа: ${remainingSec} сек`;
|
||||||
|
|
||||||
|
const tryLoginBtn = document.createElement('button');
|
||||||
|
tryLoginBtn.className = 'primary-btn';
|
||||||
|
tryLoginBtn.type = 'button';
|
||||||
|
tryLoginBtn.textContent = `Попробовать войти (${remainingSec})`;
|
||||||
|
tryLoginBtn.disabled = true;
|
||||||
|
|
||||||
|
const backBtn = document.createElement('button');
|
||||||
|
backBtn.className = 'ghost-btn';
|
||||||
|
backBtn.type = 'button';
|
||||||
|
backBtn.textContent = 'Назад';
|
||||||
|
backBtn.addEventListener('click', () => navigate('register-view'));
|
||||||
|
|
||||||
|
const stopTimer = () => {
|
||||||
|
if (timerId) {
|
||||||
|
window.clearInterval(timerId);
|
||||||
|
timerId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTimerUi = () => {
|
||||||
|
if (remainingSec > 0) {
|
||||||
|
timer.textContent = `До попытки входа: ${remainingSec} сек`;
|
||||||
|
tryLoginBtn.textContent = `Попробовать войти (${remainingSec})`;
|
||||||
|
tryLoginBtn.disabled = true;
|
||||||
|
} else {
|
||||||
|
canTryLogin = true;
|
||||||
|
timer.textContent = 'Можно входить на сервер.';
|
||||||
|
tryLoginBtn.textContent = 'Попробовать войти на сервер';
|
||||||
|
tryLoginBtn.disabled = false;
|
||||||
|
stopTimer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tryLoginBtn.addEventListener('click', async () => {
|
||||||
|
if (!canTryLogin) return;
|
||||||
|
status.style.display = 'none';
|
||||||
|
tryLoginBtn.disabled = true;
|
||||||
|
tryLoginBtn.textContent = 'Вход...';
|
||||||
|
try {
|
||||||
|
await authService.reconnect(state.entrySettings.shineServer);
|
||||||
|
const result = await authService.createSessionForExistingUser(
|
||||||
|
state.registrationDraft.login,
|
||||||
|
state.registrationDraft.password,
|
||||||
|
);
|
||||||
|
state.registrationDraft.flowType = 'registration';
|
||||||
|
state.registrationDraft.sessionId = result.sessionId;
|
||||||
|
state.registrationDraft.storagePwd = result.storagePwd;
|
||||||
|
state.registrationDraft.pendingKeyBundle = keyBundle;
|
||||||
|
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||||
|
setAuthInfo(`Регистрация завершена. Вы вошли как @${result.login}. Далее откройте вкладку «Каналы».`);
|
||||||
|
navigate('registration-keys-view');
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'status-line is-unavailable';
|
||||||
|
status.textContent = toUserMessage(error, 'Пока не удалось войти. Попробуйте ещё раз через несколько секунд.');
|
||||||
|
status.style.display = '';
|
||||||
|
tryLoginBtn.disabled = false;
|
||||||
|
tryLoginBtn.textContent = 'Попробовать войти на сервер';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
card.innerHTML = '';
|
||||||
|
card.append(info, hint, timer, tryLoginBtn, backBtn, status);
|
||||||
|
updateTimerUi();
|
||||||
|
timerId = window.setInterval(() => {
|
||||||
|
remainingSec -= 1;
|
||||||
|
updateTimerUi();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
|||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import shine.db.dao.SolanaUsersDAO;
|
import shine.db.dao.SolanaUsersDAO;
|
||||||
import shine.db.entities.SolanaUserEntry;
|
import shine.db.entities.SolanaUserEntry;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
|
|
||||||
@ -30,6 +32,7 @@ import java.security.SecureRandom;
|
|||||||
*/
|
*/
|
||||||
public class Net_AuthChallenge_Handler implements JsonMessageHandler {
|
public class Net_AuthChallenge_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_AuthChallenge_Handler.class);
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -58,6 +61,22 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login);
|
SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login);
|
||||||
|
if (solanaUserEntry == null) {
|
||||||
|
try {
|
||||||
|
solanaUserEntry = SolanaUserPdaImportService.findOrImportByLogin(login);
|
||||||
|
if (solanaUserEntry != null) {
|
||||||
|
log.info("AuthChallenge: пользователь {} импортирован из Solana PDA", solanaUserEntry.getLogin());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("AuthChallenge: ошибка lazy-import пользователя {} из Solana", login, e);
|
||||||
|
return NetExceptionResponseFactory.error(
|
||||||
|
req,
|
||||||
|
WireCodes.Status.SERVER_DATA_ERROR,
|
||||||
|
"SOLANA_IMPORT_FAILED",
|
||||||
|
"Ошибка проверки пользователя в Solana"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (solanaUserEntry == null) {
|
if (solanaUserEntry == null) {
|
||||||
return NetExceptionResponseFactory.error(
|
return NetExceptionResponseFactory.error(
|
||||||
req,
|
req,
|
||||||
|
|||||||
@ -0,0 +1,267 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.auth;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import shine.db.dao.SolanaUsersDAO;
|
||||||
|
import shine.db.dao.UserCreateDAO;
|
||||||
|
import shine.db.entities.SolanaUserEntry;
|
||||||
|
import utils.config.SolanaProgramsConfig;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-import пользователя из Solana PDA в локальную БД сервера.
|
||||||
|
* Используется при входе, если в solana_users нет записи по login.
|
||||||
|
*/
|
||||||
|
public final class SolanaUserPdaImportService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SolanaUserPdaImportService.class);
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||||
|
private static final int USER_PDA_SPACE = 768;
|
||||||
|
private static final String MAGIC = "SHiNE";
|
||||||
|
|
||||||
|
private SolanaUserPdaImportService() {}
|
||||||
|
|
||||||
|
public static SolanaUserEntry findOrImportByLogin(String loginRaw) throws Exception {
|
||||||
|
String login = normalizeLogin(loginRaw);
|
||||||
|
if (login == null) return null;
|
||||||
|
|
||||||
|
SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance();
|
||||||
|
SolanaUserEntry existing = usersDao.getByLogin(login);
|
||||||
|
if (existing != null) return existing;
|
||||||
|
|
||||||
|
ParsedSolanaUser parsed = fetchFromSolana(login);
|
||||||
|
if (parsed == null) return null;
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long sizeLimit = parsed.paidLimitBytes > 0 ? parsed.paidLimitBytes : 100_000L;
|
||||||
|
boolean inserted = UserCreateDAO.getInstance().insertUserWithBlockchain(
|
||||||
|
parsed.login,
|
||||||
|
parsed.blockchainName,
|
||||||
|
parsed.deviceKeyB64, // в текущей модели solanaKey = deviceKey
|
||||||
|
parsed.blockchainKeyB64,
|
||||||
|
parsed.deviceKeyB64,
|
||||||
|
sizeLimit,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
if (!inserted) {
|
||||||
|
return usersDao.getByLogin(login);
|
||||||
|
}
|
||||||
|
return usersDao.getByLogin(login);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ParsedSolanaUser fetchFromSolana(String login) throws Exception {
|
||||||
|
String loginB58 = toBase58(login.getBytes(StandardCharsets.UTF_8));
|
||||||
|
String lenB58 = toBase58(new byte[]{(byte) login.length()});
|
||||||
|
|
||||||
|
String reqJson = """
|
||||||
|
{
|
||||||
|
"jsonrpc":"2.0",
|
||||||
|
"id":1,
|
||||||
|
"method":"getProgramAccounts",
|
||||||
|
"params":[
|
||||||
|
"%s",
|
||||||
|
{
|
||||||
|
"encoding":"base64",
|
||||||
|
"filters":[
|
||||||
|
{"dataSize":%d},
|
||||||
|
{"memcmp":{"offset":61,"bytes":"%s"}},
|
||||||
|
{"memcmp":{"offset":62,"bytes":"%s"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".formatted(SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID, USER_PDA_SPACE, lenB58, loginB58);
|
||||||
|
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(SolanaProgramsConfig.SOLANA_RPC_URL))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(reqJson))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||||
|
throw new IllegalStateException("Solana RPC HTTP status=" + resp.statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode root = MAPPER.readTree(resp.body());
|
||||||
|
if (root.hasNonNull("error")) {
|
||||||
|
throw new IllegalStateException("Solana RPC error: " + root.get("error").toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode result = root.path("result");
|
||||||
|
if (!result.isArray() || result.isEmpty()) return null;
|
||||||
|
|
||||||
|
for (JsonNode item : result) {
|
||||||
|
JsonNode dataNode = item.path("account").path("data");
|
||||||
|
if (!dataNode.isArray() || dataNode.size() < 1) continue;
|
||||||
|
String b64 = dataNode.get(0).asText("");
|
||||||
|
if (b64.isBlank()) continue;
|
||||||
|
byte[] raw = Base64.getDecoder().decode(b64);
|
||||||
|
ParsedSolanaUser parsed = parseUserPda(raw);
|
||||||
|
if (parsed != null && parsed.login.equalsIgnoreCase(login)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ParsedSolanaUser parseUserPda(byte[] raw) {
|
||||||
|
if (raw == null || raw.length < 128) return null;
|
||||||
|
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
|
||||||
|
|
||||||
|
int recordLen = u16le(raw, 7);
|
||||||
|
if (recordLen < 73 || recordLen > raw.length) return null;
|
||||||
|
|
||||||
|
int c = 9;
|
||||||
|
c += 8; // created_at_ms
|
||||||
|
c += 8; // updated_at_ms
|
||||||
|
c += 4; // record_number
|
||||||
|
c += 32; // prev_record_hash
|
||||||
|
|
||||||
|
int loginLen = u8(raw, c++);
|
||||||
|
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
||||||
|
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
||||||
|
c += loginLen;
|
||||||
|
|
||||||
|
int blocksCount = u8(raw, c++);
|
||||||
|
String blockchainName = null;
|
||||||
|
byte[] blockchainKey32 = null;
|
||||||
|
byte[] deviceKey32 = null;
|
||||||
|
long paidLimitBytes = 0L;
|
||||||
|
|
||||||
|
for (int i = 0; i < blocksCount; i++) {
|
||||||
|
int blockType = u8(raw, c++);
|
||||||
|
int blockVer = u8(raw, c++);
|
||||||
|
if (blockVer != 0) return null;
|
||||||
|
|
||||||
|
if (blockType == 1) {
|
||||||
|
c += 32;
|
||||||
|
} else if (blockType == 2) {
|
||||||
|
deviceKey32 = slice(raw, c, 32);
|
||||||
|
c += 32;
|
||||||
|
} else if (blockType == 3) {
|
||||||
|
int count = u8(raw, c++);
|
||||||
|
for (int j = 0; j < count; j++) {
|
||||||
|
c += 1; // blockchain_type
|
||||||
|
int bchLen = u8(raw, c++);
|
||||||
|
blockchainName = new String(raw, c, bchLen, StandardCharsets.UTF_8);
|
||||||
|
c += bchLen;
|
||||||
|
blockchainKey32 = slice(raw, c, 32);
|
||||||
|
c += 32;
|
||||||
|
paidLimitBytes = u64le(raw, c);
|
||||||
|
c += 8;
|
||||||
|
c += 8; // used_bytes
|
||||||
|
c += 4; // last_block_number
|
||||||
|
c += 32; // last_block_hash
|
||||||
|
c += 64; // last_block_signature
|
||||||
|
int arweavePresent = u8(raw, c++);
|
||||||
|
if (arweavePresent == 1) {
|
||||||
|
int arLen = u8(raw, c++);
|
||||||
|
c += arLen;
|
||||||
|
} else if (arweavePresent != 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (blockType == 4) {
|
||||||
|
c += 1 + 32;
|
||||||
|
int addrLen = u8(raw, c++);
|
||||||
|
c += addrLen;
|
||||||
|
int syncCount = u8(raw, c++);
|
||||||
|
for (int j = 0; j < syncCount; j++) {
|
||||||
|
int n = u8(raw, c++);
|
||||||
|
c += n;
|
||||||
|
}
|
||||||
|
} else if (blockType == 40) {
|
||||||
|
int accessCount = u8(raw, c++);
|
||||||
|
for (int j = 0; j < accessCount; j++) {
|
||||||
|
int n = u8(raw, c++);
|
||||||
|
c += n;
|
||||||
|
}
|
||||||
|
} else if (blockType == 50) {
|
||||||
|
c += 1;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c > recordLen) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockchainName == null || blockchainKey32 == null || deviceKey32 == null) return null;
|
||||||
|
return new ParsedSolanaUser(
|
||||||
|
login,
|
||||||
|
blockchainName,
|
||||||
|
Base64.getEncoder().encodeToString(blockchainKey32),
|
||||||
|
Base64.getEncoder().encodeToString(deviceKey32),
|
||||||
|
paidLimitBytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeLogin(String login) {
|
||||||
|
if (login == null) return null;
|
||||||
|
String s = login.trim();
|
||||||
|
if (s.isEmpty()) return null;
|
||||||
|
return s.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int u8(byte[] b, int o) { return b[o] & 0xFF; }
|
||||||
|
private static int u16le(byte[] b, int o) {
|
||||||
|
return (b[o] & 0xFF) | ((b[o + 1] & 0xFF) << 8);
|
||||||
|
}
|
||||||
|
private static long u64le(byte[] b, int o) {
|
||||||
|
long out = 0L;
|
||||||
|
for (int i = 0; i < 8; i++) out |= ((long) (b[o + i] & 0xFF)) << (8 * i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
private static byte[] slice(byte[] b, int o, int len) {
|
||||||
|
byte[] out = new byte[len];
|
||||||
|
System.arraycopy(b, o, out, 0, len);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нужен для memcmp.bytes в Solana RPC.
|
||||||
|
private static String toBase58(byte[] input) {
|
||||||
|
final char[] alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray();
|
||||||
|
int zeros = 0;
|
||||||
|
while (zeros < input.length && input[zeros] == 0) zeros++;
|
||||||
|
byte[] tmp = new byte[input.length * 2];
|
||||||
|
int j = tmp.length;
|
||||||
|
int startAt = zeros;
|
||||||
|
while (startAt < input.length) {
|
||||||
|
int mod = divmod58(input, startAt);
|
||||||
|
if (input[startAt] == 0) startAt++;
|
||||||
|
tmp[--j] = (byte) alphabet[mod];
|
||||||
|
}
|
||||||
|
while (j < tmp.length && tmp[j] == alphabet[0]) j++;
|
||||||
|
while (--zeros >= 0) tmp[--j] = (byte) alphabet[0];
|
||||||
|
return new String(tmp, j, tmp.length - j, StandardCharsets.US_ASCII);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int divmod58(byte[] number, int startAt) {
|
||||||
|
int remainder = 0;
|
||||||
|
for (int i = startAt; i < number.length; i++) {
|
||||||
|
int digit256 = number[i] & 0xFF;
|
||||||
|
int temp = remainder * 256 + digit256;
|
||||||
|
number[i] = (byte) (temp / 58);
|
||||||
|
remainder = temp % 58;
|
||||||
|
}
|
||||||
|
return remainder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ParsedSolanaUser(
|
||||||
|
String login,
|
||||||
|
String blockchainName,
|
||||||
|
String blockchainKeyB64,
|
||||||
|
String deviceKeyB64,
|
||||||
|
long paidLimitBytes
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,185 +1,26 @@
|
|||||||
package server.logic.ws_protocol.JSON.handlers.tempToTest;
|
package server.logic.ws_protocol.JSON.handlers.tempToTest;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import server.logic.ws_protocol.Base64Ws;
|
|
||||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Response;
|
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
|
||||||
import shine.db.SqliteDbController;
|
|
||||||
import shine.db.dao.BlockchainStateDAO;
|
|
||||||
import shine.db.dao.SolanaUsersDAO;
|
|
||||||
import shine.db.entities.BlockchainStateEntry;
|
|
||||||
import shine.db.entities.SolanaUserEntry;
|
|
||||||
import utils.blockchain.BlockchainNameUtil;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AddUser отключен: регистрация работает через Solana.
|
||||||
|
*/
|
||||||
public class Net_AddUser_Handler implements JsonMessageHandler {
|
public class Net_AddUser_Handler implements JsonMessageHandler {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(Net_AddUser_Handler.class);
|
|
||||||
|
|
||||||
/** TEST ONLY */
|
|
||||||
private static final int TEST_BCH_LIMIT = 1_000_000;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
Net_AddUser_Request req = (Net_AddUser_Request) baseRequest;
|
Net_AddUser_Request req = (Net_AddUser_Request) baseRequest;
|
||||||
|
|
||||||
if (req.getLogin() == null || req.getLogin().isBlank()
|
|
||||||
|| req.getBlockchainName() == null || req.getBlockchainName().isBlank()
|
|
||||||
|| req.getSolanaKey() == null || req.getSolanaKey().isBlank()
|
|
||||||
|| req.getBlockchainKey() == null || req.getBlockchainKey().isBlank()
|
|
||||||
|| req.getDeviceKey() == null || req.getDeviceKey().isBlank()) {
|
|
||||||
|
|
||||||
return NetExceptionResponseFactory.error(
|
return NetExceptionResponseFactory.error(
|
||||||
req,
|
req,
|
||||||
WireCodes.Status.BAD_REQUEST,
|
410,
|
||||||
"BAD_FIELDS",
|
"ADD_USER_DISABLED",
|
||||||
"Некорректные поля: login/blockchainName/solanaKey/blockchainKey/deviceKey"
|
"Серверная регистрация AddUser отключена. Используйте регистрацию через Solana."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// blockchainName должен быть вида: <login>-NNN
|
|
||||||
if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) {
|
|
||||||
return NetExceptionResponseFactory.error(
|
|
||||||
req,
|
|
||||||
WireCodes.Status.BAD_REQUEST,
|
|
||||||
"BAD_BLOCKCHAIN_NAME",
|
|
||||||
"blockchainName должен быть вида <login>-NNN (пример: anya-001)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0)
|
|
||||||
? TEST_BCH_LIMIT
|
|
||||||
: req.getBchLimit();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// базовая валидация форматов ключей: Base64(32 bytes)
|
|
||||||
byte[] solanaKey32;
|
|
||||||
byte[] blockchainKey32;
|
|
||||||
byte[] deviceKey32;
|
|
||||||
|
|
||||||
try {
|
|
||||||
solanaKey32 = Base64Ws.decodeLen(req.getSolanaKey(), 32, "solanaKey");
|
|
||||||
blockchainKey32 = Base64Ws.decodeLen(req.getBlockchainKey(), 32, "blockchainKey");
|
|
||||||
deviceKey32 = Base64Ws.decodeLen(req.getDeviceKey(), 32, "deviceKey");
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return NetExceptionResponseFactory.error(
|
|
||||||
req,
|
|
||||||
WireCodes.Status.BAD_REQUEST,
|
|
||||||
"BAD_KEY_FORMAT",
|
|
||||||
e.getMessage()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// (переменные не используются дальше, но оставляем для ясности проверки длины)
|
|
||||||
if (solanaKey32.length != 32 || blockchainKey32.length != 32 || deviceKey32.length != 32) {
|
|
||||||
return NetExceptionResponseFactory.error(
|
|
||||||
req,
|
|
||||||
WireCodes.Status.BAD_REQUEST,
|
|
||||||
"BAD_KEY_FORMAT",
|
|
||||||
"solanaKey/blockchainKey/deviceKey должны быть Base64(32 bytes)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance();
|
|
||||||
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
|
||||||
|
|
||||||
SqliteDbController db = SqliteDbController.getInstance();
|
|
||||||
|
|
||||||
try (Connection c = db.getConnection()) {
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
|
|
||||||
// 1. Проверяем, что пользователя нет (case-insensitive)
|
|
||||||
if (usersDAO.getByLogin(c, req.getLogin()) != null) {
|
|
||||||
return NetExceptionResponseFactory.error(
|
|
||||||
req,
|
|
||||||
409,
|
|
||||||
"USER_ALREADY_EXISTS",
|
|
||||||
"Пользователь с таким login уже существует"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Проверяем, что blockchainName ещё нет (case-sensitive, как в БД)
|
|
||||||
if (usersDAO.existsByBlockchainName(c, req.getBlockchainName())) {
|
|
||||||
return NetExceptionResponseFactory.error(
|
|
||||||
req,
|
|
||||||
409,
|
|
||||||
"BLOCKCHAIN_ALREADY_EXISTS",
|
|
||||||
"Пользователь с таким blockchainName уже существует"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. На всякий случай оставляем старую проверку blockchain_state,
|
|
||||||
// потому что эта таблица нужна серверу (состояние цепочки/лимиты).
|
|
||||||
if (stateDAO.getByBlockchainName(c, req.getBlockchainName()) != null) {
|
|
||||||
return NetExceptionResponseFactory.error(
|
|
||||||
req,
|
|
||||||
409,
|
|
||||||
"BLOCKCHAIN_STATE_ALREADY_EXISTS",
|
|
||||||
"blockchain_state уже существует"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Создаём пользователя (все поля теперь лежат в solana_users)
|
|
||||||
SolanaUserEntry user = new SolanaUserEntry();
|
|
||||||
user.setLogin(req.getLogin());
|
|
||||||
user.setBlockchainName(req.getBlockchainName());
|
|
||||||
user.setSolanaKey(req.getSolanaKey());
|
|
||||||
user.setBlockchainKey(req.getBlockchainKey());
|
|
||||||
user.setDeviceKey(req.getDeviceKey());
|
|
||||||
|
|
||||||
usersDAO.insert(c, user);
|
|
||||||
|
|
||||||
// 5. Создаём INITIAL blockchain_state (для работы сервера)
|
|
||||||
BlockchainStateEntry st = new BlockchainStateEntry();
|
|
||||||
st.setBlockchainName(req.getBlockchainName());
|
|
||||||
st.setLogin(req.getLogin());
|
|
||||||
st.setBlockchainKey(req.getBlockchainKey()); // Base64(32)
|
|
||||||
st.setLastBlockNumber(-1);
|
|
||||||
st.setLastBlockHash(new byte[32]);
|
|
||||||
st.setFileSizeBytes(0);
|
|
||||||
st.setSizeLimit(limit);
|
|
||||||
st.setUpdatedAtMs(System.currentTimeMillis());
|
|
||||||
|
|
||||||
stateDAO.upsert(c, st);
|
|
||||||
|
|
||||||
c.commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
Net_AddUser_Response resp = new Net_AddUser_Response();
|
|
||||||
resp.setOp(req.getOp());
|
|
||||||
resp.setRequestId(req.getRequestId());
|
|
||||||
resp.setStatus(WireCodes.Status.OK);
|
|
||||||
|
|
||||||
log.info("✅ AddUser ok: login={}, blockchainName={}, limit={}",
|
|
||||||
req.getLogin(), req.getBlockchainName(), limit);
|
|
||||||
|
|
||||||
return resp;
|
|
||||||
|
|
||||||
} catch (SQLException e) {
|
|
||||||
log.error("❌ DB error AddUser", e);
|
|
||||||
return NetExceptionResponseFactory.error(
|
|
||||||
req,
|
|
||||||
WireCodes.Status.SERVER_DATA_ERROR,
|
|
||||||
"DB_ERROR",
|
|
||||||
"Ошибка БД"
|
|
||||||
);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("❌ Internal error AddUser", e);
|
|
||||||
return NetExceptionResponseFactory.error(
|
|
||||||
req,
|
|
||||||
WireCodes.Status.INTERNAL_ERROR,
|
|
||||||
"INTERNAL_ERROR",
|
|
||||||
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при AddUser", e)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user