Обновить синхронизацию серверов и экран сохранения ключей

This commit is contained in:
AidarKC 2026-06-24 20:18:40 +04:00
parent 0f63f7dae6
commit e60475f351
19 changed files with 908 additions and 22 deletions

View File

@ -68,6 +68,12 @@
- Без явного подтверждения пользователя формат серверного API не менять; допускается только приведение документации в соответствие уже существующему коду.
- Если добавляется новая операция `op`, нужно обновить общий список операций в `Dev_Docs/API/09_Operations_Index.md` или создать его, если файла ещё нет.
## Документация Figma
- Актуальная документация по переносу экранов SHiNE в Figma и обратному переносу из Figma в код находится в `Dev_Docs/Figma/`.
- Точка входа: `Dev_Docs/Figma/README.md`.
- Подробный рабочий регламент: `Dev_Docs/Figma/TRANSFER_UI_SCREENS.md`.
- Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ.
## Известная проблема (временная пометка)
- Мнения по связям пользователя (`known_person`, `shine_confirmed`, `shine_seen`) в UI могут отображаться нестабильно.
- Требуется отдельная доработка и финальная проверка end-to-end: запись мнения в блокчейн → обновление `connections_state` → ответ `GetUserConnectionsGraph` → отображение в UI.

View File

@ -89,10 +89,10 @@
| Компонент | Статус |
|-----------|--------|
| Регистрация серверной PDA в Solana | ✅ Реализовано |
| Чтение `sync_servers` из PDA | Нужна реализация |
| Чтение `sync_servers` из PDA | ✅ Реализовано |
| Межсерверный WebSocket-канал | Нужна реализация |
| Push новых DM партнёрам | Нужна реализация |
| Push блоков блокчейна партнёрам | Нужна реализация |
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
| Backfill при первом подключении | Нужна реализация |
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |

View File

@ -0,0 +1,13 @@
# Стартовая загрузка `sync_servers` из server PDA
- Краткое описание:
- При запуске сервер читает свой логин из `server.SHiNE.login`, загружает свою server PDA из Solana, достаёт `sync_servers`, затем читает PDA партнёров и сохраняет их `login + server_address + updated_at_ms` в локальную таблицу `sync_servers`.
- Что проверять:
- В `application.properties` задан `server.SHiNE.login=shineupme`.
- После старта сервера в SQLite появилась/обновилась таблица `sync_servers`.
- В таблице лежат логины и адреса серверов из `sync_servers` текущего server PDA.
- При изменении `sync_servers` или `server_address` в Solana и перезапуске сервера локальная таблица обновляется.
- Ожидаемый результат:
- Сервер без ручного ввода адресов подтягивает партнёров синхронизации из Solana PDA и хранит их локально для следующих этапов репликации.
- Статус:
- `pending`

View File

@ -0,0 +1,15 @@
# Фоновая one-shot синхронизация `AddBlock` на `sync_servers`
- Краткое описание:
- После успешного локального `AddBlock` сервер в фоне пытается отправить тот же блок всем партнёрам из локальной таблицы `sync_servers`.
- Если партнёр отвечает `bad_prev_hash` или `bad_block_number`, сервер один раз делает backfill: читает недостающие блоки из БД по диапазону и досылает их по одному.
- Если в процессе возникает новая ошибка, попытка для этого партнёра прерывается без повторов.
- Что проверять:
- При добавлении нового блока клиент получает быстрый `OK`, не ожидая завершения межсерверной рассылки.
- В логах видно попытки отправки на адреса из `sync_servers`.
- При отставании партнёра сервер досылает пропущенный хвост блоков по одному.
- При ошибке после backfill сервер не зацикливается и не блокирует основной `AddBlock`.
- Ожидаемый результат:
- Репликация `AddBlock` работает в фоне и не ломает основной путь записи блока.
- Статус:
- `pending`

View File

@ -14,7 +14,7 @@ import java.sql.Statement;
public final class SqliteDbController {
private static volatile SqliteDbController instance;
private static final int LATEST_SCHEMA_VERSION = 8;
private static final int LATEST_SCHEMA_VERSION = 9;
private final String jdbcUrl;
@ -91,6 +91,7 @@ public final class SqliteDbController {
case 6 -> migrateToV6();
case 7 -> migrateToV7();
case 8 -> migrateToV8();
case 9 -> migrateToV9();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
}
}
@ -269,6 +270,25 @@ public final class SqliteDbController {
}
}
private void migrateToV9() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureSyncServersTable(st);
setSchemaVersion(c, 9);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v9 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v9 failed", e);
}
}
private static void ensureChat200StateTables(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_state (
@ -468,6 +488,20 @@ public final class SqliteDbController {
""");
}
private static void ensureSyncServersTable(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS sync_servers (
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
server_address TEXT NOT NULL DEFAULT '',
updated_at_ms INTEGER NOT NULL
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_sync_servers_updated
ON sync_servers (updated_at_ms);
""");
}
private static void createConnectionsStateTable(Statement st) throws SQLException {
st.executeUpdate("""

View File

@ -6,6 +6,8 @@ import shine.db.SqliteDbController;
import shine.db.entities.BlockEntry;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
/**
* DAO для таблицы blocks (новый формат).
@ -191,6 +193,53 @@ public final class BlocksDAO {
}
}
public List<BlockEntry> listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
try (Connection c = db.getConnection()) {
return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive);
}
}
public List<BlockEntry> listRangeByNumber(Connection c, String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
String sql = """
SELECT
login,
bch_name,
block_number,
msg_type,
msg_sub_type,
block_bytes,
to_login,
to_bch_name,
to_block_number,
to_block_hash,
block_hash,
block_signature,
edited_by_block_number,
line_code,
prev_line_number,
prev_line_hash,
this_line_number
FROM blocks
WHERE bch_name = ?
AND block_number >= ?
AND block_number <= ?
ORDER BY block_number ASC
""";
List<BlockEntry> result = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bchName);
ps.setInt(2, fromBlockNumberInclusive);
ps.setInt(3, toBlockNumberInclusive);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
result.add(mapRow(rs));
}
}
}
return result;
}
// -------------------- INTERNAL --------------------
private BlockEntry mapRow(ResultSet rs) throws SQLException {

View File

@ -0,0 +1,104 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.entities.SyncServerEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
/**
* DAO локальной таблицы серверов-партнёров для будущей межсерверной синхронизации.
*/
public final class SyncServersDAO {
private static volatile SyncServersDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private SyncServersDAO() {}
public static SyncServersDAO getInstance() {
if (instance == null) {
synchronized (SyncServersDAO.class) {
if (instance == null) instance = new SyncServersDAO();
}
}
return instance;
}
public List<SyncServerEntry> listAll() throws SQLException {
try (Connection c = db.getConnection()) {
return listAll(c);
}
}
public List<SyncServerEntry> listAll(Connection c) throws SQLException {
String sql = """
SELECT login, server_address, updated_at_ms
FROM sync_servers
ORDER BY login
""";
List<SyncServerEntry> result = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
result.add(mapRow(rs));
}
}
return result;
}
/**
* Полностью заменяет список партнёров актуальным снимком из Solana PDA.
*/
public void replaceAll(List<SyncServerEntry> entries) throws SQLException {
try (Connection c = db.getConnection()) {
replaceAll(c, entries);
}
}
public void replaceAll(Connection c, List<SyncServerEntry> entries) throws SQLException {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try (Statement st = c.createStatement()) {
st.executeUpdate("DELETE FROM sync_servers");
String sql = """
INSERT INTO sync_servers (
login, server_address, updated_at_ms
) VALUES (?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
for (SyncServerEntry entry : entries) {
ps.setString(1, entry.getLogin());
ps.setString(2, safe(entry.getServerAddress()));
ps.setLong(3, entry.getUpdatedAtMs());
ps.addBatch();
}
ps.executeBatch();
}
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
if (e instanceof SQLException sqlEx) throw sqlEx;
throw new SQLException("Не удалось обновить таблицу sync_servers", e);
} finally {
try { c.setAutoCommit(oldAutoCommit); } catch (Exception ignored) {}
}
}
private SyncServerEntry mapRow(ResultSet rs) throws SQLException {
SyncServerEntry entry = new SyncServerEntry();
entry.setLogin(rs.getString("login"));
entry.setServerAddress(rs.getString("server_address"));
entry.setUpdatedAtMs(rs.getLong("updated_at_ms"));
return entry;
}
private static String safe(String value) {
return value == null ? "" : value;
}
}

View File

@ -0,0 +1,43 @@
package shine.db.entities;
/**
* Запись о сервере-партнёре, с которым текущий сервер должен синхронизироваться.
*/
public class SyncServerEntry {
private String login;
private String serverAddress;
private long updatedAtMs;
public SyncServerEntry() {}
public SyncServerEntry(String login, String serverAddress, long updatedAtMs) {
this.login = login;
this.serverAddress = serverAddress;
this.updatedAtMs = updatedAtMs;
}
public String getLogin() {
return login;
}
public void setLogin(String login) {
this.login = login;
}
public String getServerAddress() {
return serverAddress;
}
public void setServerAddress(String serverAddress) {
this.serverAddress = serverAddress;
}
public long getUpdatedAtMs() {
return updatedAtMs;
}
public void setUpdatedAtMs(long updatedAtMs) {
this.updatedAtMs = updatedAtMs;
}
}

View File

@ -82,7 +82,34 @@ public final class SolanaUserPdaImportService {
return SessionTypeCheckResult.noRecord();
}
/**
* Чтение server PDA по логину сервера. Используется сервером при старте,
* чтобы получить актуальный server_address и список sync_servers.
*/
public static ParsedServerProfile fetchServerProfileByLogin(String loginRaw) throws Exception {
String login = normalizeLogin(loginRaw);
if (login == null) return null;
byte[] raw = fetchRawUserPda(login);
if (raw == null) return null;
ParsedServerProfile parsed = parseServerProfile(raw);
if (parsed == null) return null;
if (!parsed.login.equalsIgnoreCase(login)) return null;
return parsed;
}
private static ParsedSolanaUser fetchFromSolana(String login) throws Exception {
byte[] raw = fetchRawUserPda(login);
if (raw == null) return null;
ParsedSolanaUser parsed = parseUserPda(raw);
if (parsed != null && parsed.login.equalsIgnoreCase(login)) {
return parsed;
}
return null;
}
private static byte[] fetchRawUserPda(String login) throws Exception {
String loginB58 = toBase58(login.getBytes(StandardCharsets.UTF_8));
String lenB58 = toBase58(new byte[]{(byte) login.length()});
@ -128,11 +155,7 @@ public final class SolanaUserPdaImportService {
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 Base64.getDecoder().decode(b64);
}
return null;
}
@ -258,6 +281,105 @@ public final class SolanaUserPdaImportService {
);
}
private static ParsedServerProfile parseServerProfile(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++);
boolean isServer = false;
String serverAddress = "";
List<String> syncServers = new ArrayList<>();
for (int i = 0; i < blocksCount; i++) {
int blockType = u8(raw, c++);
int blockVer = u8(raw, c++);
if (blockVer != 0) return null;
if (blockType == 0 || blockType == 1 || blockType == 2) {
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++);
c += bchLen;
c += 32; // blockchain pubkey
c += 8; // paid_limit_bytes
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 == 30) {
int isServerValue = u8(raw, c++);
if (isServerValue == 1) {
isServer = true;
c += 1; // address_format_type
c += 1; // address_format_version
int addrLen = u8(raw, c++);
serverAddress = new String(raw, c, addrLen, StandardCharsets.UTF_8);
c += addrLen;
int syncCount = u8(raw, c++);
for (int j = 0; j < syncCount; j++) {
int n = u8(raw, c++);
String syncLogin = new String(raw, c, n, StandardCharsets.UTF_8);
c += n;
syncServers.add(normalizeLogin(syncLogin));
}
} else if (isServerValue != 0) {
return null;
}
} 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) {
int sessionsMode = u8(raw, c++);
if (sessionsMode != 1 && sessionsMode != 10) return null;
int sessionsCount = u8(raw, c++);
if (sessionsCount > 64) return null;
for (int j = 0; j < sessionsCount; j++) {
c += 1; // session_type
c += 1; // session_version
int n = u8(raw, c++);
c += n;
c += 32;
}
} else if (blockType == 70) {
c += 1;
} else {
return null;
}
if (c > recordLen) return null;
}
return new ParsedServerProfile(login, isServer, serverAddress, syncServers);
}
private static String normalizeLogin(String login) {
if (login == null) return null;
String s = login.trim();
@ -350,4 +472,11 @@ public final class SolanaUserPdaImportService {
return new SessionTypeCheckResult(true, false, pdaSessionType, sessionName == null ? "" : sessionName);
}
}
public record ParsedServerProfile(
String login,
boolean isServer,
String serverAddress,
List<String> syncServers
) {}
}

View File

@ -22,6 +22,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
import server.logic.ws_protocol.WireCodes;
import server.sync.AddBlockSyncService;
import shine.db.channels.ChannelNameRules;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
@ -55,6 +56,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
private final UserParamsDAO userParamsDAO = UserParamsDAO.getInstance();
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService();
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO);
@ -484,6 +486,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}",
login, blockchainName, block.blockNumber, newHashHex);
addBlockSyncService.replicateAsync(blockchainName, block.blockNumber);
return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex);
}

View File

@ -0,0 +1,348 @@
package server.sync;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.Base64Ws;
import shine.db.dao.BlocksDAO;
import shine.db.dao.SyncServersDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.SyncServerEntry;
import utils.blockchain.BlockchainNameUtil;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
/**
* Фоновая one-shot репликация AddBlock на серверы из локальной таблицы sync_servers.
*/
public final class AddBlockSyncService {
private static final Logger log = LoggerFactory.getLogger(AddBlockSyncService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(
1,
Math.max(2, Runtime.getRuntime().availableProcessors()),
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10_000),
new ThreadFactory() {
private final AtomicLong n = new AtomicLong(1);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "sync-addblock-" + n.getAndIncrement());
t.setDaemon(true);
return t;
}
},
new ThreadPoolExecutor.DiscardPolicy()
);
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
private final SyncServersDAO syncServersDAO = SyncServersDAO.getInstance();
public void replicateAsync(String blockchainName, int blockNumber) {
EXECUTOR.execute(() -> {
try {
replicate(blockchainName, blockNumber);
} catch (Exception e) {
log.error("AddBlock sync failed unexpectedly (blockchainName={}, blockNumber={})",
blockchainName, blockNumber, e);
}
});
}
private void replicate(String blockchainName, int blockNumber) throws Exception {
String ownerLogin = normalize(BlockchainNameUtil.loginFromBlockchainName(blockchainName));
if (ownerLogin == null) {
log.warn("AddBlock sync skipped: cannot derive owner login from blockchainName={}", blockchainName);
return;
}
List<SyncServerEntry> partners = syncServersDAO.listAll();
if (partners.isEmpty()) {
return;
}
BlockEntry currentBlock = blocksDAO.getByNumber(blockchainName, blockNumber);
if (currentBlock == null || currentBlock.getBlockBytes() == null) {
log.warn("AddBlock sync skipped: block not found in DB (blockchainName={}, blockNumber={})",
blockchainName, blockNumber);
return;
}
for (SyncServerEntry partner : partners) {
if (partner == null) continue;
String partnerLogin = normalize(partner.getLogin());
if (partnerLogin == null) continue;
if (partnerLogin.equals(ownerLogin)) {
continue;
}
try {
replicateToPartner(partner, blockchainName, blockNumber, currentBlock);
} catch (Exception e) {
log.warn("AddBlock sync aborted for partner login={} blockchainName={} blockNumber={} reason={}",
partnerLogin, blockchainName, blockNumber, e.toString());
}
}
}
private void replicateToPartner(SyncServerEntry partner, String blockchainName, int blockNumber, BlockEntry currentBlock) throws Exception {
String wsUrl = buildWsUrl(partner.getServerAddress());
if (wsUrl == null) {
log.warn("AddBlock sync skipped: invalid server_address for partner login={} address={}",
partner.getLogin(), partner.getServerAddress());
return;
}
AddBlockPushResult firstTry = pushBlock(wsUrl, blockchainName, currentBlock);
if (firstTry.ok()) {
log.info("AddBlock sync ok: partner={} blockchainName={} blockNumber={}",
partner.getLogin(), blockchainName, blockNumber);
return;
}
if (!firstTry.needsBackfill()) {
log.warn("AddBlock sync failed without backfill: partner={} blockchainName={} blockNumber={} code={}",
partner.getLogin(), blockchainName, blockNumber, firstTry.code());
return;
}
int remoteLast = firstTry.serverLastGlobalNumber();
int fromBlockNumber = remoteLast + 1;
if (fromBlockNumber > blockNumber) {
log.warn("AddBlock sync inconsistent backfill window: partner={} blockchainName={} remoteLast={} target={}",
partner.getLogin(), blockchainName, remoteLast, blockNumber);
return;
}
List<BlockEntry> missingBlocks = blocksDAO.listRangeByNumber(blockchainName, fromBlockNumber, blockNumber);
if (missingBlocks.isEmpty()) {
log.warn("AddBlock sync backfill failed: local range empty partner={} blockchainName={} from={} to={}",
partner.getLogin(), blockchainName, fromBlockNumber, blockNumber);
return;
}
for (BlockEntry blockEntry : missingBlocks) {
AddBlockPushResult backfillResult = pushBlock(wsUrl, blockchainName, blockEntry);
if (!backfillResult.ok()) {
log.warn("AddBlock sync backfill failed: partner={} blockchainName={} blockNumber={} code={}",
partner.getLogin(), blockchainName, blockEntry.getBlockNumber(), backfillResult.code());
return;
}
}
log.info("AddBlock sync backfill ok: partner={} blockchainName={} from={} to={}",
partner.getLogin(), blockchainName, fromBlockNumber, blockNumber);
}
private AddBlockPushResult pushBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception {
JsonNode response = sendAddBlock(wsUrl, blockchainName, blockEntry);
int status = response.path("status").asInt(500);
if (status >= 200 && status < 300) {
return AddBlockPushResult.success();
}
String code = textOrEmpty(response, "code");
if (code.isBlank()) {
code = textOrEmpty(response, "error");
}
JsonNode payload = response.path("payload");
int serverLastGlobalNumber = payload.path("serverLastGlobalNumber").asInt(Integer.MIN_VALUE);
String serverLastGlobalHash = payload.path("serverLastGlobalHash").asText("");
return new AddBlockPushResult(false, status, code, serverLastGlobalNumber, serverLastGlobalHash);
}
private JsonNode sendAddBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception {
CompletableFuture<String> responseFuture = new CompletableFuture<>();
CountDownLatch openLatch = new CountDownLatch(1);
SyncWsListener listener = new SyncWsListener(responseFuture, openLatch);
WebSocket webSocket = HTTP.newWebSocketBuilder()
.connectTimeout(Duration.ofSeconds(6))
.buildAsync(URI.create(wsUrl), listener)
.get(8, TimeUnit.SECONDS);
if (!openLatch.await(8, TimeUnit.SECONDS)) {
tryAbort(webSocket);
throw new TimeoutException("WS open timeout");
}
String requestId = "sync-" + UUID.randomUUID();
String json = buildAddBlockJson(requestId, blockchainName, blockEntry);
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
tryAbort(webSocket);
return MAPPER.readTree(responseJson);
}
private String buildAddBlockJson(String requestId, String blockchainName, BlockEntry blockEntry) throws Exception {
String prevHashHex = blockEntry.getBlockNumber() <= 0
? ""
: toHex(extractPrevHash32(blockEntry.getBlockBytes()));
String blockBytesB64 = Base64Ws.encode(blockEntry.getBlockBytes());
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
String safePrevHashHex = MAPPER.writeValueAsString(prevHashHex);
String safeBlockBytes = MAPPER.writeValueAsString(blockBytesB64);
String safeRequestId = MAPPER.writeValueAsString(requestId);
return """
{
"op":"AddBlock",
"requestId":%s,
"payload":{
"blockchainName":%s,
"blockNumber":%d,
"prevBlockHash":%s,
"blockBytesB64":%s
}
}
""".formatted(safeRequestId, safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes);
}
private static byte[] extractPrevHash32(byte[] blockBytes) {
if (blockBytes == null || blockBytes.length < 44) {
return new byte[32];
}
byte[] out = new byte[32];
System.arraycopy(blockBytes, 12, out, 0, 32);
return out;
}
private static String textOrEmpty(JsonNode node, String field) {
return node == null ? "" : node.path(field).asText("");
}
private static String normalize(String value) {
if (value == null) return null;
String s = value.trim().toLowerCase(Locale.ROOT);
return s.isEmpty() ? null : s;
}
private static String buildWsUrl(String serverAddressRaw) {
String host = normalizeHostLike(serverAddressRaw);
if (host == null) return null;
return "wss://" + host + "/ws";
}
private static String normalizeHostLike(String value) {
if (value == null) return null;
String raw = value.trim();
if (raw.isEmpty()) return null;
try {
String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw;
URI uri = URI.create(withScheme);
String host = uri.getHost();
if (host == null || host.isBlank()) return null;
return host.trim().toLowerCase(Locale.ROOT);
} catch (Exception e) {
String cleaned = raw
.replaceFirst("^[a-zA-Z]+://", "")
.replaceFirst("/.*$", "")
.trim()
.toLowerCase(Locale.ROOT);
return cleaned.isEmpty() ? null : cleaned;
}
}
private static String toHex(byte[] bytes) {
if (bytes == null) return "";
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(Character.forDigit((b >>> 4) & 0xF, 16));
sb.append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
}
private static void tryAbort(WebSocket webSocket) {
try {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
} catch (Exception ignored) {
}
try {
webSocket.abort();
} catch (Exception ignored) {
}
}
private record AddBlockPushResult(
boolean ok,
int status,
String code,
int serverLastGlobalNumber,
String serverLastGlobalHash
) {
static AddBlockPushResult success() {
return new AddBlockPushResult(true, 200, "", Integer.MIN_VALUE, "");
}
boolean needsBackfill() {
return !ok && ("bad_prev_hash".equalsIgnoreCase(code) || "bad_block_number".equalsIgnoreCase(code));
}
}
private static final class SyncWsListener implements WebSocket.Listener {
private final CompletableFuture<String> responseFuture;
private final CountDownLatch openLatch;
private final StringBuilder textBuffer = new StringBuilder();
private SyncWsListener(CompletableFuture<String> responseFuture, CountDownLatch openLatch) {
this.responseFuture = responseFuture;
this.openLatch = openLatch;
}
@Override
public void onOpen(WebSocket webSocket) {
openLatch.countDown();
webSocket.request(1);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
textBuffer.append(data);
if (last && !responseFuture.isDone()) {
responseFuture.complete(textBuffer.toString());
}
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
}
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(error);
}
openLatch.countDown();
}
}
}

View File

@ -0,0 +1,85 @@
package server.sync;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService;
import shine.db.dao.SyncServersDAO;
import shine.db.entities.SyncServerEntry;
import utils.config.AppConfig;
import java.util.ArrayList;
import java.util.List;
/**
* При старте сервера читает server PDA текущего сервера, затем PDA партнёров из
* sync_servers и сохраняет их логины/адреса в локальную таблицу sync_servers.
*/
public final class SyncServersBootstrapService {
private static final Logger log = LoggerFactory.getLogger(SyncServersBootstrapService.class);
private static final String CONFIG_KEY = "server.SHiNE.login";
private SyncServersBootstrapService() {}
public static void refreshFromSolanaOrLog() {
String serverLogin = normalize(AppConfig.getInstance().getParam(CONFIG_KEY));
if (serverLogin == null) {
log.warn("Sync bootstrap skipped: параметр {} не задан", CONFIG_KEY);
return;
}
try {
SolanaUserPdaImportService.ParsedServerProfile own =
SolanaUserPdaImportService.fetchServerProfileByLogin(serverLogin);
if (own == null) {
log.warn("Sync bootstrap skipped: server PDA не найдена для login={}", serverLogin);
return;
}
if (!own.isServer()) {
log.warn("Sync bootstrap skipped: PDA login={} не помечена как server", serverLogin);
return;
}
List<SyncServerEntry> entries = new ArrayList<>();
long now = System.currentTimeMillis();
for (String partnerLogin : own.syncServers()) {
String normalizedPartnerLogin = normalize(partnerLogin);
if (normalizedPartnerLogin == null) continue;
SolanaUserPdaImportService.ParsedServerProfile partner =
SolanaUserPdaImportService.fetchServerProfileByLogin(normalizedPartnerLogin);
if (partner == null) {
log.warn("Sync bootstrap: partner PDA не найдена для login={}", normalizedPartnerLogin);
continue;
}
if (!partner.isServer()) {
log.warn("Sync bootstrap: partner login={} не является server PDA", normalizedPartnerLogin);
continue;
}
String serverAddress = safe(partner.serverAddress());
if (serverAddress.isBlank()) {
log.warn("Sync bootstrap: у partner login={} пустой server_address", normalizedPartnerLogin);
continue;
}
entries.add(new SyncServerEntry(normalizedPartnerLogin, serverAddress, now));
}
SyncServersDAO.getInstance().replaceAll(entries);
log.info("Sync bootstrap: сохранено {} серверов синхронизации для login={}", entries.size(), serverLogin);
} catch (Exception e) {
log.error("Sync bootstrap failed while loading server PDA and sync_servers from Solana", e);
}
}
private static String normalize(String value) {
if (value == null) return null;
String s = value.trim().toLowerCase();
return s.isEmpty() ? null : s;
}
private static String safe(String value) {
return value == null ? "" : value.trim();
}
}

View File

@ -6,6 +6,7 @@ import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerI
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.debug.DebugApiConfigurator;
import server.sync.SyncServersBootstrapService;
import utils.config.AppConfig;
import java.time.Duration;
@ -50,6 +51,11 @@ public final class WsServer {
log.info("Не удалось прочитать параметр server.port, используем порт по умолчанию {}", port);
}
// ============================================================
// 1.1) Загрузка списка серверов синхронизации из Solana PDA
// ============================================================
SyncServersBootstrapService.refreshFromSolanaOrLog();
// ============================================================
// 2) Запуск Jetty WS
// ============================================================

View File

@ -1,5 +1,6 @@
server.1port=7070
db.path=data/shine.sqlite
server.SHiNE.login=shineupme
# ------------------------------------------------------------
# Server public info

View File

@ -1,2 +1,2 @@
client.version=1.2.262
server.version=1.2.247
client.version=1.2.263
server.version=1.2.248

View File

@ -35,16 +35,20 @@ export function render({ navigate }) {
card.innerHTML = `
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Root Key</span></label>
<p class="meta-muted key-storage-option__description">Главный ключ аккаунта. Нужен для смены пароля, восстановления доступа и важных основных настроек.</p>
<input class="input" type="text" value="${state.keyStorage.rootKey}" />
</div>
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Blockchain Key</span></label>
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне SHiNE.</p>
<input class="input" type="text" value="${state.keyStorage.blockchainKey}" />
</div>
<div class="key-card stack">
<label class="checkbox-row"><span class="field-label">Device Key</span></label>
<p class="meta-muted key-storage-option__description">Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.</p>
<input class="input" type="text" value="${state.keyStorage.clientKey}" />
</div>
<p class="key-storage-note key-storage-note--strong">Если вы не особо понимаете, о чём идёт речь, и не хотите особо заморачиваться с ключами, можете просто сохранить все ключи на телефоне.</p>
`;
card.children[0].querySelector('label').prepend(rootToggle);

View File

@ -42,6 +42,22 @@ export function render({ navigate }) {
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const createKeyInfo = (toggle, titleText, descriptionText) => {
const wrap = document.createElement('div');
wrap.className = 'key-storage-option stack';
const row = document.createElement('label');
row.className = 'checkbox-row';
row.append(toggle, document.createTextNode(titleText));
const description = document.createElement('p');
description.className = 'meta-muted key-storage-option__description';
description.textContent = descriptionText;
wrap.append(row, description);
return wrap;
};
const rootToggle = document.createElement('input');
rootToggle.type = 'checkbox';
rootToggle.checked = state.keyStorage.saveRoot;
@ -55,19 +71,29 @@ export function render({ navigate }) {
deviceToggle.checked = true;
deviceToggle.disabled = true;
const rootRow = document.createElement('label');
rootRow.className = 'checkbox-row';
rootRow.append(rootToggle, document.createTextNode('Ключ root'));
const rootRow = createKeyInfo(
rootToggle,
'Ключ root',
'Главный ключ аккаунта. Нужен для смены пароля, восстановления доступа и важных основных настроек.',
);
const blockchainRow = document.createElement('label');
blockchainRow.className = 'checkbox-row';
blockchainRow.append(blockchainToggle, document.createTextNode('Ключ blockchain'));
const blockchainRow = createKeyInfo(
blockchainToggle,
'Ключ blockchain',
'Используется для подписи ваших действий и записей в блокчейне SHiNE.',
);
const deviceRow = document.createElement('label');
deviceRow.className = 'checkbox-row';
deviceRow.append(deviceToggle, document.createTextNode('Ключ device (всегда)'));
const deviceRow = createKeyInfo(
deviceToggle,
'Ключ device (всегда)',
'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.',
);
card.append(title, question, nextStep, rootRow, blockchainRow, deviceRow, status);
const simpleNote = document.createElement('p');
simpleNote.className = 'key-storage-note key-storage-note--strong';
simpleNote.textContent = 'Если вы не особо понимаете, о чём идёт речь, и не хотите особо заморачиваться с ключами, можете просто сохранить все ключи на телефоне.';
card.append(title, question, nextStep, rootRow, blockchainRow, deviceRow, simpleNote, status);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';

View File

@ -290,7 +290,7 @@ function createInitialState({ withStoredSession = true } = {}) {
rootKey: 'Ключ root хранится в зашифрованном виде',
blockchainKey: 'Ключ blockchain хранится в зашифрованном виде',
clientKey: 'Ключ device хранится в зашифрованном виде',
saveRoot: false,
saveRoot: true,
saveBlockchain: true,
saveDevice: true,
},

View File

@ -628,6 +628,25 @@
gap: 10px;
}
.key-storage-option {
gap: 6px;
}
.key-storage-option__description {
margin: 0;
}
.key-storage-note {
margin: 0;
line-height: 1.45;
}
.key-storage-note--strong {
font-size: 17px;
font-weight: 700;
color: #eef4ff;
}
.key-card {
padding: 12px;
border-radius: var(--radius-md);