diff --git a/AGENTS.md b/AGENTS.md index f67ac8f..65c4660 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Dev_Docs/Blockchain/sync-between-servers.md b/Dev_Docs/Blockchain/sync-between-servers.md index 427f954..dfdabd8 100644 --- a/Dev_Docs/Blockchain/sync-between-servers.md +++ b/Dev_Docs/Blockchain/sync-between-servers.md @@ -89,10 +89,10 @@ | Компонент | Статус | |-----------|--------| | Регистрация серверной PDA в Solana | ✅ Реализовано | -| Чтение `sync_servers` из PDA | Нужна реализация | +| Чтение `sync_servers` из PDA | ✅ Реализовано | | Межсерверный WebSocket-канал | Нужна реализация | | Push новых DM партнёрам | Нужна реализация | -| Push блоков блокчейна партнёрам | Нужна реализация | +| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия | | Backfill при первом подключении | Нужна реализация | | Маршрутизация DM через access_servers | Нужна реализация (заглушка) | diff --git a/Dev_Docs/Pending_Features/2026-06-24_1905_sync_servers_bootstrap_from_server_pda.md b/Dev_Docs/Pending_Features/2026-06-24_1905_sync_servers_bootstrap_from_server_pda.md new file mode 100644 index 0000000..c570d50 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-24_1905_sync_servers_bootstrap_from_server_pda.md @@ -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` diff --git a/Dev_Docs/Pending_Features/2026-06-24_1945_addblock_background_sync_to_sync_servers.md b/Dev_Docs/Pending_Features/2026-06-24_1945_addblock_background_sync_to_sync_servers.md new file mode 100644 index 0000000..71dc748 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-24_1945_addblock_background_sync_to_sync_servers.md @@ -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` diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java index 16c3635..c469c45 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -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(""" diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java index 980ff49..6291e4c 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java @@ -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 listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException { + try (Connection c = db.getConnection()) { + return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive); + } + } + + public List 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 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 { @@ -242,4 +291,4 @@ public final class BlocksDAO { return e; } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java new file mode 100644 index 0000000..edeeb9b --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java @@ -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 listAll() throws SQLException { + try (Connection c = db.getConnection()) { + return listAll(c); + } + } + + public List listAll(Connection c) throws SQLException { + String sql = """ + SELECT login, server_address, updated_at_ms + FROM sync_servers + ORDER BY login + """; + List 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 entries) throws SQLException { + try (Connection c = db.getConnection()) { + replaceAll(c, entries); + } + } + + public void replaceAll(Connection c, List 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; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SyncServerEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SyncServerEntry.java new file mode 100644 index 0000000..c1f1007 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SyncServerEntry.java @@ -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; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java index 53f65a7..142afa9 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java @@ -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 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 syncServers + ) {} } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 5b98a18..bc77b07 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -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); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java new file mode 100644 index 0000000..b2fd337 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java @@ -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 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 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 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 responseFuture; + private final CountDownLatch openLatch; + private final StringBuilder textBuffer = new StringBuilder(); + + private SyncWsListener(CompletableFuture 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(); + } + } +} diff --git a/SHiNE-server/src/main/java/server/sync/SyncServersBootstrapService.java b/SHiNE-server/src/main/java/server/sync/SyncServersBootstrapService.java new file mode 100644 index 0000000..a7fe1e4 --- /dev/null +++ b/SHiNE-server/src/main/java/server/sync/SyncServersBootstrapService.java @@ -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 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(); + } +} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 01f4884..3c5815b 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -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 // ============================================================ diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index c6fde35..592f4b9 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -1,5 +1,6 @@ server.1port=7070 db.path=data/shine.sqlite +server.SHiNE.login=shineupme # ------------------------------------------------------------ # Server public info diff --git a/VERSION.properties b/VERSION.properties index bb0faf4..62408f5 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.262 -server.version=1.2.247 +client.version=1.2.263 +server.version=1.2.248 diff --git a/shine-UI/js/pages/key-storage-view.js b/shine-UI/js/pages/key-storage-view.js index 17b41fa..25e5e38 100644 --- a/shine-UI/js/pages/key-storage-view.js +++ b/shine-UI/js/pages/key-storage-view.js @@ -35,16 +35,20 @@ export function render({ navigate }) { card.innerHTML = `
+

Главный ключ аккаунта. Нужен для смены пароля, восстановления доступа и важных основных настроек.

+

Используется для подписи ваших действий и записей в блокчейне SHiNE.

+

Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.

+

Если вы не особо понимаете, о чём идёт речь, и не хотите особо заморачиваться с ключами, можете просто сохранить все ключи на телефоне.

`; card.children[0].querySelector('label').prepend(rootToggle); diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js index 461f08a..cd19a66 100644 --- a/shine-UI/js/pages/registration-keys-view.js +++ b/shine-UI/js/pages/registration-keys-view.js @@ -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'; diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 102dc7a..5093546 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -290,7 +290,7 @@ function createInitialState({ withStoredSession = true } = {}) { rootKey: 'Ключ root хранится в зашифрованном виде', blockchainKey: 'Ключ blockchain хранится в зашифрованном виде', clientKey: 'Ключ device хранится в зашифрованном виде', - saveRoot: false, + saveRoot: true, saveBlockchain: true, saveDevice: true, }, diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 7b128db..84a702c 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -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);