Добавить импорт архивов с доверенных серверов

This commit is contained in:
AidarKC
2026-09-12 16:59:35 +03:00
parent 2df0a78eb2
commit 1be1d56599
34 changed files with 4942 additions and 114 deletions
@@ -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<String> allowedPublishers,
int intervalMinutes,
String arweaveGateway,
Path workDir
) {
public static ArchiveImportConfig load() {
AppConfig c = AppConfig.getInstance();
Set<String> 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(); }
}
@@ -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;
}
}
}
@@ -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<ShineArchiveReader.Reference> 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<ShineArchiveReader.Reference> 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<Path> 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;
}
}
@@ -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<Long, ArchiveBigBlockRef> 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) {
@@ -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<byte[]> rawRecords,
long previousBigBlockRef, long previousChunkOffset, long previousChunkSize) {}
public record Document(long bigBlockNumber, long createdAtMs, String creatorLogin,
List<Reference> references, List<Chunk> 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<Reference> refs=new ArrayList<>((int)Math.min(refsCount, Integer.MAX_VALUE));
for(long i=0;i<refsCount;i++) {
long number=u32(in); byte[] hash=readFixed(in,32); byte[] tx=readFixed(in,32);
refs.add(new Reference(number,hash,Base64.getUrlEncoder().withoutPadding().encodeToString(tx)));
}
if (refs.isEmpty() && parentRef != ShineArchiveWriter.NO_REFERENCE_U32) throw new IOException("Некорректный parent reference");
if (!refs.isEmpty() && parentRef >= refs.size()) throw new IOException("parent_reference_index вне таблицы");
List<Chunk> chunks=new ArrayList<>((int)Math.min(chunksCount, Integer.MAX_VALUE));
long parsedRecords=0;
for(long ci=0;ci<chunksCount;ci++) {
long chunkOffset=in.getFilePointer(); long chunkSize=u32(in); long chunkEnd=chunkOffset+chunkSize;
if (chunkSize < 21 || chunkEnd > 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<byte[]> records=new ArrayList<>((int)Math.min(count,Integer.MAX_VALUE));
for(long ri=0;ri<count;ri++) {
long n=u32(in); if(n<=0 || n>Integer.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); }
}
@@ -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;
}
}
}
@@ -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
) {}
@@ -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
) {}
@@ -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<ArchivePublisherHead> listPendingPublisherHeads(Set<String> approvedLogins) throws SQLException {
if (approvedLogins == null || approvedLogins.isEmpty()) return List.of();
List<ArchivePublisherHead> 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) {}
}
@@ -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;
@@ -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;
@@ -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),
@@ -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 ====================================== */
/* ===================================================================== */
@@ -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();}
}
@@ -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;}
}
@@ -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;}
}
@@ -629,7 +629,7 @@ public final class SolanaUsersSyncService
account.address(),
account.slot(),
account.dataBase64(),
state.lastSeenSignature()
""
)
);
} catch (Exception ignored) {
@@ -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, " +
@@ -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();
@@ -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
+28
View File
@@ -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.
+39
View File
@@ -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-а.
+55 -4
View File
@@ -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 '<DATABASE_URL_OR_NAME>' -f shine-before-archive-v22.dump
@@ -115,7 +115,7 @@ pg_dump -Fc -d '<DATABASE_URL_OR_NAME>' -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=<LOGIN_ARCHIVE_SERVER>
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://<UI_HOST>/Blockchain-Viewer.html
```
В приложении: `Настройки → Архив блокчейна`.
+27
View File
@@ -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=<publisher-login>
```
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.
+23 -45
View File
@@ -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.
+28 -51
View File
@@ -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`.
## Удаления
На этом этапе файлов для удаления нет.
@@ -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=<ARWEAVE_TX_ID>
&offset=<CHUNK_OFFSET>
&size=<CHUNK_SIZE>
&blockchain=<BLOCKCHAIN_NAME>
```
Дополнительно:
```text
&channel=<CHANNEL_NAME>
&message=<BLOCK_NUMBER>
```
`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;
```
+15 -1
View File
@@ -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`.
@@ -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
File diff suppressed because one or more lines are too long
+2
View File
@@ -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,
@@ -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 = `
<div class="stack" style="gap:.35rem;">
<strong>Последний архивный блок</strong>
<div id="archive-status" class="meta-muted">Загружаю...</div>
</div>
<label class="field">
<span>Блокчейн</span>
<input id="archive-blockchain" type="text" readonly>
</label>
<label class="field">
<span>Канал (необязательно)</span>
<input id="archive-channel" type="text" placeholder="Название канала">
</label>
<label class="field">
<span>Номер сообщения / блока (необязательно)</span>
<input id="archive-message" type="number" min="0" step="1" placeholder="Например, 125">
</label>
<label class="field">
<span>Ссылка</span>
<textarea id="archive-link" rows="5" readonly style="resize:vertical;"></textarea>
</label>
<div style="display:flex; gap:.6rem; flex-wrap:wrap;">
<button id="archive-copy" class="shine-btn" type="button" disabled>Копировать ссылку</button>
<button id="archive-open" class="shine-btn" type="button" disabled>Открыть Viewer</button>
</div>
<details>
<summary>Технические данные</summary>
<pre id="archive-tech" style="white-space:pre-wrap; word-break:break-all;"></pre>
</details>
`;
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;
}
+7
View File
@@ -71,6 +71,12 @@ export function render({navigate, chrome}) {
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
</span>
</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-blockchain-archive">
<span style="display:block; text-align:left;">
<strong>Архив блокчейна</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Ссылка на архивную историю вашего публичного блокчейна</span>
</span>
</button>
<button class="shine-btn shine-btn--settings" type="button" id="settings-arweave-uploads">
<span style="display:block; text-align:left;">
<strong>Загрузить файлы в блокчейн</strong>
@@ -85,6 +91,7 @@ export function render({navigate, chrome}) {
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-blockchain-archive').addEventListener('click', () => navigate('blockchain-archive-view'));
card.querySelector('#settings-arweave-uploads').addEventListener('click', () => navigate('arweave-uploads-view'));
card.querySelector('#settings-language').addEventListener('click', () => {
sessionStorage.setItem('shine-language-return-page', 'settings-view');
+8
View File
@@ -1077,6 +1077,14 @@ export class AuthService {
return response.payload || {};
}
async getArchiveBlockchainLocation(blockchainName) {
const cleanBlockchainName = String(blockchainName || '').trim();
if (!cleanBlockchainName) throw new Error('Не указано имя блокчейна');
const response = await this.ws.request('GetArchiveBlockchainLocation', { blockchainName: cleanBlockchainName });
if (response.status !== 200) throw opError('GetArchiveBlockchainLocation', response);
return response.payload || response || {};
}
async resolveLoginForAuth(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Введите логин');