Solana-first регистрация: lazy-import пользователя при входе, AddUser отключен, UI ожидание 15с

This commit is contained in:
AidarKC
2026-05-27 18:38:45 +04:00
parent 6f0bb01b61
commit 101fd2eaa4
8 changed files with 408 additions and 225 deletions
@@ -11,6 +11,8 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.SolanaUserEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.SecureRandom;
@@ -30,6 +32,7 @@ import java.security.SecureRandom;
*/
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();
@Override
@@ -58,6 +61,22 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
}
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) {
return NetExceptionResponseFactory.error(
req,
@@ -84,4 +103,4 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
return resp;
}
}
}
@@ -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;
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.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.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.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 {
private static final Logger log = LoggerFactory.getLogger(Net_AddUser_Handler.class);
/** TEST ONLY */
private static final int TEST_BCH_LIMIT = 1_000_000;
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
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(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login/blockchainName/solanaKey/blockchainKey/deviceKey"
);
}
// 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)
);
}
return NetExceptionResponseFactory.error(
req,
410,
"ADD_USER_DISABLED",
"Серверная регистрация AddUser отключена. Используйте регистрацию через Solana."
);
}
}