From 1be1d56599f5d86c7302d8638c37955fec00e484655e47b43a6270147a4df6d5 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 12 Sep 2026 16:59:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20=D0=B0=D1=80?= =?UTF-8?q?=D1=85=D0=B8=D0=B2=D0=BE=D0=B2=20=D1=81=20=D0=B4=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/archive/ArchiveImportConfig.java | 37 + .../archive/ArchiveImportScheduler.java | 54 + .../server/archive/ArchiveImportService.java | 229 ++ .../archive/ArchivePublisherService.java | 39 + .../server/archive/ShineArchiveReader.java | 101 + .../java/shine/db/DatabaseInitializer.java | 6 + .../db/archive/ArchiveBlockchainLocation.java | 14 + .../db/archive/ArchivePublisherHead.java | 14 + .../java/shine/db/dao/ArchiveImportDAO.java | 150 + .../main/resources/postgres/migration_v23.sql | 32 + .../src/main/resources/postgres/schema_v1.sql | 19 +- .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../blockchain/Net_AddBlock_Handler.java | 45 +- ..._GetArchiveBlockchainLocation_Handler.java | 52 + ..._GetArchiveBlockchainLocation_Request.java | 9 + ...GetArchiveBlockchainLocation_Response.java | 16 + .../sync/service/SolanaUsersSyncService.java | 2 +- .../postgres/PostgresStorageRepository.java | 16 +- .../src/main/java/server/ws/WsServer.java | 5 +- .../src/main/resources/application.properties | 6 + docs/Archive/01_PROTOCOL_v1.0.md | 28 + docs/Archive/02_IMPLEMENTATION_MAP.md | 39 + docs/Archive/03_DEPLOY_TEST_SERVER.md | 59 +- docs/Archive/04_TEST_AND_OPERATIONS.md | 27 + .../Archive/05_PATCH_CONTENTS_AND_REMOVALS.md | 68 +- docs/Archive/06_FILE_MANIFEST.md | 79 +- docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md | 303 ++ docs/Archive/README.md | 16 +- .../archive-publisher.example.properties | 10 + shine-UI/Blockchain-Viewer.html | 3416 +++++++++++++++++ shine-UI/js/app.js | 2 + shine-UI/js/pages/blockchain-archive-view.js | 144 + shine-UI/js/pages/settings-view.js | 7 + shine-UI/js/services/auth-service.js | 8 + 34 files changed, 4942 insertions(+), 114 deletions(-) create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java create mode 100644 SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java create mode 100644 docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md create mode 100644 shine-UI/Blockchain-Viewer.html create mode 100644 shine-UI/js/pages/blockchain-archive-view.js diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java new file mode 100644 index 00000000..f4c58918 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java @@ -0,0 +1,37 @@ +package server.archive; + +import utils.config.AppConfig; + +import java.nio.file.Path; +import java.util.*; + +/** Настройки чтения архивов других доверенных SHiNE-серверов. Пустой whitelist = импорт выключен. */ +public record ArchiveImportConfig( + Set allowedPublishers, + int intervalMinutes, + String arweaveGateway, + Path workDir +) { + public static ArchiveImportConfig load() { + AppConfig c = AppConfig.getInstance(); + Set approved = new LinkedHashSet<>(); + String raw = c.getParam("archive.import.allowedPublishers"); + if (raw != null) { + for (String item : raw.split(",")) { + String login = item.trim().toLowerCase(Locale.ROOT); + if (!login.isEmpty()) approved.add(login); + } + } + String gateway = c.getParam("archive.arweave.gateway"); + if (gateway == null || gateway.isBlank()) gateway = "https://arweave.net"; + String dir = c.getParam("archive.import.workDir"); + if (dir == null || dir.isBlank()) dir = "data/archive-import"; + return new ArchiveImportConfig( + Set.copyOf(approved), + Math.max(1, c.getInt("archive.import.intervalMinutes", 60)), + gateway.trim(), + Path.of(dir)); + } + + public boolean enabled() { return !allowedPublishers.isEmpty(); } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java new file mode 100644 index 00000000..1fad1e6c --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java @@ -0,0 +1,54 @@ +package server.archive; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.*; + +/** + * Фоновая проверка уже синхронизированной локальной таблицы User PDA. + * Solana отдельно здесь не опрашивается: первый запуск через 10 секунд после старта, + * затем по умолчанию раз в час. + */ +public final class ArchiveImportScheduler { + private static final Logger log = LoggerFactory.getLogger(ArchiveImportScheduler.class); + private static ScheduledExecutorService executor; + + private ArchiveImportScheduler() {} + + public static synchronized void startOrLog() { + if (executor != null) return; + ArchiveImportConfig cfg = ArchiveImportConfig.load(); + if (!cfg.enabled()) { + log.info("Archive importer выключен: archive.import.allowedPublishers пуст"); + return; + } + try { + ArchiveImportService service = new ArchiveImportService(cfg); + executor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "archive-importer"); + t.setDaemon(true); + return t; + }); + executor.scheduleWithFixedDelay( + () -> { + try { service.pollOnce(); } + catch (Exception e) { log.error("Archive importer cycle failed", e); } + }, + 10, + Math.multiplyExact((long) cfg.intervalMinutes(), 60L), + TimeUnit.SECONDS); + log.info("Archive importer включён. approvedPublishers={} interval={}min", + cfg.allowedPublishers(), cfg.intervalMinutes()); + } catch (Exception e) { + log.error("Archive importer не запущен", e); + } + } + + public static synchronized void close() { + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java new file mode 100644 index 00000000..06b6cf80 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java @@ -0,0 +1,229 @@ +package server.archive; + +import blockchain.BchBlockEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; +import shine.db.archive.*; +import shine.db.dao.*; +import shine.db.entities.BlockchainStateEntry; + +import java.io.IOException; +import java.net.URI; +import java.net.http.*; +import java.nio.file.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.locks.ReentrantLock; + +/** Импорт archive head только от серверов из server-side whitelist. */ +public final class ArchiveImportService { + private static final Logger log = LoggerFactory.getLogger(ArchiveImportService.class); + private final ArchiveImportConfig cfg; + private final ArchiveImportDAO dao = ArchiveImportDAO.getInstance(); + private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance(); + private final BlocksDAO blocksDao = BlocksDAO.getInstance(); + private final ShineArchiveReader reader = new ShineArchiveReader(); + private final Net_AddBlock_Handler addBlockHandler = new Net_AddBlock_Handler(); + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(20)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + public ArchiveImportService(ArchiveImportConfig cfg) throws IOException { + this.cfg = Objects.requireNonNull(cfg); + Files.createDirectories(cfg.workDir()); + } + + /** + * Никаких отдельных Solana RPC запросов здесь нет. + * Читаем только solana_user_pda_current, которую уже обновляет обычный Solana users sync. + */ + public void pollOnce() throws Exception { + if (!cfg.enabled()) return; + for (ArchivePublisherHead head : dao.listPendingPublisherHeads(cfg.allowedPublishers())) { + try { + importHead(head); + } catch (Exception e) { + log.error("Archive import publisher={} tx={} failed", head.login(), head.archiveHeadTxId(), e); + } + } + } + + private void importHead(ArchivePublisherHead publisher) throws Exception { + String headTxId = publisher.archiveHeadTxId(); + byte[] headHash = hex(publisher.archiveHeadHash()); + + // Если процесс успел импортировать head, но упал до установки archive_imported=true, + // следующий цикл просто завершает отметку без повторного скачивания всей истории. + if (headTxId.equals(publisher.lastImportedArchiveTxId())) { + if (!dao.markHeadImported(publisher.login(), headTxId)) { + log.info("Archive head changed while finalizing import marker publisher={} oldHead={}", + publisher.login(), headTxId); + } + return; + } + + Path headFile = download(headTxId); + ShineArchiveReader.Document head; + try { + head = reader.readAndVerify(headFile, publisher.login(), headHash, publisher.rootKey()); + } finally { + Files.deleteIfExists(headFile); + } + + // FULL reference table позволяет догнать все archive blocks, пропущенные пока сервер был выключен. + List previous = new ArrayList<>(head.references()); + previous.sort(Comparator.comparingLong(ShineArchiveReader.Reference::bigBlockNumber)); + + int startIndex = findResumeIndex(previous, publisher.lastImportedArchiveTxId(), headTxId); + for (int i = startIndex; i < previous.size(); i++) { + ShineArchiveReader.Reference ref = previous.get(i); + importOne(publisher, ref.arweaveTxId(), ref.archiveHash()); + if (!dao.advanceImportCursor(publisher.login(), headTxId, ref.arweaveTxId())) { + throw new IllegalStateException("Archive head changed during import for publisher " + publisher.login()); + } + } + + // Head уже скачан и проверен выше — второй раз его из Arweave не качаем. + importDocument(publisher, headTxId, head); + if (!dao.advanceImportCursor(publisher.login(), headTxId, headTxId)) { + throw new IllegalStateException("Archive head changed during final import for publisher " + publisher.login()); + } + if (!dao.markHeadImported(publisher.login(), headTxId)) { + throw new IllegalStateException("Archive head changed before imported marker for publisher " + publisher.login()); + } + + log.info("Archive head fully imported publisher={} bigBlock={} tx={}", + publisher.login(), head.bigBlockNumber(), headTxId); + } + + /** + * Возвращает индекс первого ещё не импортированного previous big block. + * Пустой cursor = новый сервер, импортируем всю FULL историю. + */ + private static int findResumeIndex(List previous, + String lastImportedTxId, + String currentHeadTxId) { + String last = lastImportedTxId == null ? "" : lastImportedTxId.trim(); + if (last.isEmpty()) return 0; + if (last.equals(currentHeadTxId)) return previous.size(); + for (int i = 0; i < previous.size(); i++) { + if (last.equals(previous.get(i).arweaveTxId())) return i + 1; + } + throw new IllegalStateException( + "Последний импортированный archive TX не найден в FULL history текущего head: " + last); + } + + private void importOne(ArchivePublisherHead publisher, String txId, byte[] expectedHash) throws Exception { + Path file = download(txId); + try { + ShineArchiveReader.Document doc = reader.readAndVerify( + file, publisher.login(), expectedHash, publisher.rootKey()); + importDocument(publisher, txId, doc); + } finally { + Files.deleteIfExists(file); + } + } + + private void importDocument(ArchivePublisherHead publisher, String txId, + ShineArchiveReader.Document doc) throws Exception { + for (ShineArchiveReader.Chunk chunk : doc.chunks()) { + processChunk(publisher.login(), txId, doc, chunk); + } + log.info("Archive imported publisher={} bigBlock={} tx={} chunks={}", + publisher.login(), doc.bigBlockNumber(), txId, doc.chunks().size()); + } + + private void processChunk(String publisherLogin, String txId, + ShineArchiveReader.Document doc, + ShineArchiveReader.Chunk chunk) throws Exception { + ReentrantLock lock = BlockchainLocks.lockFor(chunk.blockchainName()); + lock.lock(); + try { + ensureState(chunk.blockchainName()); + BlockchainStateEntry state = stateDao.getByBlockchainName(chunk.blockchainName()); + int localLast = state == null ? -1 : state.getLastBlockNumber(); + long archiveLast = -1; + + for (byte[] raw : chunk.rawRecords()) { + BchBlockEntry block = new BchBlockEntry(raw); + archiveLast = Integer.toUnsignedLong(block.blockNumber); + + if (block.blockNumber <= localLast) { + byte[] localHash = blocksDao.getHashByNumber(chunk.blockchainName(), block.blockNumber); + if (!Arrays.equals(localHash, block.getHash32())) { + throw new IllegalStateException( + "Archive conflict " + chunk.blockchainName() + "#" + block.blockNumber); + } + continue; + } + + if (block.blockNumber != localLast + 1) { + throw new IllegalStateException( + "Archive gap " + chunk.blockchainName() + ": local=" + localLast + + " next=" + block.blockNumber); + } + + var result = addBlockHandler.addBlockFromArchive(chunk.blockchainName(), raw); + if (!result.ok()) { + throw new IllegalStateException( + "Archive AddBlock rejected " + chunk.blockchainName() + "#" + + block.blockNumber + ": " + result.reasonCode()); + } + localLast = block.blockNumber; + } + + if (archiveLast >= 0) { + dao.upsertLocation(new ArchiveBlockchainLocation( + chunk.blockchainName(), publisherLogin, txId, doc.archiveHash(), doc.bigBlockNumber(), + chunk.offset(), chunk.size(), archiveLast, System.currentTimeMillis())); + } + } finally { + lock.unlock(); + } + } + + private void ensureState(String blockchainName) throws Exception { + if (stateDao.getByBlockchainName(blockchainName) != null) return; + ArchiveImportDAO.BlockchainIdentity id = dao.getBlockchainIdentity(blockchainName); + if (id == null) throw new IllegalStateException("Нет User PDA для blockchain " + blockchainName); + + BlockchainStateEntry s = new BlockchainStateEntry(); + s.setBlockchainName(id.blockchainName()); + s.setLogin(id.login()); + s.setBlockchainKey(id.blockchainKey()); + s.setSizeLimit(id.sizeLimit()); + s.setFileSizeBytes(0); + s.setLastBlockNumber(-1); + s.setLastBlockHash(null); + s.setUpdatedAtMs(System.currentTimeMillis()); + stateDao.insertIfMissing(s); + } + + private Path download(String txId) throws Exception { + String gateway = cfg.arweaveGateway().replaceAll("/+$", ""); + Path target = Files.createTempFile(cfg.workDir(), "archive-", ".download"); + HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/" + txId)) + .timeout(Duration.ofMinutes(10)) + .GET() + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofFile(target)); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + Files.deleteIfExists(target); + throw new IOException("Arweave HTTP " + resp.statusCode() + " tx=" + txId); + } + return target; + } + + private static byte[] hex(String value) { + String s = String.valueOf(value == null ? "" : value).trim(); + if (s.length() != 64) throw new IllegalArgumentException("archive_head_hash должен быть HEX64"); + byte[] out = new byte[32]; + for (int i = 0; i < 32; i++) { + out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16); + } + return out; + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java index a523f849..0826fc8d 100644 --- a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java @@ -8,6 +8,7 @@ import shine.db.archive.ArchiveChainCursor; import shine.db.archive.ArchivePublishJob; import shine.db.archive.ArchivePublishJobChain; import shine.db.dao.ArchivePublicationDAO; +import shine.db.dao.ArchiveImportDAO; import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlocksDAO; import shine.db.entities.BlockEntry; @@ -24,6 +25,7 @@ public final class ArchivePublisherService { private final ArchivePublisherConfig cfg; private final ArchivePublicationDAO archiveDao = ArchivePublicationDAO.getInstance(); + private final ArchiveImportDAO archiveImportDao = ArchiveImportDAO.getInstance(); private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance(); private final BlocksDAO blocksDao = BlocksDAO.getInstance(); private final ShineArchiveWriter writer = new ShineArchiveWriter(); @@ -40,6 +42,32 @@ public final class ArchivePublisherService { this.arweave = new ArweaveArchiveService(cfg); this.solana = new SolanaArchiveHeadWriter(cfg); this.solana.validateKeysAgainstPda(); + backfillOwnLocationIndex(); + } + + /** + * После обновления старого publisher-сервера заполняет универсальный location index + * из уже существующих finalized cursors, не дожидаясь следующего суточного archive block. + */ + private void backfillOwnLocationIndex() throws Exception { + Map finalized = new HashMap<>(); + for (ArchiveBigBlockRef ref : archiveDao.listFinalizedBigBlocks()) finalized.put(ref.bigBlockNumber(), ref); + if (finalized.isEmpty()) return; + long now = System.currentTimeMillis(); + int updated = 0; + for (BlockchainStateEntry state : stateDao.listAll()) { + if (state == null || state.getBlockchainName() == null || state.getBlockchainName().isBlank()) continue; + ArchiveChainCursor cursor = archiveDao.getCursor(state.getBlockchainName()); + if (cursor == null) continue; + ArchiveBigBlockRef ref = finalized.get(cursor.lastArchiveBigBlockNumber()); + if (ref == null || ref.arweaveTxId() == null || ref.arweaveTxId().length != 32) continue; + archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation( + cursor.blockchainName(), cfg.serverLogin(), b64url(ref.arweaveTxId()), + cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(), + cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), now)); + updated++; + } + if (updated > 0) log.info("Archive publisher: восстановлен location index для {} blockchain", updated); } /** После рестарта продолжает только уже существующий незавершённый job; новый snapshot не создаёт. */ @@ -285,6 +313,17 @@ public final class ArchivePublisherService { private void commitCursors(ArchivePublishJob job) throws Exception { archiveDao.commitCursors(job.id(), job.archiveHash()); + + // Тот же универсальный индекс используется и для собственных опубликованных архивов. + // Поэтому UI publisher-сервера получает ссылку даже если он не импортирует самого себя через whitelist. + String txId = b64url(job.arweaveTxId()); + long now = System.currentTimeMillis(); + for (ArchivePublishJobChain ch : archiveDao.listJobChains(job.id())) { + if (ch.newChunkOffset() == null || ch.newChunkSize() == null) continue; + archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation( + ch.blockchainName(), cfg.serverLogin(), txId, job.archiveHash(), job.bigBlockNumber(), + ch.newChunkOffset(), ch.newChunkSize(), ch.toSourceBlockNumber(), now)); + } } private static Path requireLocalFile(ArchivePublishJob job) { diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java new file mode 100644 index 00000000..9d705429 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java @@ -0,0 +1,101 @@ +package server.archive; + +import sync.util.Base58Util; +import utils.crypto.Ed25519Util; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.*; + +/** Строгий reader/verifier формата SHINE-ARCHIVE v1.0. */ +public final class ShineArchiveReader { + public record Reference(long bigBlockNumber, byte[] archiveHash, String arweaveTxId) {} + public record Chunk(long offset, long size, String blockchainName, List rawRecords, + long previousBigBlockRef, long previousChunkOffset, long previousChunkSize) {} + public record Document(long bigBlockNumber, long createdAtMs, String creatorLogin, + List references, List chunks, byte[] archiveHash) {} + + public Document readAndVerify(Path file, String expectedPublisherLogin, byte[] expectedHash, String rootPublicKeyBase58) throws Exception { + long fileSize = Files.size(file); + if (fileSize < 13 + 38 + 1 + 32 + 64) throw new IOException("SHINE-ARCHIVE слишком короткий"); + if (fileSize >= 0x1_0000_0000L) throw new IOException("SHINE-ARCHIVE v1 >= 4 GiB не поддерживается"); + + byte[] computedHash = digestPrefix(file, fileSize - 96); + try (RandomAccessFile in = new RandomAccessFile(file.toFile(), "r")) { + byte[] magic = new byte[ShineArchiveWriter.MAGIC.length]; + in.readFully(magic); + if (!Arrays.equals(magic, ShineArchiveWriter.MAGIC)) throw new IOException("Bad SHINE-ARCHIVE magic"); + int major=in.readUnsignedByte(), minor=in.readUnsignedByte(); + if (major != 1 || minor != 0) throw new IOException("Unsupported SHINE-ARCHIVE version " + major + "." + minor); + long headerSize=u32(in); long bigBlock=u32(in); long createdAt=in.readLong(); + int creatorLen=in.readUnsignedByte(); int referencesMode=in.readUnsignedByte(); + long refsCount=u32(in); int refSize=in.readUnsignedShort(); long parentRef=u32(in); + long chunksCount=u32(in); long recordsCount=u32(in); + if (referencesMode != ShineArchiveWriter.REFERENCES_FULL || refSize != ShineArchiveWriter.REFERENCE_ENTRY_SIZE) { + throw new IOException("Unsupported references table"); + } + String creator=readUtf8(in,creatorLen); + if (!creator.equalsIgnoreCase(String.valueOf(expectedPublisherLogin))) { + throw new SecurityException("Archive creator " + creator + " != trusted publisher " + expectedPublisherLogin); + } + if (headerSize != in.getFilePointer()) throw new IOException("Некорректный header_size"); + if (refsCount > 10_000_000L || chunksCount > 10_000_000L) throw new IOException("Слишком много refs/chunks"); + + List refs=new ArrayList<>((int)Math.min(refsCount, Integer.MAX_VALUE)); + for(long i=0;i= refs.size()) throw new IOException("parent_reference_index вне таблицы"); + + List chunks=new ArrayList<>((int)Math.min(chunksCount, Integer.MAX_VALUE)); + long parsedRecords=0; + for(long ci=0;ci fileSize-96) throw new IOException("Некорректный chunk_size"); + int nameLen=in.readUnsignedByte(); String bch=readUtf8(in,nameLen); long count=u32(in); + if (count > 10_000_000L) throw new IOException("Слишком много records в chunk " + bch); + List records=new ArrayList<>((int)Math.min(count,Integer.MAX_VALUE)); + for(long ri=0;riInteger.MAX_VALUE || in.getFilePointer()+n>chunkEnd-12) throw new IOException("Некорректный record_size"); + records.add(readFixed(in,(int)n)); parsedRecords++; + } + long prevRef=u32(in), prevOffset=u32(in), prevSize=u32(in); + if (prevRef != ShineArchiveWriter.NO_REFERENCE_U32 && prevRef >= refs.size()) throw new IOException("previous_big_block_ref вне таблицы"); + if (in.getFilePointer()!=chunkEnd) throw new IOException("Chunk framing mismatch: " + bch); + chunks.add(new Chunk(chunkOffset,chunkSize,bch,List.copyOf(records),prevRef,prevOffset,prevSize)); + } + if (parsedRecords != recordsCount) throw new IOException("total_user_records_count mismatch"); + + int closerLen=in.readUnsignedByte(); String closer=readUtf8(in,closerLen); + if(!closer.equals(creator)) throw new SecurityException("creator_login != closer_login"); + if(in.getFilePointer()!=fileSize-96) throw new IOException("Некорректная граница footer/hash"); + byte[] storedHash=readFixed(in,32); byte[] signature=readFixed(in,64); + if(in.getFilePointer()!=fileSize) throw new IOException("Лишние данные после signature"); + if(!Arrays.equals(storedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с footer"); + if(expectedHash!=null && !Arrays.equals(expectedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с ожидаемым hash"); + byte[] root=Base58Util.decode(rootPublicKeyBase58); + if(root.length!=32) throw new SecurityException("Некорректный root public key publisher-а"); + byte[] signed=new byte[ShineArchiveWriter.SIGNATURE_DOMAIN.length+32]; + System.arraycopy(ShineArchiveWriter.SIGNATURE_DOMAIN,0,signed,0,ShineArchiveWriter.SIGNATURE_DOMAIN.length); + System.arraycopy(computedHash,0,signed,ShineArchiveWriter.SIGNATURE_DOMAIN.length,32); + if(!Ed25519Util.verify(signed,signature,root)) throw new SecurityException("Не прошла подпись SHINE-ARCHIVE publisher-а"); + return new Document(bigBlock,createdAt,creator,List.copyOf(refs),List.copyOf(chunks),computedHash); + } + } + + private static byte[] digestPrefix(Path file,long bytes) throws Exception { + MessageDigest md=MessageDigest.getInstance("SHA-256"); + try(InputStream in=new BufferedInputStream(Files.newInputStream(file))) { + byte[] buf=new byte[1024*1024]; long left=bytes; + while(left>0){int n=in.read(buf,0,(int)Math.min(buf.length,left)); if(n<0) throw new EOFException(); md.update(buf,0,n); left-=n;} + } + return md.digest(); + } + private static long u32(RandomAccessFile in) throws IOException { return Integer.toUnsignedLong(in.readInt()); } + private static byte[] readFixed(RandomAccessFile in,int n) throws IOException { byte[] b=new byte[n]; in.readFully(b); return b; } + private static String readUtf8(RandomAccessFile in,int n) throws IOException { return new String(readFixed(in,n), StandardCharsets.UTF_8); } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 52a19ca8..fa153599 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -39,6 +39,7 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_20 = 20; public static final int SCHEMA_VERSION_21 = 21; public static final int SCHEMA_VERSION_22 = 22; + public static final int SCHEMA_VERSION_23 = 23; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; @@ -61,6 +62,7 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql"; public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql"; public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql"; + public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql"; private DatabaseInitializer() {} @@ -218,6 +220,10 @@ public final class DatabaseInitializer { runSqlScript(conn, POSTGRES_MIGRATION_V22_RESOURCE); currentVersion = SCHEMA_VERSION_22; } + if (currentVersion < SCHEMA_VERSION_23) { + runSqlScript(conn, POSTGRES_MIGRATION_V23_RESOURCE); + currentVersion = SCHEMA_VERSION_23; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java new file mode 100644 index 00000000..111afa2e --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java @@ -0,0 +1,14 @@ +package shine.db.archive; + +/** Последний известный архивный chunk конкретной blockchain на этом сервере. */ +public record ArchiveBlockchainLocation( + String blockchainName, + String publisherLogin, + String arweaveTxId, + byte[] archiveHash, + long bigBlockNumber, + long chunkOffset, + long chunkSize, + long sourceLastBlockNumber, + long updatedAtMs +) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java new file mode 100644 index 00000000..a3ddeb6a --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java @@ -0,0 +1,14 @@ +package shine.db.archive; + +/** + * Текущая archive head trusted publisher-а из локальной таблицы, + * которую уже заполнил обычный Solana users sync. + */ +public record ArchivePublisherHead( + String login, + String rootKey, + String archiveHeadTxId, + String archiveHeadHash, + boolean archiveImported, + String lastImportedArchiveTxId +) {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java new file mode 100644 index 00000000..6fd3def3 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java @@ -0,0 +1,150 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.archive.ArchiveBlockchainLocation; +import shine.db.archive.ArchivePublisherHead; + +import java.sql.*; +import java.util.*; + +/** БД индекс последних archive chunks и локальное состояние импорта archive head. */ +public final class ArchiveImportDAO { + private static volatile ArchiveImportDAO instance; + private final DbController db = DbController.getInstance(); + + private ArchiveImportDAO() {} + + public static ArchiveImportDAO getInstance() { + if (instance == null) synchronized (ArchiveImportDAO.class) { + if (instance == null) instance = new ArchiveImportDAO(); + } + return instance; + } + + /** + * Возвращает только доверенных publisher-ов, для которых обычный Solana users sync + * уже увидел archive head, но этот head ещё не был полностью импортирован локально. + */ + public List listPendingPublisherHeads(Set approvedLogins) throws SQLException { + if (approvedLogins == null || approvedLogins.isEmpty()) return List.of(); + List out = new ArrayList<>(); + String sql = """ + SELECT login, root_key, archive_head_tx_id, archive_head_hash, + archive_imported, archive_last_imported_tx_id + FROM solana_user_pda_current + WHERE LOWER(login)=LOWER(?) + AND is_server=TRUE + AND archive_head_tx_id<>'' + AND archive_head_hash<>'' + AND archive_imported=FALSE + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + for (String login : approvedLogins) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + out.add(new ArchivePublisherHead( + rs.getString("login"), + rs.getString("root_key"), + rs.getString("archive_head_tx_id"), + rs.getString("archive_head_hash"), + rs.getBoolean("archive_imported"), + rs.getString("archive_last_imported_tx_id"))); + } + } + } + } + return out; + } + + /** + * Сдвигает локальный курсор импорта внутри FULL archive chain. + * Update выполняется только если текущий head всё ещё тот же, с которым работает importer. + */ + public boolean advanceImportCursor(String publisherLogin, String expectedHeadTxId, String importedTxId) throws SQLException { + String sql = """ + UPDATE solana_user_pda_current + SET archive_last_imported_tx_id=? + WHERE LOWER(login)=LOWER(?) + AND archive_head_tx_id=? + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, importedTxId == null ? "" : importedTxId); + ps.setString(2, publisherLogin); + ps.setString(3, expectedHeadTxId); + return ps.executeUpdate() == 1; + } + } + + /** + * Помечает именно текущий head как полностью импортированный. + * Если Solana users sync успел заменить head на более новый, update не произойдёт. + */ + public boolean markHeadImported(String publisherLogin, String expectedHeadTxId) throws SQLException { + String sql = """ + UPDATE solana_user_pda_current + SET archive_imported=TRUE, + archive_last_imported_tx_id=archive_head_tx_id + WHERE LOWER(login)=LOWER(?) + AND archive_head_tx_id=? + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, publisherLogin); + ps.setString(2, expectedHeadTxId); + return ps.executeUpdate() == 1; + } + } + + public void upsertLocation(ArchiveBlockchainLocation e) throws SQLException { + String sql=""" + INSERT INTO archive_blockchain_location( + blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number, + chunk_offset,chunk_size,source_last_block_number,updated_at_ms) + VALUES(?,?,?,?,?,?,?,?,?) + ON CONFLICT(blockchain_name) DO UPDATE SET + publisher_login=EXCLUDED.publisher_login,arweave_tx_id=EXCLUDED.arweave_tx_id, + archive_hash=EXCLUDED.archive_hash,big_block_number=EXCLUDED.big_block_number, + chunk_offset=EXCLUDED.chunk_offset,chunk_size=EXCLUDED.chunk_size, + source_last_block_number=EXCLUDED.source_last_block_number,updated_at_ms=EXCLUDED.updated_at_ms + WHERE EXCLUDED.source_last_block_number >= archive_blockchain_location.source_last_block_number + """; + try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) { + ps.setString(1,e.blockchainName()); ps.setString(2,e.publisherLogin()); ps.setString(3,e.arweaveTxId()); + ps.setBytes(4,e.archiveHash()); ps.setLong(5,e.bigBlockNumber()); ps.setLong(6,e.chunkOffset()); + ps.setLong(7,e.chunkSize()); ps.setLong(8,e.sourceLastBlockNumber()); ps.setLong(9,e.updatedAtMs()); ps.executeUpdate(); + } + } + + public ArchiveBlockchainLocation getLocation(String blockchainName) throws SQLException { + String sql=""" + SELECT blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number, + chunk_offset,chunk_size,source_last_block_number,updated_at_ms + FROM archive_blockchain_location WHERE LOWER(blockchain_name)=LOWER(?) + """; + try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) { + ps.setString(1,blockchainName); + try(ResultSet rs=ps.executeQuery()) { + if(!rs.next()) return null; + return new ArchiveBlockchainLocation(rs.getString(1),rs.getString(2),rs.getString(3),rs.getBytes(4), + rs.getLong(5),rs.getLong(6),rs.getLong(7),rs.getLong(8),rs.getLong(9)); + } + } + } + + /** Данные PDA, нужные для создания пустого локального blockchain_state перед первым архивным импортом. */ + public BlockchainIdentity getBlockchainIdentity(String blockchainName) throws SQLException { + String sql=""" + SELECT login, blockchain_name, blockchain_key, paid_limit_bytes + FROM solana_user_pda_current WHERE LOWER(blockchain_name)=LOWER(?) LIMIT 1 + """; + try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) { + ps.setString(1,blockchainName); + try(ResultSet rs=ps.executeQuery()) { + if(!rs.next()) return null; + return new BlockchainIdentity(rs.getString(1),rs.getString(2),rs.getString(3),rs.getLong(4)); + } + } + } + + public record BlockchainIdentity(String login,String blockchainName,String blockchainKey,long sizeLimit) {} +} diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql new file mode 100644 index 00000000..664fcffe --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql @@ -0,0 +1,32 @@ +-- v23: trusted SHINE-ARCHIVE import state + last chunk location index. +-- Solana archive head itself is already copied by the normal Solana users sync (v22). + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_imported BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_last_imported_tx_id TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending + ON solana_user_pda_current(is_server, archive_imported) + WHERE archive_head_tx_id <> ''; + +CREATE TABLE IF NOT EXISTS archive_blockchain_location ( + blockchain_name TEXT PRIMARY KEY, + publisher_login TEXT NOT NULL, + arweave_tx_id TEXT NOT NULL, + archive_hash BYTEA NOT NULL, + big_block_number BIGINT NOT NULL, + chunk_offset BIGINT NOT NULL, + chunk_size BIGINT NOT NULL, + source_last_block_number BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher + ON archive_blockchain_location(publisher_login); + +UPDATE db_schema_version +SET schema_version = 23, + updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) +WHERE id = 1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 337a4d71..d9c1365b 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -2083,8 +2083,25 @@ ALTER TABLE solana_user_pda_history ALTER TABLE solana_user_pda_history ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_imported BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_last_imported_tx_id TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending + ON solana_user_pda_current(is_server, archive_imported) + WHERE archive_head_tx_id <> ''; + +CREATE TABLE IF NOT EXISTS archive_blockchain_location ( + blockchain_name TEXT PRIMARY KEY, publisher_login TEXT NOT NULL, arweave_tx_id TEXT NOT NULL, + archive_hash BYTEA NOT NULL, big_block_number BIGINT NOT NULL, chunk_offset BIGINT NOT NULL, + chunk_size BIGINT NOT NULL, source_last_block_number BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher ON archive_blockchain_location(publisher_login); + INSERT INTO db_schema_version(id,schema_version,updated_at_ms) -VALUES(1,22,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) +VALUES(1,23,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 10406a25..a7a9b21a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -42,8 +42,10 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetArchiveBlockchainLocation_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request; +import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetArchiveBlockchainLocation_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; @@ -191,6 +193,7 @@ public final class JsonHandlerRegistry { // --- blockchain --- Map.entry("AddBlock", new Net_AddBlock_Handler()), Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()), + Map.entry("GetArchiveBlockchainLocation", new Net_GetArchiveBlockchainLocation_Handler()), // --- userParams --- Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()), @@ -283,6 +286,7 @@ public final class JsonHandlerRegistry { // --- blockchain --- Map.entry("AddBlock", Net_AddBlock_Request.class), Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class), + Map.entry("GetArchiveBlockchainLocation", Net_GetArchiveBlockchainLocation_Request.class), // --- userParams --- Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class), 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 0c10212e..8ccf7b43 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 @@ -102,7 +102,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { blockchainName, req.getBlockNumber(), // старое поле, пока оставляем req.getPrevBlockHash(), // старое поле, пока оставляем - req.getBlockBytesB64() + req.getBlockBytesB64(), + true ); // ✅ УСПЕХ: как раньше @@ -188,7 +189,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { String blockchainName, int globalNumberFromReq, String prevGlobalHashHexFromReq, - String blockBytesB64 + String blockBytesB64, + boolean replicateAfterWrite ) { if (blockchainName == null || blockchainName.isBlank()) { log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq); @@ -613,11 +615,48 @@ 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); + if (replicateAfterWrite) { + addBlockSyncService.replicateAsync(blockchainName, block.blockNumber); + } return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex); } + /** + * Архивный импорт использует ту же проверку и запись, что обычный AddBlock, + * но не запускает повторную межсерверную репликацию уже импортированного блока. + */ + public ArchiveImportResult addBlockFromArchive(String blockchainName, byte[] rawBlockBytes) { + if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) { + return new ArchiveImportResult(false, "chain_resync_in_progress", -1, ""); + } + if (rawBlockBytes == null || rawBlockBytes.length == 0) { + return new ArchiveImportResult(false, "empty_block", -1, ""); + } + final BchBlockEntry parsed; + try { + parsed = new BchBlockEntry(rawBlockBytes); + } catch (Exception e) { + return new ArchiveImportResult(false, "bad_block_format", -1, ""); + } + ReentrantLock lock = BlockchainLocks.lockFor(blockchainName); + lock.lock(); + try { + AddBlockResult r = addBlock( + blockchainName, + parsed.blockNumber, + toHex(parsed.prevHash32), + Base64Ws.encode(rawBlockBytes), + false + ); + return new ArchiveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex); + } finally { + lock.unlock(); + } + } + + public record ArchiveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {} + /* ===================================================================== */ /* ====================== Helpers ====================================== */ /* ===================================================================== */ diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java new file mode 100644 index 00000000..210b2a6f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java @@ -0,0 +1,52 @@ +package server.logic.ws_protocol.JSON.handlers.blockchain; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.*; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.*; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.archive.ArchiveBlockchainLocation; +import shine.db.archive.ArchiveBigBlockRef; +import shine.db.archive.ArchiveChainCursor; +import shine.db.dao.ArchiveImportDAO; +import shine.db.dao.ArchivePublicationDAO; +import utils.config.AppConfig; + +import java.util.Base64; + +public final class Net_GetArchiveBlockchainLocation_Handler implements JsonMessageHandler { + @Override public Net_Response handle(Net_Request base, ConnectionContext ctx) { + Net_GetArchiveBlockchainLocation_Request req=(Net_GetArchiveBlockchainLocation_Request)base; + String bch=String.valueOf(req.getBlockchainName()==null?"":req.getBlockchainName()).trim(); + if(bch.isEmpty()) return NetExceptionResponseFactory.error(req,WireCodes.Status.BAD_REQUEST,"BAD_FIELDS","blockchainName обязателен"); + try { + ArchiveImportDAO importDao = ArchiveImportDAO.getInstance(); + ArchiveBlockchainLocation e=importDao.getLocation(bch); + if (e == null) { + // Backward-compatible fallback для publisher-а, который уже имел v22 cursors до появления v23 location index. + ArchivePublicationDAO publicationDao = ArchivePublicationDAO.getInstance(); + ArchiveChainCursor cursor = publicationDao.getCursor(bch); + if (cursor != null) { + ArchiveBigBlockRef matching = publicationDao.listFinalizedBigBlocks().stream() + .filter(ref -> ref.bigBlockNumber() == cursor.lastArchiveBigBlockNumber()) + .findFirst().orElse(null); + if (matching != null && matching.arweaveTxId() != null && matching.arweaveTxId().length == 32) { + String publisher = java.util.Objects.toString(AppConfig.getInstance().getParam("server.SHiNE.login"), "").trim(); + String txId = Base64.getUrlEncoder().withoutPadding().encodeToString(matching.arweaveTxId()); + e = new ArchiveBlockchainLocation( + bch, publisher, txId, cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(), + cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), System.currentTimeMillis()); + importDao.upsertLocation(e); + } + } + } + if(e==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.NOT_FOUND,"ARCHIVE_LOCATION_NOT_FOUND","Архивная ссылка пока не известна этому серверу"); + Net_GetArchiveBlockchainLocation_Response r=new Net_GetArchiveBlockchainLocation_Response(); + r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); + r.setBlockchainName(e.blockchainName()); r.setPublisherLogin(e.publisherLogin()); r.setArweaveTxId(e.arweaveTxId()); r.setArchiveHash(hex(e.archiveHash())); + r.setBigBlockNumber(e.bigBlockNumber()); r.setChunkOffset(e.chunkOffset()); r.setChunkSize(e.chunkSize()); r.setSourceLastBlockNumber(e.sourceLastBlockNumber()); return r; + } catch(Exception e) { return NetExceptionResponseFactory.error(req,WireCodes.Status.INTERNAL_ERROR,"INTERNAL_ERROR",NetExceptionResponseFactory.detailedMessage("Не удалось получить архивную ссылку",e)); } + } + private static String hex(byte[] b){if(b==null)return"";StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java new file mode 100644 index 00000000..a8189cb4 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java @@ -0,0 +1,9 @@ +package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public final class Net_GetArchiveBlockchainLocation_Request extends Net_Request { + private String blockchainName; + public String getBlockchainName(){return blockchainName;} + public void setBlockchainName(String value){this.blockchainName=value;} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java new file mode 100644 index 00000000..cbe64495 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java @@ -0,0 +1,16 @@ +package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public final class Net_GetArchiveBlockchainLocation_Response extends Net_Response { + private String blockchainName,publisherLogin,arweaveTxId,archiveHash; + private long bigBlockNumber,chunkOffset,chunkSize,sourceLastBlockNumber; + public String getBlockchainName(){return blockchainName;} public void setBlockchainName(String v){blockchainName=v;} + public String getPublisherLogin(){return publisherLogin;} public void setPublisherLogin(String v){publisherLogin=v;} + public String getArweaveTxId(){return arweaveTxId;} public void setArweaveTxId(String v){arweaveTxId=v;} + public String getArchiveHash(){return archiveHash;} public void setArchiveHash(String v){archiveHash=v;} + public long getBigBlockNumber(){return bigBlockNumber;} public void setBigBlockNumber(long v){bigBlockNumber=v;} + public long getChunkOffset(){return chunkOffset;} public void setChunkOffset(long v){chunkOffset=v;} + public long getChunkSize(){return chunkSize;} public void setChunkSize(long v){chunkSize=v;} + public long getSourceLastBlockNumber(){return sourceLastBlockNumber;} public void setSourceLastBlockNumber(long v){sourceLastBlockNumber=v;} +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java index 5ef0b1c0..43325749 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java @@ -629,7 +629,7 @@ public final class SolanaUsersSyncService account.address(), account.slot(), account.dataBase64(), - state.lastSeenSignature() + "" ) ); } catch (Exception ignored) { diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java index 9f56f004..c8cbf1aa 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java @@ -299,13 +299,7 @@ public final class PostgresStorageRepository connection.setAutoCommit(false); - try (Statement statement = - connection.createStatement()) { - - statement.executeUpdate( - "TRUNCATE TABLE solana_user_pda_current" - ); - + try { upsertCurrentSnapshots( connection, snapshots @@ -494,6 +488,14 @@ public final class PostgresStorageRepository "last_block_hash = EXCLUDED.last_block_hash, " + "last_block_signature = EXCLUDED.last_block_signature, " + "arweave_tx_id = EXCLUDED.arweave_tx_id, " + + "archive_imported = CASE WHEN " + + "EXCLUDED.archive_head_tx_id IS DISTINCT FROM solana_user_pda_current.archive_head_tx_id " + + "OR EXCLUDED.archive_head_hash IS DISTINCT FROM solana_user_pda_current.archive_head_hash " + + "THEN FALSE ELSE solana_user_pda_current.archive_imported END, " + + "archive_last_imported_tx_id = CASE WHEN " + + "EXCLUDED.archive_head_tx_id = solana_user_pda_current.archive_head_tx_id " + + "AND EXCLUDED.archive_head_hash IS DISTINCT FROM solana_user_pda_current.archive_head_hash " + + "THEN '' ELSE solana_user_pda_current.archive_last_imported_tx_id END, " + "archive_head_tx_id = EXCLUDED.archive_head_tx_id, " + "archive_head_hash = EXCLUDED.archive_head_hash, " + "is_server = EXCLUDED.is_server, " + diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 7e76d99d..76ba4871 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.archive.ArchivePublisherScheduler; +import server.archive.ArchiveImportScheduler; import server.debug.DebugApiConfigurator; import server.files.DmFileApiConfigurator; import server.sync.BlockchainResyncRecoveryOnStartup; @@ -85,6 +86,8 @@ public final class WsServer { // Опциональная серверная публикация больших SHINE-ARCHIVE блоков. ArchivePublisherScheduler.startOrLog(); + // Импорт больших архивов только от publisher-ов из archive.import.allowedPublishers. + ArchiveImportScheduler.startOrLog(); // ============================================================ // 2) Запуск Jetty WS @@ -114,7 +117,7 @@ public final class WsServer { log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); ServerConnectionPool.getInstance().startOrLog(); Runtime.getRuntime().addShutdownHook(new Thread( - () -> { ServerConnectionPool.getInstance().close(); ArchivePublisherScheduler.close(); }, + () -> { ServerConnectionPool.getInstance().close(); ArchivePublisherScheduler.close(); ArchiveImportScheduler.close(); }, "server-connection-pool-shutdown")); PeriodicDmDeliveryService.startOrLog(); server.join(); diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 6ce8cf97..577684e5 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -155,3 +155,9 @@ test.freeAvatar.limitPerUser=3 test.freeAvatar.maxBytes=131072 test.freeAvatar.walletAddress= test.freeAvatar.walletJwkPath= + +# Trusted SHINE-ARCHIVE import. Empty list = do not trust/import archives from anyone. +# Comma-separated SHiNE server logins, case-insensitive. +archive.import.allowedPublishers= +archive.import.intervalMinutes=60 +archive.import.workDir=data/archive-import diff --git a/docs/Archive/01_PROTOCOL_v1.0.md b/docs/Archive/01_PROTOCOL_v1.0.md index c4a2db63..8bef6d09 100644 --- a/docs/Archive/01_PROTOCOL_v1.0.md +++ b/docs/Archive/01_PROTOCOL_v1.0.md @@ -1617,3 +1617,31 @@ BigBlock #100 - PDA обновляется обычным `update_user_pda`; - Arweave используется как долговременное хранилище; - публикация считается завершённой только после Arweave confirmation + Solana finalized + cursor commit. + + +--- + +## Trusted archive reader / importer + +Бинарный формат v1.0 не меняется. Обычный сервер MAY использовать archive head из User PDA для восстановления недостающих SHiNE-блоков, но SHOULD принимать архивы только от явно разрешённых server logins. + +Рекомендуемая политика v1: + +```text +publisher login ∈ local whitelist +AND UserPDA.is_server = true +AND SHA256(file) == UserPDA.archive_head_hash / reference hash +AND creator_login == closer_login == publisher login +AND Ed25519 archive signature valid for publisher root key +AND every raw SHiNE block passes normal AddBlock validation +``` + +FULL reference table head-блока позволяет поздно подключившемуся серверу импортировать всю неизвестную историю publisher-а от старых big blocks к новым. + +Для быстрого пользовательского доступа сервер SHOULD поддерживать локальный индекс: + +```text +blockchain_name -> arweave_tx_id + chunk_offset + chunk_size +``` + +Он указывает на последний известный `UserBlockchainChunk`. Backlink внутри chunk делает эту одну ссылку достаточной для обхода всей архивной истории конкретной blockchain. diff --git a/docs/Archive/02_IMPLEMENTATION_MAP.md b/docs/Archive/02_IMPLEMENTATION_MAP.md index f0654733..e671af29 100644 --- a/docs/Archive/02_IMPLEMENTATION_MAP.md +++ b/docs/Archive/02_IMPLEMENTATION_MAP.md @@ -96,3 +96,42 @@ bytes[32] archive_hash ## 7. Legacy TestFreeAvatar Старый временный `TestFreeAvatarArweaveService` больше не является частью активного WS-протокола. Registry/API документация убраны. При наложении changed-files ZIP поверх старого дерева старые исходники физически останутся, поэтому их список для удаления находится в `05_PATCH_CONTENTS_AND_REMOVALS.md`. + + +## Trusted importer / location index / Viewer + +Server importer: + +```text +shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java +shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java +shine-server-archive/src/main/java/server/archive/ArchiveImportService.java +shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java +``` + +Database: + +```text +shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java +shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java +shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java +shine-server-db/src/main/resources/postgres/migration_v23.sql +shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java +``` + +`PostgresStorageRepository` сохраняет `archive_imported=true` при повторном sync того же head и автоматически сбрасывает флаг в `false`, если `archive_head_tx_id` или `archive_head_hash` изменились. + +WS API: + +```text +GetArchiveBlockchainLocation +``` + +UI: + +```text +shine-UI/js/pages/blockchain-archive-view.js +shine-UI/Blockchain-Viewer.html +``` + +`Blockchain-Viewer.html` получает `tx + offset + size + blockchain`, идёт назад по `PreviousBlockchainChunkRef` и использует существующий parser каналов старого Viewer-а. diff --git a/docs/Archive/03_DEPLOY_TEST_SERVER.md b/docs/Archive/03_DEPLOY_TEST_SERVER.md index 5b738ad7..6b6248f8 100644 --- a/docs/Archive/03_DEPLOY_TEST_SERVER.md +++ b/docs/Archive/03_DEPLOY_TEST_SERVER.md @@ -7,7 +7,7 @@ Нужны изменения одновременно в: 1. серверном Java-коде; -2. PostgreSQL schema v22; +2. PostgreSQL schema v23; 3. Solana-программе `shine_users` (PDA block type `100` + backward-compatible update parser); 4. Java/JS User PDA codecs. @@ -107,7 +107,7 @@ SHiNE-server/build/libs/shine-server.jar # 4. Подготовить PostgreSQL backup -Перед первым стартом версии со schema v22 сделать backup тестовой БД. Например: +Перед первым стартом версии со schema v23 сделать backup тестовой БД. Например: ```bash pg_dump -Fc -d '' -f shine-before-archive-v22.dump @@ -115,7 +115,7 @@ pg_dump -Fc -d '' -f shine-before-archive-v22.dump Точная команда зависит от текущей схемы доступа PostgreSQL. -При старте сервер сам применит `migration_v22.sql`, если `db_schema_version < 22`. Вручную migration выполнять обычно не нужно. +При старте сервер последовательно применит `migration_v22.sql` и `migration_v23.sql`, если это требуется текущей версии БД. Вручную migrations выполнять обычно не нужно. После старта проверить: @@ -126,7 +126,7 @@ SELECT * FROM db_schema_version WHERE id=1; Ожидается: ```text -schema_version = 22 +schema_version = 23 ``` И наличие: @@ -413,3 +413,54 @@ archive.publish.enabled=false ### Старый `shine_users` Если новый update payload отклоняется программой, проверить, что целевая Solana `shine_users` действительно обновлена этой версией. + + +## Trusted archive importer + +На обычном тестовом сервере publisher можно оставить выключенным, но разрешить импорт от конкретного архиватора: + +```properties +archive.publish.enabled=false +archive.import.allowedPublishers= +archive.import.intervalMinutes=60 +archive.import.workDir=data/archive-import +``` + +Несколько логинов: + +```properties +archive.import.allowedPublishers=server-a,server-b +``` + +Пустая строка означает, что importer не запускается. + +После старта проверить логи: + +```text +Archive importer включён. approvedPublishers=... +``` + +Если сервер подключается к publisher впервые, importer скачает head, прочитает FULL reference table и обработает все ещё не известные big blocks от старых к новым. + +Проверка БД: + +```sql +SELECT login, archive_head_tx_id, archive_imported, archive_last_imported_tx_id +FROM solana_user_pda_current +WHERE is_server=TRUE AND archive_head_tx_id<>'' +ORDER BY login; + +SELECT blockchain_name, publisher_login, arweave_tx_id, + big_block_number, chunk_offset, chunk_size, source_last_block_number +FROM archive_blockchain_location +ORDER BY updated_at_ms DESC +LIMIT 20; +``` + +После деплоя UI файл должен быть доступен по: + +```text +https:///Blockchain-Viewer.html +``` + +В приложении: `Настройки → Архив блокчейна`. diff --git a/docs/Archive/04_TEST_AND_OPERATIONS.md b/docs/Archive/04_TEST_AND_OPERATIONS.md index 7666ec5c..da32de67 100644 --- a/docs/Archive/04_TEST_AND_OPERATIONS.md +++ b/docs/Archive/04_TEST_AND_OPERATIONS.md @@ -84,3 +84,30 @@ archive.publish.enabled=false ``` Сервер должен запускаться без Arweave/root/client archive key files и не создавать `archive_publish_job`. + + +## Проверка trusted importer + +1. На принимающем сервере указать только тестовый publisher: + +```properties +archive.import.allowedPublishers= +``` + +2. Перезапустить сервер. +3. Дождаться Solana PDA sync и цикла importer-а. +4. Проверить, что у publisher в `solana_user_pda_current` после успешного цикла `archive_imported=true`, а `archive_last_imported_tx_id=archive_head_tx_id`. +5. Проверить `archive_blockchain_location`. +6. Для blockchain, которой локально не хватало блоков, убедиться, что `blockchain_state.last_block_number` вырос. +7. Для уже существующих блоков importer должен пропускать совпадающий hash, а не создавать дубликат. +8. Временно удалить publisher из whitelist и убедиться, что новые archive heads больше не скачиваются. + +### Проверка Viewer + +Открыть `Настройки → Архив блокчейна`, получить ссылку и проверить: + +- `tx`, `offset`, `size`, `blockchain` присутствуют; +- Viewer собирает несколько chunks по backlink; +- неправильный `blockchain` в URL приводит к ошибке проверки; +- `channel` открывает нужный канал; +- `message` прокручивает к нужному block number. diff --git a/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md b/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md index 9e461b61..fd2c6d5c 100644 --- a/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md +++ b/docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md @@ -1,60 +1,38 @@ -# Состав patch-пакета и удаление legacy-файлов +# Состав текущего patch-пакета -ZIP `SHINE_archive_changed_files_with_docs.zip` предназначен для распаковки **поверх исходного дерева той версии сервера, из которой он был сделан**. Внутри находятся только новые и изменённые файлы, пути сохранены относительно корня репозитория. +Этот этап рассчитан **поверх последнего рабочего ZIP**, присланного после успешного запуска archive publisher. -## Важное ограничение ZIP-overlay +Пакет этого этапа добавляет: -Распаковка ZIP может добавить/заменить файлы, но не удалит старые. Поэтому после распаковки нужно удалить legacy test-free-avatar исходники ниже. Они больше не зарегистрированы в `JsonHandlerRegistry`, однако физическое удаление сохраняет дерево в точном состоянии новой версии и не оставляет старый тестовый Arweave-код рядом с production archive publisher. +- trusted archive importer; +- whitelist publisher-ов через настройки сервера; +- строгую проверку больших `SHINE-ARCHIVE`; +- импорт недостающих raw SHiNE blocks через обычный validator `AddBlock`; +- локальные поля состояния импорта в `solana_user_pda_current` и таблицу `archive_blockchain_location`; +- schema migration v23; +- WS API `GetArchiveBlockchainLocation`; +- экран `Настройки → Архив блокчейна`; +- `shine-UI/Blockchain-Viewer.html`; +- документацию importer/viewer. -## Удалить после распаковки +## Удаления -```text -SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java -SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java -SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java -docs/API/14_Test_Free_Avatar_Upload_API.md -``` +В **этом** обновлении удалять файлы не требуется. -Linux-команда из корня репозитория: +Старые test/free-avatar исходники, если они всё ещё физически присутствуют в рабочем дереве, этим patch-пакетом не затрагиваются. Они не относятся к trusted archive importer и не должны удаляться автоматически при наложении этого обновления. -```bash -rm -f \ - 'SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java' \ - 'SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java' \ - 'SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java' \ - 'docs/API/14_Test_Free_Avatar_Upload_API.md' -``` +## Как накладывать ZIP changed-files -## Что пакет принципиально добавляет +ZIP содержит только новые/изменённые файлы с путями от корня репозитория. -- новый Gradle module `shine-server-archive`; -- migration/schema v22; -- archive state DAO/entities; -- server startup scheduler; -- Arweave chunk uploader; -- Solana User PDA writer; -- PDA block type `100` в Rust/Java/JS; -- обновлённую документацию формата User PDA; -- отдельную папку `docs/Archive/` с полным protocol/deploy/runbook. +Распаковать поверх той рабочей версии, из которой сделан пакет, с заменой совпадающих файлов. -## После overlay + удаления - -Минимально проверить: +После наложения: ```bash ./gradlew shadowJar ``` -И отдельно собрать/задеплоить изменённую Solana `shine_users` согласно `03_DEPLOY_TEST_SERVER.md`. +Если Gradle wrapper ещё не установлен локально, сначала обеспечить доступ к уже используемой версии Gradle/кэшу. + +При старте сервер сам должен поднять schema с v22 до v23. diff --git a/docs/Archive/06_FILE_MANIFEST.md b/docs/Archive/06_FILE_MANIFEST.md index c2a81b85..12662e6f 100644 --- a/docs/Archive/06_FILE_MANIFEST.md +++ b/docs/Archive/06_FILE_MANIFEST.md @@ -1,73 +1,50 @@ # Manifest changed/new files -Основа сравнения: последний исходный ZIP пользователя, на который рассчитан этот пакет. +Основа сравнения: последний присланный рабочий ZIP `3d14e34c-4249-4e11-8042-3f3349c8e9fd.zip`. -- Изменённых файлов: 20 -- Новых файлов до добавления этого manifest: 25 -- Удаляемых legacy-файлов: 10 +- Изменённых файлов: 19 +- Новых файлов: 14 +- Удаляемых файлов: 0 ## Изменённые файлы -- `SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java` - `SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java` - `SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql` - `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java` -- `SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java` - `SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java` - `SHiNE-server/src/main/java/server/ws/WsServer.java` - `SHiNE-server/src/main/resources/application.properties` -- `build.gradle` -- `docs/API/09_Operations_Index.md` -- `docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU.md` -- `docs/SHINE_ARCHIVE_PROTOCOL_v1.0_RU_FINAL.md` -- `docs/Solana/user_pda/README.md` -- `docs/Solana_Architecture/details/shine_users.md` -- `settings.gradle` -- `shine-UI/js/services/auth-service.js` -- `shine-UI/js/services/shine-user-pda-service.js` -- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` -- `shine-solana/shine/doc/programs/shine_users.md` -- `shine-solana/shine/programs/shine_users/src/lib.rs` - -## Новые файлы - -- `SHiNE-server/shine-server-archive/build.gradle` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveFileNames.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveKeyLoader.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherConfig.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherScheduler.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchivePublisherService.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveArchiveService.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveWriter.java` -- `SHiNE-server/shine-server-archive/src/main/java/server/archive/SolanaArchiveHeadWriter.java` -- `SHiNE-server/shine-server-archive/src/test/java/server/archive/ArchiveFileNamesTest.java` -- `SHiNE-server/shine-server-archive/src/test/java/server/archive/ShineArchiveWriterTest.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBigBlockRef.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveChainCursor.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJob.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublishJobChain.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchivePublicationDAO.java` -- `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql` - `docs/Archive/01_PROTOCOL_v1.0.md` - `docs/Archive/02_IMPLEMENTATION_MAP.md` - `docs/Archive/03_DEPLOY_TEST_SERVER.md` - `docs/Archive/04_TEST_AND_OPERATIONS.md` - `docs/Archive/05_PATCH_CONTENTS_AND_REMOVALS.md` +- `docs/Archive/06_FILE_MANIFEST.md` - `docs/Archive/README.md` - `docs/Archive/archive-publisher.example.properties` +- `shine-UI/js/app.js` +- `shine-UI/js/pages/settings-view.js` +- `shine-UI/js/services/auth-service.js` -## Legacy-файлы, которые ZIP сам не удаляет +## Новые файлы -- `SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java` -- `SHiNE-server/shine-server-db/src/main/java/shine/db/entities/TestFreeAvatarUploadEntry.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestGetFreeAvatarQuota_Handler.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_TestUploadFreeAvatar_Handler.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/TestFreeAvatarArweaveService.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Request.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestGetFreeAvatarQuota_Response.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Request.java` -- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_TestUploadFreeAvatar_Response.java` -- `docs/API/14_Test_Free_Avatar_Upload_API.md` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportConfig.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportScheduler.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ArchiveImportService.java` +- `SHiNE-server/shine-server-archive/src/main/java/server/archive/ShineArchiveReader.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchiveBlockchainLocation.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/archive/ArchivePublisherHead.java` +- `SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArchiveImportDAO.java` +- `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetArchiveBlockchainLocation_Handler.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Request.java` +- `SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetArchiveBlockchainLocation_Response.java` +- `docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md` +- `shine-UI/Blockchain-Viewer.html` +- `shine-UI/js/pages/blockchain-archive-view.js` -Подробная команда удаления находится в `05_PATCH_CONTENTS_AND_REMOVALS.md`. +## Удаления + +На этом этапе файлов для удаления нет. diff --git a/docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md b/docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md new file mode 100644 index 00000000..51275c52 --- /dev/null +++ b/docs/Archive/07_ARCHIVE_IMPORT_AND_VIEWER.md @@ -0,0 +1,303 @@ +# Импорт доверенных SHINE-ARCHIVE и Blockchain Viewer + +Этот документ описывает вторую половину архивной системы: как обычный SHiNE-сервер узнаёт о новых archive head других серверов, кому доверяет, как импортирует недостающие SHiNE-блоки и как UI получает ссылку на историю конкретной `blockchain_name`. + +## 1. Источник archive head + +Archive importer **не делает отдельные Solana RPC-запросы**. + +Уже существующий Solana Users Sync разбирает User PDA block type `100` и копирует его в локальную таблицу: + +```text +solana_user_pda_current.archive_head_tx_id +solana_user_pda_current.archive_head_hash +``` + +Для локального состояния importer v23 добавляет туда же: + +```text +archive_imported BOOLEAN +archive_last_imported_tx_id TEXT +``` + +Это локальные поля сервера, в Solana они не записываются. + +Когда обычный Solana Users Sync видит тот же archive head повторно, `archive_imported` сохраняется как есть. + +Когда `archive_head_tx_id` или `archive_head_hash` изменился: + +```text +archive_imported = false +``` + +а `archive_last_imported_tx_id` сохраняет последнюю успешно обработанную точку и позволяет продолжить после сбоя. + +## 2. Whitelist доверенных publisher-ов + +В `application.properties` задаётся список логинов серверов, архивы которых разрешено принимать: + +```properties +archive.import.allowedPublishers=archive-server-1,archive-server-2 +archive.import.intervalMinutes=60 +archive.import.workDir=data/archive-import +``` + +Правила: + +- логины разделяются запятыми; +- сравнение без учёта регистра; +- пустое `archive.import.allowedPublishers=` полностью выключает importer; +- принимаются только строки `solana_user_pda_current` с `is_server=true`; +- whitelist является только первым фильтром, криптографические проверки всё равно обязательны. + +## 3. Периодическая проверка + +После запуска сервера importer делает первую проверку примерно через 10 секунд, затем по умолчанию раз в 60 минут. + +Каждый цикл — дешёвый запрос только к локальной PostgreSQL: + +```text +approved publisher +AND is_server=true +AND archive_head_tx_id != '' +AND archive_imported=false +``` + +Если таких строк нет, Arweave не вызывается. + +## 4. Проверки archive block + +Для каждого pending publisher сервер проверяет: + +1. publisher находится в whitelist; +2. `archive_head_tx_id/archive_head_hash` уже пришли через обычный User PDA sync; +3. SHA-256 скачанного файла совпадает с `archive_head_hash`; +4. `creator_login == closer_login == publisher login`; +5. Ed25519 archive signature проверяется root key publisher-а из User PDA; +6. каждый вложенный raw SHiNE block проходит обычную SHiNE-проверку через существующий `AddBlock` path. + +Подпись большого архива защищает контейнер и навигацию, а подписи обычных SHiNE blocks защищают сами пользовательские данные. + +## 5. Как определяется, что head новый + +Отдельная таблица обработанных TX для основной логики не нужна. + +Текущий User PDA snapshot уже содержит: + +```text +archive_head_tx_id +archive_head_hash +archive_imported +archive_last_imported_tx_id +``` + +Пример: + +```text +archive_head_tx_id = TX100 +archive_imported = false +archive_last_imported_tx_id = TX97 +``` + +Это означает: Solana уже объявила `TX100` текущей головой publisher-а, но локальный сервер успел импортировать только до `TX97`. + +После полной успешной обработки `TX100`: + +```text +archive_imported = true +archive_last_imported_tx_id = TX100 +``` + +При следующем новом PDA head Users Sync сам сбросит `archive_imported=false`. + +## 6. Догон пропущенных больших блоков + +Если сервер был выключен и вместо `TX97` сразу увидел `TX100`, он скачивает и проверяет `TX100`, читает его FULL reference table и находит `TX97`. + +После этого импортирует только: + +```text +TX98 +TX99 +TX100 +``` + +После каждого полностью импортированного большого блока `archive_last_imported_tx_id` сдвигается вперёд. + +Если сервер впервые видит publisher и `archive_last_imported_tx_id` пустой, импортируются все previous refs от старых к новым, затем текущий head. + +Если непустой `archive_last_imported_tx_id` отсутствует в FULL history текущего head, importer останавливается: это рассматривается как возможная смена/fork archive chain, а не как повод молча забыть старый cursor. + +## 7. Crash recovery + +Если процесс упал после `TX98`, но до `TX100`: + +```text +archive_imported = false +archive_last_imported_tx_id = TX98 +``` + +Следующий часовой цикл продолжит с `TX99`. + +Если процесс успел импортировать head и записать `archive_last_imported_tx_id = TX100`, но упал до установки `archive_imported=true`, следующий цикл просто завершит отметку без повторной загрузки всей цепочки. + +Все cursor updates выполняются условно по ожидаемому `archive_head_tx_id`. Если обычный Solana Users Sync успел заменить head во время импорта, старый процесс не сможет пометить новый head импортированным. + +## 8. Импорт `UserBlockchainChunk` + +Для каждого chunk: + +1. берётся lock этой `blockchain_name`; +2. если локального `blockchain_state` нет, identity создаётся по синхронизированному User PDA; +3. raw records разбираются как обычные `BchBlockEntry`; +4. уже существующий block допускается только при совпадении hash; +5. новый block должен идти строго `localLast + 1`; +6. новый block добавляется существующим validator/write path; +7. конфликт hash или gap останавливает импорт этой archive chain. + +## 9. Индекс последнего archive chunk + +Таблица: + +```text +archive_blockchain_location +``` + +содержит для каждой `blockchain_name`: + +```text +blockchain_name +publisher_login +arweave_tx_id +archive_hash +big_block_number +chunk_offset +chunk_size +source_last_block_number +updated_at_ms +``` + +Если blockchain встретилась в новом archive block, её location обновляется. Если не встретилась — старая ссылка остаётся. + +Эту таблицу заполняют как trusted importer, так и локальный archive publisher. + +## 10. API для UI + +WS operation: + +```text +GetArchiveBlockchainLocation +``` + +Request: + +```json +{ + "op": "GetArchiveBlockchainLocation", + "blockchainName": "alice-001" +} +``` + +Response содержит: + +```text +blockchainName +publisherLogin +arweaveTxId +archiveHash +bigBlockNumber +chunkOffset +chunkSize +sourceLastBlockNumber +``` + +## 11. UI и ссылка Viewer + +В настройках пользователя есть экран `Архив блокчейна`. + +Viewer-файл: + +```text +shine-UI/Blockchain-Viewer.html +``` + +Основные параметры ссылки: + +```text +/Blockchain-Viewer.html + ?tx= + &offset= + &size= + &blockchain= +``` + +Дополнительно: + +```text +&channel= +&message= +``` + +`blockchain` используется также для проверки: если загруженный chunk имеет другое имя blockchain, Viewer прекращает обработку. + +`channel` открывает нужный канал, а `message` прокручивает к указанному сообщению/block number и выделяет его. + +## 12. Как Viewer собирает всю цепочку + +Viewer начинает с последнего `TX + offset + size`: + +```text +последний UserBlockchainChunk + ↓ +PreviousBlockchainChunkRef + ↓ +FULL reference table текущего big block + ↓ +TX предыдущего big block + ↓ +Range предыдущего chunk + ↓ +следующий backlink + ↓ +до NO_REFERENCE +``` + +Чужие chunks скачивать не требуется. + +## 13. Минимальная настройка принимающего сервера + +```properties +archive.publish.enabled=false +archive.import.allowedPublishers=server-a,server-b +archive.import.intervalMinutes=60 +archive.import.workDir=data/archive-import +``` + +Если импорт архивов не нужен: + +```properties +archive.import.allowedPublishers= +``` + +Тогда importer вообще не запускается. + +## 14. Диагностика PostgreSQL + +Pending archive heads: + +```sql +SELECT login, archive_head_tx_id, archive_imported, archive_last_imported_tx_id +FROM solana_user_pda_current +WHERE is_server = TRUE + AND archive_head_tx_id <> '' +ORDER BY login; +``` + +Последние известные пользовательские chunks: + +```sql +SELECT blockchain_name, publisher_login, arweave_tx_id, + big_block_number, chunk_offset, chunk_size, source_last_block_number +FROM archive_blockchain_location +ORDER BY updated_at_ms DESC; +``` diff --git a/docs/Archive/README.md b/docs/Archive/README.md index 743c9bf8..edf5b61d 100644 --- a/docs/Archive/README.md +++ b/docs/Archive/README.md @@ -9,7 +9,8 @@ 3. `03_DEPLOY_TEST_SERVER.md` — полный порядок установки на тестовый сервер, включая обязательный апгрейд `shine_users`, конфиг, ключи, сборку и запуск. 4. `04_TEST_AND_OPERATIONS.md` — что проверять до полуночи, после полуночи и при сбоях. 5. `05_PATCH_CONTENTS_AND_REMOVALS.md` — какие файлы содержит пакет и какие legacy test-free-avatar файлы нужно удалить при наложении ZIP поверх старого исходника. -6. `archive-publisher.example.properties` — минимальный конфиг архиватора. +6. `07_ARCHIVE_IMPORT_AND_VIEWER.md` — whitelist доверенных publisher-ов, импорт archive chain, индекс последнего chunk, UI и `Blockchain-Viewer.html`. +7. `archive-publisher.example.properties` — пример конфигурации publisher + importer. ## Коротко @@ -26,3 +27,16 @@ ## Важно перед тестом Изменён формат/парсер `shine_users`. **Нельзя просто заменить серверный JAR и включить archive publisher, если целевая Solana-программа `shine_users` ещё не обновлена кодом из этого пакета.** Сначала обновить программу на нужном кластере, затем сервер. + + +## Импорт архивов других серверов + +Импорт по умолчанию также выключен. Настройка: + +```properties +archive.import.allowedPublishers= +``` + +Пустой список означает: не доверять архивам ни одного внешнего сервера. Для разрешения перечислить логины через запятую. Подробности — `07_ARCHIVE_IMPORT_AND_VIEWER.md`. + +Текущая схема БД: **v23**. `v22` добавила publisher, `v23` добавляет локальные `archive_imported/archive_last_imported_tx_id`, trusted importer и универсальный `archive_blockchain_location`. diff --git a/docs/Archive/archive-publisher.example.properties b/docs/Archive/archive-publisher.example.properties index f9190f38..dd046cf0 100644 --- a/docs/Archive/archive-publisher.example.properties +++ b/docs/Archive/archive-publisher.example.properties @@ -21,3 +21,13 @@ archive.solana.clientKeyPath=/home/player/SHiNE/secrets/server-client.key archive.solana.confirmPollSeconds=5 archive.solana.confirmTimeoutMinutes=30 archive.solana.commitment=finalized + + +# ============================================================= +# Trusted archive import (independent from publisher) +# Empty = do not import archives from any external server. +# Comma-separated SHiNE server logins, case-insensitive. +# ============================================================= +archive.import.allowedPublishers= +archive.import.intervalMinutes=60 +archive.import.workDir=data/archive-import diff --git a/shine-UI/Blockchain-Viewer.html b/shine-UI/Blockchain-Viewer.html new file mode 100644 index 00000000..7a54eda5 --- /dev/null +++ b/shine-UI/Blockchain-Viewer.html @@ -0,0 +1,3416 @@ + + + + + + SHiNE Blockchain Viewer + + + + + +
+
+ + + + + +
+ +
+ + +
+ +
Готово к загрузке.
+ +
+
+
После загрузки PDA viewer покажет список каналов.
+
+ + +
+
+ + diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index dcaf7fbf..bca3b962 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -73,6 +73,7 @@ import * as profileEditView from './pages/profile-edit-view.js'; import * as profilesView from './pages/profiles-view.js'; import * as walletView from './pages/wallet-view.js?v=202606281930'; import * as settingsView from './pages/settings-view.js'; +import * as blockchainArchiveView from './pages/blockchain-archive-view.js'; import * as accessServersView from './pages/access-servers-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js'; import * as advancedSettingsView from './pages/advanced-settings-view.js'; @@ -140,6 +141,7 @@ const routes = { 'profiles-view': profilesView, 'wallet-view': walletView, 'settings-view': settingsView, + 'blockchain-archive-view': blockchainArchiveView, 'access-servers-view': accessServersView, 'developer-settings-view': developerSettingsView, 'advanced-settings-view': advancedSettingsView, diff --git a/shine-UI/js/pages/blockchain-archive-view.js b/shine-UI/js/pages/blockchain-archive-view.js new file mode 100644 index 00000000..14c73909 --- /dev/null +++ b/shine-UI/js/pages/blockchain-archive-view.js @@ -0,0 +1,144 @@ +import { createTopBar } from '../components/topbar.js'; +import { authService, state } from '../state.js'; + +export const pageMeta = { id: 'blockchain-archive-view', title: 'Архив блокчейна' }; + +function text(value) { + return String(value == null ? '' : value).trim(); +} + +function buildViewerUrl(location, blockchainName, channelName, messageNumber) { + const url = new URL('/Blockchain-Viewer.html', window.location.origin); + url.searchParams.set('tx', text(location.arweaveTxId)); + url.searchParams.set('offset', String(location.chunkOffset)); + url.searchParams.set('size', String(location.chunkSize)); + url.searchParams.set('blockchain', blockchainName); + const channel = text(channelName); + if (channel) url.searchParams.set('channel', channel); + const message = text(messageNumber); + if (message) url.searchParams.set('message', message); + return url.toString(); +} + +async function copyText(value) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + const area = document.createElement('textarea'); + area.value = value; + document.body.appendChild(area); + area.select(); + document.execCommand('copy'); + area.remove(); +} + +export function render({ navigate, chrome }) { + const screen = document.createElement('section'); + screen.className = 'stack'; + chrome?.setTopbar(createTopBar({ + title: 'Архив блокчейна', + back: { label: '←', onClick: () => navigate('settings-view') }, + })); + + const card = document.createElement('div'); + card.className = 'card stack'; + card.innerHTML = ` +
+ Последний архивный блок +
Загружаю...
+
+ + + + +
+ + +
+
+ Технические данные +

+    
+ `; + screen.appendChild(card); + + const status = card.querySelector('#archive-status'); + const blockchainInput = card.querySelector('#archive-blockchain'); + const channelInput = card.querySelector('#archive-channel'); + const messageInput = card.querySelector('#archive-message'); + const linkInput = card.querySelector('#archive-link'); + const copyButton = card.querySelector('#archive-copy'); + const openButton = card.querySelector('#archive-open'); + const tech = card.querySelector('#archive-tech'); + + let location = null; + let blockchainName = ''; + + function refreshLink() { + if (!location || !blockchainName) { + linkInput.value = ''; + copyButton.disabled = true; + openButton.disabled = true; + return; + } + linkInput.value = buildViewerUrl(location, blockchainName, channelInput.value, messageInput.value); + copyButton.disabled = false; + openButton.disabled = false; + } + + channelInput.addEventListener('input', refreshLink); + messageInput.addEventListener('input', refreshLink); + copyButton.addEventListener('click', async () => { + if (!linkInput.value) return; + await copyText(linkInput.value); + status.textContent = 'Ссылка скопирована.'; + }); + openButton.addEventListener('click', () => { + if (linkInput.value) window.open(linkInput.value, '_blank', 'noopener,noreferrer'); + }); + + void (async () => { + try { + const login = text(state.session.login); + if (!login) throw new Error('Нет активного пользователя'); + const user = await authService.getUser(login); + blockchainName = text(user.blockchainName); + if (!blockchainName) throw new Error('У пользователя не найден blockchainName'); + blockchainInput.value = blockchainName; + location = await authService.getArchiveBlockchainLocation(blockchainName); + if (!text(location.arweaveTxId) || !Number.isFinite(Number(location.chunkOffset)) || !Number.isFinite(Number(location.chunkSize))) { + throw new Error('Сервер вернул неполную архивную ссылку'); + } + status.textContent = `Известна архивная история до блока ${location.sourceLastBlockNumber ?? '—'}.`; + tech.textContent = [ + `publisher: ${text(location.publisherLogin) || '—'}`, + `archive big block: ${location.bigBlockNumber ?? '—'}`, + `Arweave TX: ${text(location.arweaveTxId)}`, + `archive hash: ${text(location.archiveHash) || '—'}`, + `chunk offset: ${location.chunkOffset}`, + `chunk size: ${location.chunkSize}`, + `source last block: ${location.sourceLastBlockNumber ?? '—'}`, + ].join('\n'); + refreshLink(); + } catch (error) { + status.textContent = `Архивная ссылка пока недоступна: ${error?.message || error}`; + tech.textContent = ''; + refreshLink(); + } + })(); + + return screen; +} diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index 20ad280e..44614949 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -71,6 +71,12 @@ export function render({navigate, chrome}) { Solana, SHiNE и Arweave для публичных данных +