Compare commits

..
7 Commits
100 changed files with 17445 additions and 1506 deletions
@@ -0,0 +1,31 @@
plugins {
id 'java'
}
group = 'shine'
version = '1.0.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories { mavenCentral() }
dependencies {
implementation project(':shine-server-config')
implementation project(':shine-server-db')
implementation project(':shine-server-crypto')
implementation project(':shine-server-blockchain')
implementation project(':shine-server-net-protocol')
implementation project(':shine-server-solana-users-sync')
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
implementation 'org.slf4j:slf4j-api:2.0.16'
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
}
test { useJUnitPlatform() }
@@ -0,0 +1,56 @@
package server.archive;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
/** Единые локальные имена больших архивных файлов. */
public final class ArchiveFileNames {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yy");
private ArchiveFileNames() {}
/**
* Временное имя полностью собранного файла до появления Arweave TX ID.
* Пример: archive01.00001.11.09.26.tmp.SHiNE-archive
*/
public static String pendingFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
return baseName(login, bigBlockNumber, createdAtMs, zone) + ".tmp.SHiNE-archive";
}
/**
* Постоянное имя после успешной загрузки в Arweave.
* Пример: archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive
*/
public static String uploadedFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
if (txId == null || txId.length != 32) throw new IllegalArgumentException("Arweave TX ID должен быть 32 bytes");
String tx = Base64.getUrlEncoder().withoutPadding().encodeToString(txId);
return baseName(login, bigBlockNumber, createdAtMs, zone) + "." + tx + ".SHiNE-archive";
}
public static Path pendingFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
return archiveDir.resolve(pendingFileName(login, bigBlockNumber, createdAtMs, zone));
}
public static Path uploadedFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
return archiveDir.resolve(uploadedFileName(login, bigBlockNumber, createdAtMs, zone, txId));
}
private static String baseName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
if (bigBlockNumber < 0 || bigBlockNumber > 0xffff_ffffL) {
throw new IllegalArgumentException("Номер большого блока вне u32: " + bigBlockNumber);
}
String safeLogin = sanitize(login);
String number = String.format("%05d", bigBlockNumber);
String date = DATE.format(Instant.ofEpochMilli(createdAtMs).atZone(zone == null ? ZoneId.systemDefault() : zone));
return safeLogin + "." + number + "." + date;
}
static String sanitize(String login) {
String s = login == null ? "server" : login.trim();
if (s.isEmpty()) s = "server";
return s.replaceAll("[^A-Za-z0-9._-]", "_");
}
}
@@ -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;
}
}
@@ -0,0 +1,75 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import sync.util.Base58Util;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
/** Читает Ed25519 seed32 из распространённых SHiNE/Solana форматов ключей. */
final class ArchiveKeyLoader {
private static final ObjectMapper JSON = new ObjectMapper();
private ArchiveKeyLoader() {}
static byte[] loadSeed32(Path path) throws IOException {
if (path == null) throw new IllegalArgumentException("Путь ключа не задан");
byte[] raw = Files.readAllBytes(path);
if (raw.length == 32) return raw.clone();
if (raw.length == 64) return first32(raw);
String text = new String(raw, StandardCharsets.UTF_8).trim();
if (text.isEmpty()) throw new IOException("Пустой key file: " + path);
// Solana keypair JSON: [64 чисел].
if (text.startsWith("[")) {
JsonNode arr = JSON.readTree(text);
if (!arr.isArray() || (arr.size() != 32 && arr.size() != 64)) {
throw new IOException("Ожидался JSON keypair на 32/64 байта: " + path);
}
byte[] bytes = new byte[arr.size()];
for (int i = 0; i < arr.size(); i++) {
int v = arr.get(i).asInt(-1);
if (v < 0 || v > 255) throw new IOException("Некорректный байт keypair: " + path);
bytes[i] = (byte) v;
}
return bytes.length == 32 ? bytes : first32(bytes);
}
// Base58 seed32 или Solana secret key 64 bytes.
try {
byte[] decoded = Base58Util.decode(text);
if (decoded.length == 32) return decoded;
if (decoded.length == 64) return first32(decoded);
} catch (IllegalArgumentException ignored) {
// Пробуем остальные текстовые форматы ниже.
}
// Base64 raw seed / keypair / Ed25519 PKCS8. В SHiNE PKCS8 seed находится в последних 32 байтах.
try {
byte[] decoded = Base64.getDecoder().decode(text.replaceAll("\\s+", ""));
if (decoded.length == 32) return decoded;
if (decoded.length == 64) return first32(decoded);
if (decoded.length > 32) {
byte[] seed = new byte[32];
System.arraycopy(decoded, decoded.length - 32, seed, 0, 32);
return seed;
}
} catch (IllegalArgumentException ignored) {
// Ниже выдадим понятную общую ошибку.
}
throw new IOException("Не удалось распознать Ed25519 key file: " + path
+ ". Поддерживаются raw seed32, raw/keypair64, Solana JSON, Base58 seed/keypair и Base64 PKCS8.");
}
private static byte[] first32(byte[] bytes) {
byte[] out = new byte[32];
System.arraycopy(bytes, 0, out, 0, 32);
return out;
}
}
@@ -0,0 +1,98 @@
package server.archive;
import utils.config.AppConfig;
import utils.config.SolanaProgramsConfig;
import java.nio.file.Path;
import java.time.LocalTime;
import java.time.ZoneId;
/** Настройки опционального архивного publisher-а. По умолчанию функция выключена. */
public record ArchivePublisherConfig(
boolean enabled,
LocalTime publishTime,
ZoneId publishZone,
long maxFileBytes,
Path workDir,
String serverLogin,
String arweaveGateway,
Path arweaveWalletJwkPath,
int arweaveMinConfirmations,
int arweaveConfirmPollSeconds,
int arweaveConfirmTimeoutMinutes,
Path solanaRootKeyPath,
Path solanaClientKeyPath,
String solanaRpcUrl,
String solanaUsersProgramId,
String solanaPaymentsProgramId,
int solanaConfirmPollSeconds,
int solanaConfirmTimeoutMinutes,
String solanaCommitment
) {
public static ArchivePublisherConfig load() {
AppConfig c = AppConfig.getInstance();
return new ArchivePublisherConfig(
c.getBoolean("archive.publish.enabled", false),
parseTime(orDefault(c.getParam("archive.publish.time"), "00:00")),
parseZone(c.getParam("archive.publish.zoneId")),
positiveLong(parseLong(c.getParam("archive.maxFileBytes"), 4_000_000_000L), "archive.maxFileBytes"),
Path.of(orDefault(c.getParam("archive.workDir"), "data/archive")),
requiredIfEnabled(c, "server.SHiNE.login"),
orDefault(c.getParam("archive.arweave.gateway"), "https://arweave.net"),
optionalPath(c.getParam("archive.arweave.walletJwkPath")),
nonNegative(c.getInt("archive.arweave.minConfirmations", 1), "archive.arweave.minConfirmations"),
positive(c.getInt("archive.arweave.confirmPollSeconds", 30), "archive.arweave.confirmPollSeconds"),
positive(c.getInt("archive.arweave.confirmTimeoutMinutes", 180), "archive.arweave.confirmTimeoutMinutes"),
optionalPath(c.getParam("archive.solana.rootKeyPath")),
optionalPath(c.getParam("archive.solana.clientKeyPath")),
firstNonBlank(c.getParam("solana.users.sync.rpcUrl"), c.getParam("solana.rpcUrl"), SolanaProgramsConfig.SOLANA_RPC_URL),
firstNonBlank(c.getParam("solana.users.sync.programId"), SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID),
SolanaProgramsConfig.SHINE_PAYMENTS_PROGRAM_ID,
positive(c.getInt("archive.solana.confirmPollSeconds", 5), "archive.solana.confirmPollSeconds"),
positive(c.getInt("archive.solana.confirmTimeoutMinutes", 30), "archive.solana.confirmTimeoutMinutes"),
orDefault(c.getParam("archive.solana.commitment"), "finalized")
);
}
public void validateForStart() {
if (!enabled) return;
if (serverLogin == null || serverLogin.isBlank()) throw new IllegalArgumentException("server.SHiNE.login обязателен для archive publisher");
if (arweaveWalletJwkPath == null) throw new IllegalArgumentException("archive.arweave.walletJwkPath обязателен");
if (solanaRootKeyPath == null) throw new IllegalArgumentException("archive.solana.rootKeyPath обязателен");
if (solanaClientKeyPath == null) throw new IllegalArgumentException("archive.solana.clientKeyPath обязателен");
if (solanaRpcUrl == null || solanaRpcUrl.isBlank()) throw new IllegalArgumentException("solana.users.sync.rpcUrl или solana.rpcUrl обязателен");
if (maxFileBytes >= 0x1_0000_0000L) {
throw new IllegalArgumentException("SHINE-ARCHIVE v1 требует archive.maxFileBytes < 4294967296");
}
}
private static String requiredIfEnabled(AppConfig c, String name) {
String v = c.getParam(name);
return v == null ? "" : v.trim();
}
private static Path optionalPath(String s) { return s == null || s.isBlank() ? null : Path.of(s.trim()); }
private static String firstNonBlank(String... values) {
if (values != null) for (String v : values) if (v != null && !v.isBlank()) return v.trim();
return "";
}
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
private static long parseLong(String s, long d) { return s == null || s.isBlank() ? d : Long.parseLong(s.trim()); }
private static LocalTime parseTime(String value) {
try {
return LocalTime.parse(value.trim());
} catch (Exception e) {
throw new IllegalArgumentException("archive.publish.time должен быть в формате HH:mm, например 00:00", e);
}
}
private static ZoneId parseZone(String value) {
if (value == null || value.isBlank()) return ZoneId.systemDefault();
try {
return ZoneId.of(value.trim());
} catch (Exception e) {
throw new IllegalArgumentException("Некорректный archive.publish.zoneId: " + value, e);
}
}
private static int positive(int v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
private static int nonNegative(int v, String n) { if (v < 0) throw new IllegalArgumentException(n + " должен быть >= 0"); return v; }
private static long positiveLong(long v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
}
@@ -0,0 +1,90 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/** Опциональный scheduler: один новый archive job в заданное локальное время каждый день. */
public final class ArchivePublisherScheduler {
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherScheduler.class);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static ScheduledExecutorService executor;
private ArchivePublisherScheduler() {}
public static void startOrLog() {
ArchivePublisherConfig cfg;
try {
cfg = ArchivePublisherConfig.load();
} catch (Exception e) {
log.error("Не удалось прочитать archive publisher config", e);
return;
}
if (!cfg.enabled()) {
log.info("Archive publisher выключен (archive.publish.enabled=false)");
return;
}
if (!STARTED.compareAndSet(false, true)) return;
try {
ArchivePublisherService service = new ArchivePublisherService(cfg);
executor = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override public Thread newThread(Runnable r) {
Thread t = new Thread(r, "shine-archive-publisher");
t.setDaemon(true);
return t;
}
});
// Незавершённую публикацию после рестарта продолжаем сразу, а новый snapshot
// создаём только в очередное назначенное суточное время.
executor.execute(() -> {
try {
service.resumeUnfinishedJob();
} catch (Exception e) {
log.error("Не удалось продолжить незавершённый archive job", e);
}
});
scheduleNextDaily(service, cfg);
log.info("Archive publisher включён: login={} dir={} dailyAt={} zone={}",
cfg.serverLogin(), cfg.workDir(), cfg.publishTime(), cfg.publishZone());
} catch (Exception e) {
STARTED.set(false);
log.error("Archive publisher НЕ запущен из-за ошибки preflight", e);
}
}
private static void scheduleNextDaily(ArchivePublisherService service, ArchivePublisherConfig cfg) {
ScheduledExecutorService e = executor;
if (e == null || e.isShutdown()) return;
ZonedDateTime now = ZonedDateTime.now(cfg.publishZone());
ZonedDateTime next = now.toLocalDate().atTime(cfg.publishTime()).atZone(cfg.publishZone());
if (!next.isAfter(now)) next = next.plusDays(1);
long delayMs = Math.max(1L, Duration.between(now, next).toMillis());
e.schedule(() -> {
try {
service.runCycle();
} catch (Exception ex) {
log.error("Archive publisher daily cycle failed; retry нового snapshot будет в следующее назначенное время", ex);
} finally {
scheduleNextDaily(service, cfg);
}
}, delayMs, TimeUnit.MILLISECONDS);
log.info("Следующая архивная публикация запланирована на {}", next);
}
public static void close() {
ScheduledExecutorService e = executor;
if (e != null) e.shutdownNow();
}
}
@@ -0,0 +1,341 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import shine.db.archive.ArchiveBigBlockRef;
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;
import shine.db.entities.BlockchainStateEntry;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
/** Один crash-safe цикл публикации большого SHINE-ARCHIVE блока. */
public final class ArchivePublisherService {
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherService.class);
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();
private final ArweaveArchiveService arweave;
private final SolanaArchiveHeadWriter solana;
private final byte[] archiveSigningSeed32;
private final AtomicBoolean running = new AtomicBoolean(false);
public ArchivePublisherService(ArchivePublisherConfig cfg) throws Exception {
this.cfg = Objects.requireNonNull(cfg);
cfg.validateForStart();
Files.createDirectories(cfg.workDir());
this.archiveSigningSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
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 не создаёт. */
public boolean resumeUnfinishedJob() throws Exception {
if (!running.compareAndSet(false, true)) return false;
try {
ArchivePublishJob job = archiveDao.findUnfinishedJob();
if (job == null) return false;
log.info("Archive publisher: после рестарта продолжаем job id={} block={} status={}",
job.id(), job.bigBlockNumber(), job.status());
process(job.id());
return true;
} finally {
running.set(false);
}
}
/** Запускает суточный цикл: продолжает незавершённый job либо создаёт новый frozen snapshot. */
public boolean runCycle() throws Exception {
if (!running.compareAndSet(false, true)) {
log.info("Archive publisher: предыдущий цикл ещё работает, новый пропущен");
return false;
}
try {
ArchivePublishJob job = archiveDao.findUnfinishedJob();
if (job == null) {
job = createFrozenJob();
if (job == null) {
log.info("Archive publisher: новых SHiNE-блоков нет");
return false;
}
} else {
log.info("Archive publisher: продолжаем незавершённый job id={} block={} status={}",
job.id(), job.bigBlockNumber(), job.status());
}
process(job.id());
return true;
} finally {
running.set(false);
}
}
private ArchivePublishJob createFrozenJob() throws Exception {
List<ArchivePublishJobChain> chains = new ArrayList<>();
for (BlockchainStateEntry listed : stateDao.listAll()) {
if (listed == null || listed.getBlockchainName() == null || listed.getBlockchainName().isBlank()) continue;
String name = listed.getBlockchainName();
ReentrantLock lock = BlockchainLocks.lockFor(name);
lock.lock();
try {
BlockchainStateEntry state = stateDao.getByBlockchainName(name);
if (state == null || state.getLastBlockNumber() < 0) continue;
ArchiveChainCursor cursor = archiveDao.getCursor(name);
long from;
byte[] previousSourceHash;
Long prevBig = null;
byte[] prevBigHash = null;
Long prevChunkOffset = null;
Long prevChunkSize = null;
if (cursor == null) {
from = 0L;
previousSourceHash = new byte[32];
} else {
long cursorBlock = cursor.lastArchivedSourceBlockNumber();
if (cursorBlock < 0 || cursorBlock > 0xffff_ffffL) {
throw new IllegalStateException("Некорректный archive cursor для " + name + ": " + cursorBlock);
}
byte[] actualCursorHash = blocksDao.getHashByNumber(name, (int) cursorBlock);
if (!Arrays.equals(actualCursorHash, cursor.lastArchivedSourceBlockHash())) {
throw new IllegalStateException("Archive cursor hash не совпадает с локальной blockchain: " + name + "#" + cursorBlock);
}
from = cursorBlock + 1L;
previousSourceHash = cursor.lastArchivedSourceBlockHash();
prevBig = cursor.lastArchiveBigBlockNumber();
prevBigHash = cursor.lastArchiveBigBlockHash();
prevChunkOffset = cursor.lastChunkOffset();
prevChunkSize = cursor.lastChunkSize();
}
long to = Integer.toUnsignedLong(state.getLastBlockNumber());
if (from > to) continue;
if (from > 0xffff_ffffL || to > 0xffff_ffffL) throw new IllegalStateException("Source block number вне u32: " + name);
byte[] lastHash = state.getLastBlockHash();
if (lastHash == null || lastHash.length != 32) {
lastHash = blocksDao.getHashByNumber(name, (int) to);
}
require32(lastHash, "last source block hash " + name);
chains.add(new ArchivePublishJobChain(
0L, name, from, to, previousSourceHash, lastHash,
prevBig, prevBigHash, prevChunkOffset, prevChunkSize, null, null));
} finally {
lock.unlock();
}
}
if (chains.isEmpty()) return null;
chains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
long bigBlock = archiveDao.nextBigBlockNumber();
if (bigBlock > 0xffff_ffffL) throw new IllegalStateException("Закончился диапазон u32 big_block_number");
long createdAt = System.currentTimeMillis();
long id = archiveDao.createJobWithChains(bigBlock, createdAt, chains);
log.info("Archive snapshot создан: job={} bigBlock={} chains={}", id, bigBlock, chains.size());
return archiveDao.getJob(id);
}
private void process(long jobId) throws Exception {
while (true) {
ArchivePublishJob job = archiveDao.getJob(jobId);
if (job == null) throw new IllegalStateException("Archive job исчез: " + jobId);
switch (job.status()) {
case "SNAPSHOT_CREATED" -> buildFile(job);
case "FILE_BUILT" -> uploadArweave(job);
case "ARWEAVE_UPLOADED" -> waitArweave(job);
case "ARWEAVE_CONFIRMED" -> submitSolana(job);
case "SOLANA_SUBMITTED" -> waitSolana(job);
case "SOLANA_FINALIZED" -> commitCursors(job);
case "CURSORS_COMMITTED" -> {
log.info("Archive job завершён: id={} bigBlock={} file={} tx={}", job.id(), job.bigBlockNumber(), job.localArchivePath(), b64url(job.arweaveTxId()));
return;
}
case "FAILED" -> throw new IllegalStateException("Archive job помечен FAILED: " + job.errorText());
default -> throw new IllegalStateException("Неизвестный archive job status: " + job.status());
}
}
}
private void buildFile(ArchivePublishJob job) throws Exception {
List<ArchivePublishJobChain> chains = archiveDao.listJobChains(job.id());
if (chains.isEmpty()) throw new IllegalStateException("SNAPSHOT_CREATED job не содержит chains: " + job.id());
Map<String,List<BlockEntry>> records = new LinkedHashMap<>();
for (ArchivePublishJobChain ch : chains) {
ReentrantLock lock = BlockchainLocks.lockFor(ch.blockchainName());
lock.lock();
try {
if (ch.fromSourceBlockNumber() > 0) {
byte[] prev = blocksDao.getHashByNumber(ch.blockchainName(), (int)(ch.fromSourceBlockNumber() - 1));
if (!Arrays.equals(prev, ch.previousSourceBlockHash())) {
throw new IllegalStateException("Frozen previous source hash изменился: " + ch.blockchainName());
}
}
List<BlockEntry> range = blocksDao.listRangeByNumber(
ch.blockchainName(), (int)ch.fromSourceBlockNumber(), (int)ch.toSourceBlockNumber());
if (range.isEmpty()) throw new IllegalStateException("Frozen range пуст: " + ch.blockchainName());
byte[] last = range.get(range.size()-1).getBlockHash();
if (!Arrays.equals(last, ch.lastSourceBlockHash())) {
throw new IllegalStateException("Frozen last source hash изменился: " + ch.blockchainName());
}
records.put(ch.blockchainName(), range);
} finally {
lock.unlock();
}
}
List<ArchiveBigBlockRef> previous = archiveDao.listFinalizedBigBlocks();
Path file = ArchiveFileNames.pendingFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone());
ShineArchiveWriter.BuildResult result = writer.build(
file, cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), previous,
chains, records, archiveSigningSeed32, cfg.maxFileBytes());
for (Map.Entry<String, ShineArchiveWriter.ChunkPosition> e : result.chunks().entrySet()) {
archiveDao.updateNewChunkPosition(job.id(), e.getKey(), e.getValue().offset(), e.getValue().size());
}
archiveDao.markFileBuilt(job.id(), result.file().toString(), result.archiveHash());
log.info("SHINE-ARCHIVE локально сохранён: block={} file={} bytes={} hash={}",
job.bigBlockNumber(), result.file(), result.fileSize(), hex(result.archiveHash()));
}
private void uploadArweave(ArchivePublishJob job) throws Exception {
Path file = requireLocalFile(job);
byte[] verified = ShineArchiveWriter.verifyLocalFile(file);
if (!Arrays.equals(verified, job.archiveHash())) throw new IllegalStateException("Локальный archive hash отличается от job DB");
ArweaveArchiveService.UploadResult upload = arweave.upload(file, cfg.serverLogin(), job.bigBlockNumber(), job.archiveHash());
// Сначала надёжно фиксируем полученный TX ID в БД. Если процесс упадёт до rename,
// recovery на следующем шаге переименует тот же временный файл без повторной загрузки.
archiveDao.markArweaveUploaded(job.id(), upload.txIdBytes());
Path uploadedFile = markLocalFileUploaded(job, file, upload.txIdBytes());
log.info("SHINE-ARCHIVE загружен в Arweave: block={} tx={} file={}",
job.bigBlockNumber(), upload.txId(), uploadedFile);
}
private void waitArweave(ArchivePublishJob job) throws Exception {
ensureUploadedLocalName(job);
String tx = b64url(job.arweaveTxId());
int confirmations = arweave.waitForConfirmations(tx);
archiveDao.markArweaveConfirmed(job.id(), confirmations);
log.info("Arweave archive закреплён: block={} tx={} confirmations={}", job.bigBlockNumber(), tx, confirmations);
}
/** После успешного Arweave upload временный файл получает реальный TX ID в имени и остаётся локально. */
private Path markLocalFileUploaded(ArchivePublishJob job, Path currentFile, byte[] txId) throws Exception {
require32(txId, "arweave tx id");
Path target = ArchiveFileNames.uploadedFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), txId);
Files.createDirectories(target.toAbsolutePath().getParent());
if (!currentFile.equals(target)) {
try {
Files.move(currentFile, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(currentFile, target, StandardCopyOption.REPLACE_EXISTING);
}
}
archiveDao.updateLocalArchivePath(job.id(), target.toString());
return target;
}
/** Crash recovery: TX уже сохранён, а временный файл ещё не успели переименовать. */
private void ensureUploadedLocalName(ArchivePublishJob job) throws Exception {
require32(job.arweaveTxId(), "job.arweave_tx_id");
Path target = ArchiveFileNames.uploadedFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), job.arweaveTxId());
if (Files.isRegularFile(target)) {
if (!target.toString().equals(job.localArchivePath())) {
archiveDao.updateLocalArchivePath(job.id(), target.toString());
}
return;
}
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) return;
Path current = Path.of(job.localArchivePath());
if (Files.isRegularFile(current)) {
markLocalFileUploaded(job, current, job.arweaveTxId());
}
}
private void submitSolana(ArchivePublishJob job) throws Exception {
require32(job.arweaveTxId(), "job.arweave_tx_id");
require32(job.archiveHash(), "job.archive_hash");
SolanaArchiveHeadWriter.SubmitResult submitted = solana.submitArchiveHead(job.arweaveTxId(), job.archiveHash());
archiveDao.markSolanaSubmitted(job.id(), submitted.signature());
}
private void waitSolana(ArchivePublishJob job) throws Exception {
if (job.solanaSignature() == null || job.solanaSignature().isBlank()) throw new IllegalStateException("SOLANA_SUBMITTED без signature");
solana.waitFinalized(job.solanaSignature());
if (!solana.currentHeadMatches(job.arweaveTxId(), job.archiveHash())) {
throw new IllegalStateException("После Solana finalized User PDA archive head не совпадает с job");
}
archiveDao.markSolanaFinalized(job.id());
log.info("Solana archive head finalized: block={} signature={}", job.bigBlockNumber(), job.solanaSignature());
}
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) {
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) throw new IllegalStateException("FILE_BUILT без local_archive_path");
Path p = Path.of(job.localArchivePath());
if (!Files.isRegularFile(p)) throw new IllegalStateException("Локальный archive-файл отсутствует: " + p);
return p;
}
private static void require32(byte[] v, String name) {
if (v == null || v.length != 32) throw new IllegalStateException(name + " должен быть 32 bytes");
}
private static String b64url(byte[] v) { return v == null ? "" : Base64.getUrlEncoder().withoutPadding().encodeToString(v); }
private static String hex(byte[] b){StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
}
@@ -0,0 +1,259 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.*;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PSSParameterSpec;
import java.security.spec.RSAPrivateCrtKeySpec;
import java.time.Duration;
import java.util.*;
/**
* Публикация готовых локальных SHINE-ARCHIVE файлов в Arweave v2.
* Поддерживает стандартный data Merkle tree и загрузку через /chunk.
*/
public final class ArweaveArchiveService {
private static final Logger log = LoggerFactory.getLogger(ArweaveArchiveService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder B64D = Base64.getUrlDecoder();
private final ArchivePublisherConfig cfg;
private final HttpClient http;
private volatile Wallet wallet;
public ArweaveArchiveService(ArchivePublisherConfig cfg) {
this.cfg = Objects.requireNonNull(cfg);
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
}
public record UploadResult(String txId, byte[] txIdBytes, String gateway) {}
public UploadResult upload(Path archiveFile, String publisherLogin, long bigBlockNumber, byte[] archiveHash)
throws Exception {
long dataSize = Files.size(archiveFile);
if (dataSize <= 0) throw new IllegalArgumentException("Пустой SHINE-ARCHIVE");
Wallet w = wallet();
String gateway = trimGateway(cfg.arweaveGateway());
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(archiveFile);
String anchor = getRequiredText(gateway, "/tx_anchor");
String reward = getRequiredText(gateway, "/price/" + dataSize);
if (!reward.matches("^\\d+$")) throw new IOException("Arweave вернул некорректную цену: " + reward);
verifyBalance(gateway, w.address(), reward);
List<List<byte[]>> tagList = new ArrayList<>();
List<Map<String,String>> jsonTags = new ArrayList<>();
addTag(jsonTags, tagList, "Content-Type", "application/octet-stream");
addTag(jsonTags, tagList, "App-Name", "SHiNE");
addTag(jsonTags, tagList, "SHiNE-Type", "archive");
addTag(jsonTags, tagList, "SHiNE-Archive-Version", "1.0");
addTag(jsonTags, tagList, "SHiNE-Publisher", publisherLogin);
addTag(jsonTags, tagList, "SHiNE-Big-Block", Long.toUnsignedString(bigBlockNumber));
addTag(jsonTags, tagList, "SHiNE-Archive-SHA256", hex(archiveHash));
byte[] signaturePayload = deepHash(List.of(
utf8("2"),
b64Decode(w.owner()),
new byte[0],
utf8("0"),
utf8(reward),
b64Decode(anchor),
tagList,
utf8(Long.toString(dataSize)),
merkle.dataRoot()
));
byte[] rawSignature = signPayload(w.privateKey(), signaturePayload);
byte[] txIdBytes = sha256(rawSignature);
String txId = b64(txIdBytes);
Map<String,Object> tx = new LinkedHashMap<>();
tx.put("format", 2);
tx.put("id", txId);
tx.put("last_tx", anchor);
tx.put("owner", w.owner());
tx.put("tags", jsonTags);
tx.put("target", "");
tx.put("quantity", "0");
tx.put("data_root", b64(merkle.dataRoot()));
tx.put("data_size", Long.toString(dataSize));
tx.put("data", "");
tx.put("reward", reward);
tx.put("signature", b64(rawSignature));
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
log.info("Arweave tx header принят: tx={}, chunks={}, bytes={}", txId, merkle.chunks().size(), dataSize);
for (int i=0; i<merkle.chunks().size(); i++) {
ArweaveMerkle.Chunk chunkMeta = merkle.chunks().get(i);
ArweaveMerkle.Proof proof = merkle.proofs().get(i);
byte[] chunk = ArweaveMerkle.readChunk(archiveFile, chunkMeta);
Map<String,Object> body = new LinkedHashMap<>();
body.put("data_root", b64(merkle.dataRoot()));
body.put("data_size", Long.toString(dataSize));
body.put("data_path", b64(proof.proof()));
body.put("offset", Long.toString(proof.offset()));
body.put("chunk", b64(chunk));
postChunkWithRetry(gateway, body, i, merkle.chunks().size());
}
return new UploadResult(txId, txIdBytes, gateway);
}
/** Ждёт включения tx в блок и заданного количества подтверждений. */
public int waitForConfirmations(String txId) throws Exception {
if (cfg.arweaveMinConfirmations() <= 0) return 0;
String gateway = trimGateway(cfg.arweaveGateway());
long deadline = System.currentTimeMillis() + cfg.arweaveConfirmTimeoutMinutes() * 60_000L;
int last = 0;
while (System.currentTimeMillis() < deadline) {
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/tx/" + txId + "/status"))
.timeout(Duration.ofSeconds(20)).GET().build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() == 200) {
JsonNode json = MAPPER.readTree(resp.body());
last = json.path("number_of_confirmations").asInt(0);
if (last >= cfg.arweaveMinConfirmations()) return last;
} else if (resp.statusCode() != 202 && resp.statusCode() != 404) {
log.warn("Arweave status tx={} HTTP {}: {}", txId, resp.statusCode(), safe(resp.body()));
}
Thread.sleep(cfg.arweaveConfirmPollSeconds() * 1000L);
}
throw new IOException("Истёк timeout ожидания Arweave confirmations для " + txId + ", last=" + last);
}
private void postChunkWithRetry(String gateway, Map<String,Object> body, int index, int total) throws Exception {
Exception last = null;
for (int attempt=1; attempt<=12; attempt++) {
try {
postJsonExpect2xx(gateway + "/chunk", body, Duration.ofSeconds(60));
if ((index + 1) % 25 == 0 || index + 1 == total) {
log.info("Arweave chunks: {}/{}", index + 1, total);
}
return;
} catch (Exception e) {
last = e;
if (attempt == 12) break;
log.warn("Arweave chunk {}/{} attempt {}/12: {}", index + 1, total, attempt, e.getMessage());
Thread.sleep(5_000L);
}
}
throw new IOException("Не удалось загрузить Arweave chunk " + (index + 1) + "/" + total, last);
}
private void postJsonExpect2xx(String url, Object body, Duration timeout) throws Exception {
String json = MAPPER.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.timeout(timeout)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("HTTP " + resp.statusCode() + " " + safe(resp.body()));
}
}
private void verifyBalance(String gateway, String address, String reward) throws Exception {
if (address == null || address.isBlank()) return;
String balance = getRequiredText(gateway, "/wallet/" + address + "/balance");
if (balance.matches("^\\d+$") && new BigInteger(balance).compareTo(new BigInteger(reward)) < 0) {
throw new IllegalStateException("Недостаточно AR: balance=" + balance + ", reward=" + reward);
}
}
private String getRequiredText(String gateway, String path) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + path)).timeout(Duration.ofSeconds(20)).GET().build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("Arweave HTTP " + resp.statusCode() + " для " + path + ": " + safe(resp.body()));
}
String s = resp.body() == null ? "" : resp.body().trim();
if (s.isBlank()) throw new IOException("Пустой Arweave ответ для " + path);
return s;
}
private Wallet wallet() throws Exception {
Wallet local = wallet;
if (local != null) return local;
synchronized (this) {
if (wallet != null) return wallet;
JsonNode jwk = MAPPER.readTree(Files.readString(cfg.arweaveWalletJwkPath(), StandardCharsets.UTF_8));
String owner = required(jwk, "n");
PrivateKey pk = buildPrivateKey(jwk);
String address = b64(sha256(b64Decode(owner)));
wallet = new Wallet(owner, address, pk);
return wallet;
}
}
private static PrivateKey buildPrivateKey(JsonNode jwk) throws GeneralSecurityException {
RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec(
big(required(jwk,"n")), big(required(jwk,"e")), big(required(jwk,"d")),
big(required(jwk,"p")), big(required(jwk,"q")), big(required(jwk,"dp")),
big(required(jwk,"dq")), big(required(jwk,"qi")));
return KeyFactory.getInstance("RSA").generatePrivate(spec);
}
private static byte[] signPayload(PrivateKey key, byte[] payload) throws GeneralSecurityException {
Signature signature = Signature.getInstance("RSASSA-PSS");
signature.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
signature.initSign(key);
signature.update(payload);
return signature.sign();
}
private static byte[] deepHash(Object data) throws GeneralSecurityException {
if (data instanceof List<?> list) {
byte[] tag = concat(utf8("list"), utf8(Integer.toString(list.size())));
return deepHashChunks(list, sha384(tag));
}
if (!(data instanceof byte[] bytes)) throw new IllegalArgumentException("deepHash: ожидается byte[]/List");
byte[] tag = concat(utf8("blob"), utf8(Integer.toString(bytes.length)));
return sha384(concat(sha384(tag), sha384(bytes)));
}
private static byte[] deepHashChunks(List<?> list, byte[] acc) throws GeneralSecurityException {
byte[] cur = acc;
for (Object item : list) cur = sha384(concat(cur, deepHash(item)));
return cur;
}
private static void addTag(List<Map<String,String>> json, List<List<byte[]>> raw, String name, String value) {
byte[] n=utf8(name), v=utf8(value);
Map<String,String> item = new LinkedHashMap<>();
item.put("name", b64(n)); item.put("value", b64(v));
json.add(item); raw.add(List.of(n,v));
}
private static String required(JsonNode node, String field) {
String s = node == null ? "" : node.path(field).asText("").trim();
if (s.isBlank()) throw new IllegalStateException("В Arweave JWK отсутствует " + field);
return s;
}
private static BigInteger big(String s) { return new BigInteger(1, b64Decode(s)); }
private static String trimGateway(String s) { return (s == null ? "https://arweave.net" : s.trim()).replaceAll("/+$", ""); }
private static byte[] utf8(String s) { return String.valueOf(s == null ? "" : s).getBytes(StandardCharsets.UTF_8); }
private static byte[] b64Decode(String s) { return B64D.decode(s); }
private static String b64(byte[] b) { return B64.encodeToString(b); }
private static byte[] sha256(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-256").digest(b); }
private static byte[] sha384(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-384").digest(b); }
private static String hex(byte[] b) { StringBuilder s=new StringBuilder(); for(byte x:b)s.append(String.format("%02x",x)); return s.toString(); }
private static byte[] concat(byte[]... arrays) { int n=0; for(byte[] a:arrays)n=Math.addExact(n,a.length); byte[] o=new byte[n]; int p=0; for(byte[]a:arrays){System.arraycopy(a,0,o,p,a.length);p+=a.length;} return o; }
private static String safe(String s) { s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim(); return s.length()<=250?s:s.substring(0,250)+"..."; }
private record Wallet(String owner, String address, PrivateKey privateKey) {}
}
@@ -0,0 +1,157 @@
package server.archive;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
/**
* Arweave v2 data Merkle tree/proofs. Алгоритм повторяет arweave-js merkle.ts.
*/
final class ArweaveMerkle {
static final int MAX_CHUNK_SIZE = 256 * 1024;
static final int MIN_CHUNK_SIZE = 32 * 1024;
record Chunk(byte[] dataHash, long minByteRange, long maxByteRange) {
long size() { return maxByteRange - minByteRange; }
}
record Proof(long offset, byte[] proof) {}
record Prepared(byte[] dataRoot, List<Chunk> chunks, List<Proof> proofs) {}
private sealed interface Node permits Leaf, Branch {
byte[] id();
long maxByteRange();
}
private record Leaf(byte[] id, byte[] dataHash, long minByteRange, long maxByteRange) implements Node {}
private record Branch(byte[] id, long byteRange, long maxByteRange, Node left, Node right) implements Node {}
private ArweaveMerkle() {}
static Prepared prepare(Path file) throws IOException {
long fileSize = Files.size(file);
List<Chunk> chunks = chunkFile(file, fileSize);
List<Node> leaves = new ArrayList<>(chunks.size());
for (Chunk c : chunks) {
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
}
if (leaves.isEmpty()) throw new IOException("Arweave Merkle: нет leaves");
Node root = buildLayers(leaves);
List<Proof> proofs = new ArrayList<>();
resolveProofs(root, new byte[0], proofs);
// arweave-js строит root вместе с нулевым последним chunk при точном кратном 256KiB,
// но сам нулевой chunk/proof не загружает.
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
chunks = new ArrayList<>(chunks);
chunks.remove(chunks.size() - 1);
proofs.remove(proofs.size() - 1);
}
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
}
private static List<Chunk> chunkFile(Path file, long fileSize) throws IOException {
List<Chunk> out = new ArrayList<>();
long remaining = fileSize;
long cursor = 0;
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
while (remaining >= MAX_CHUNK_SIZE) {
long chunkSize = MAX_CHUNK_SIZE;
long next = remaining - MAX_CHUNK_SIZE;
if (next > 0 && next < MIN_CHUNK_SIZE) {
chunkSize = (remaining + 1) / 2;
}
byte[] data = readExact(in, (int)chunkSize);
out.add(new Chunk(sha256(data), cursor, cursor + chunkSize));
cursor += chunkSize;
remaining -= chunkSize;
}
byte[] rest = readExact(in, (int)remaining);
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
}
return out;
}
private static Node buildLayers(List<Node> nodes) {
if (nodes.size() < 2) return nodes.get(0);
List<Node> next = new ArrayList<>((nodes.size() + 1) / 2);
for (int i = 0; i < nodes.size(); i += 2) {
Node left = nodes.get(i);
Node right = i + 1 < nodes.size() ? nodes.get(i + 1) : null;
if (right == null) {
next.add(left);
continue;
}
long byteRange = left.maxByteRange();
byte[] id = sha256(concat(
sha256(left.id()),
sha256(right.id()),
sha256(note(byteRange))
));
next.add(new Branch(id, byteRange, right.maxByteRange(), left, right));
}
return buildLayers(next);
}
private static void resolveProofs(Node node, byte[] prefix, List<Proof> out) {
if (node instanceof Leaf leaf) {
out.add(new Proof(leaf.maxByteRange() - 1,
concat(prefix, leaf.dataHash(), note(leaf.maxByteRange()))));
return;
}
Branch b = (Branch) node;
byte[] partial = concat(prefix, b.left().id(), b.right().id(), note(b.byteRange()));
resolveProofs(b.left(), partial, out);
resolveProofs(b.right(), partial, out);
}
static byte[] readChunk(Path file, Chunk c) throws IOException {
long len = c.size();
if (len < 0 || len > Integer.MAX_VALUE) throw new IOException("Некорректный Arweave chunk size: " + len);
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
raf.seek(c.minByteRange());
byte[] data = new byte[(int)len];
raf.readFully(data);
return data;
}
}
private static byte[] readExact(InputStream in, int len) throws IOException {
byte[] out = new byte[len];
int pos = 0;
while (pos < len) {
int n = in.read(out, pos, len - pos);
if (n < 0) throw new EOFException("Неожиданный EOF");
pos += n;
}
return out;
}
private static byte[] note(long value) {
if (value < 0) throw new IllegalArgumentException("negative note");
byte[] out = new byte[32];
long v = value;
for (int i = 31; i >= 0 && v != 0; i--) {
out[i] = (byte)(v & 0xff);
v >>>= 8;
}
return out;
}
private static byte[] sha256(byte[] data) {
try { return MessageDigest.getInstance("SHA-256").digest(data); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private static byte[] concat(byte[]... arrays) {
int len = 0;
for (byte[] a : arrays) len = Math.addExact(len, a.length);
byte[] out = new byte[len];
int p=0;
for (byte[] a : arrays) { System.arraycopy(a,0,out,p,a.length); p+=a.length; }
return out;
}
}
@@ -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); }
}
@@ -0,0 +1,310 @@
package server.archive;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchivePublishJobChain;
import shine.db.entities.BlockEntry;
import utils.crypto.Ed25519Util;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
/**
* Writer бинарного SHINE-ARCHIVE v1.0.
*
* Один blockchain_name встречается максимум один раз и содержит все новые raw records этой цепочки.
*/
public final class ShineArchiveWriter {
public static final byte VERSION_MAJOR = 1;
public static final byte VERSION_MINOR = 0;
public static final byte REFERENCES_FULL = 0;
public static final long NO_REFERENCE_U32 = 0xffff_ffffL;
public static final int REFERENCE_ENTRY_SIZE = 68;
public static final byte[] MAGIC = "SHINE-ARCHIVE".getBytes(StandardCharsets.US_ASCII);
public static final byte[] SIGNATURE_DOMAIN = "SHINE-ARCHIVE-V1".getBytes(StandardCharsets.US_ASCII);
public record ChunkPosition(long offset, long size) {}
public record BuildResult(Path file, byte[] archiveHash, Map<String, ChunkPosition> chunks, long fileSize) {}
public BuildResult build(
Path finalPath,
String creatorLogin,
long bigBlockNumber,
long createdAtMs,
List<ArchiveBigBlockRef> previousBigBlocks,
List<ArchivePublishJobChain> chains,
Map<String, List<BlockEntry>> recordsByBlockchain,
byte[] signingPrivateSeed32,
long maxFileBytes
) throws IOException {
Objects.requireNonNull(finalPath, "finalPath");
Objects.requireNonNull(previousBigBlocks, "previousBigBlocks");
Objects.requireNonNull(chains, "chains");
Objects.requireNonNull(recordsByBlockchain, "recordsByBlockchain");
requireU32(bigBlockNumber, "big_block_number");
if (signingPrivateSeed32 == null || signingPrivateSeed32.length != 32) {
throw new IllegalArgumentException("Archive signing key должен быть 32-byte Ed25519 seed");
}
byte[] loginBytes = utf8U8(creatorLogin, "creator_login");
if (previousBigBlocks.size() > 0xffff_ffffL || chains.size() > 0xffff_ffffL) {
throw new IllegalArgumentException("Слишком много references/chunks для v1");
}
List<ArchivePublishJobChain> sortedChains = new ArrayList<>(chains);
sortedChains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
long totalRecords = 0;
for (ArchivePublishJobChain ch : sortedChains) {
List<BlockEntry> rs = recordsByBlockchain.get(ch.blockchainName());
if (rs == null || rs.isEmpty()) {
throw new IllegalArgumentException("Нет frozen records для " + ch.blockchainName());
}
totalRecords += rs.size();
requireU32(totalRecords, "total_user_records_count");
}
Map<Long, Integer> referenceIndexByBlock = new HashMap<>();
for (int i = 0; i < previousBigBlocks.size(); i++) {
ArchiveBigBlockRef ref = previousBigBlocks.get(i);
requireU32(ref.bigBlockNumber(), "PreviousBigBlockReference.big_block_number");
require32(ref.archiveHash(), "PreviousBigBlockReference.big_block_hash");
require32(ref.arweaveTxId(), "PreviousBigBlockReference.arweave_tx_id");
referenceIndexByBlock.put(ref.bigBlockNumber(), i);
}
Files.createDirectories(finalPath.toAbsolutePath().getParent());
Path tmp = finalPath.resolveSibling(finalPath.getFileName() + ".tmp");
Files.deleteIfExists(tmp);
Map<String, ChunkPosition> positions = new LinkedHashMap<>();
MessageDigest digest = sha256();
try (OutputStream fileOut = new BufferedOutputStream(Files.newOutputStream(tmp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE));
CountingOutputStream counting = new CountingOutputStream(fileOut);
DigestOutputStream digestOut = new DigestOutputStream(counting, digest);
DataOutputStream out = new DataOutputStream(digestOut)) {
// ---------- header ----------
out.write(MAGIC);
out.writeByte(VERSION_MAJOR);
out.writeByte(VERSION_MINOR);
long headerSize = fixedHeaderSize() + loginBytes.length;
writeU32(out, headerSize);
writeU32(out, bigBlockNumber);
out.writeLong(createdAtMs);
out.writeByte(loginBytes.length);
out.writeByte(REFERENCES_FULL);
writeU32(out, previousBigBlocks.size());
out.writeShort(REFERENCE_ENTRY_SIZE);
long parentIndex = previousBigBlocks.isEmpty() ? NO_REFERENCE_U32 : previousBigBlocks.size() - 1L;
writeU32(out, parentIndex);
writeU32(out, sortedChains.size());
writeU32(out, totalRecords);
out.write(loginBytes);
// ---------- полный список предыдущих больших блоков ----------
for (ArchiveBigBlockRef ref : previousBigBlocks) {
writeU32(out, ref.bigBlockNumber());
out.write(ref.archiveHash());
out.write(ref.arweaveTxId());
}
// ---------- по одному chunk на blockchain_name ----------
for (ArchivePublishJobChain ch : sortedChains) {
List<BlockEntry> records = new ArrayList<>(recordsByBlockchain.get(ch.blockchainName()));
records.sort(Comparator.comparingInt(BlockEntry::getBlockNumber));
validateFrozenRange(ch, records);
byte[] name = utf8U8(ch.blockchainName(), "blockchain_name");
long chunkSize = calculateChunkSize(name.length, records);
requireU32(chunkSize, "chunk_size");
long chunkOffset = counting.getCount();
requireU32(chunkOffset, "chunk_offset");
if (chunkOffset + chunkSize > maxFileBytes) {
throw new ArchiveTooLargeException("Большой архив превысит archive.maxFileBytes: " + (chunkOffset + chunkSize));
}
writeU32(out, chunkSize);
out.writeByte(name.length);
out.write(name);
writeU32(out, records.size());
for (BlockEntry record : records) {
byte[] raw = record.getBlockBytes();
if (raw == null) throw new IllegalArgumentException("block_bytes=NULL: " + ch.blockchainName() + "#" + record.getBlockNumber());
writeU32(out, raw.length);
out.write(raw);
}
if (ch.previousArchiveBigBlockNumber() == null) {
writeU32(out, NO_REFERENCE_U32);
writeU32(out, 0);
writeU32(out, 0);
} else {
Integer refIndex = referenceIndexByBlock.get(ch.previousArchiveBigBlockNumber());
if (refIndex == null) {
throw new IllegalStateException("Предыдущий big block blockchain отсутствует в FULL reference table: "
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
}
ArchiveBigBlockRef previousRef = previousBigBlocks.get(refIndex);
require32(ch.previousArchiveBigBlockHash(), "previous_archive_big_block_hash " + ch.blockchainName());
if (!Arrays.equals(previousRef.archiveHash(), ch.previousArchiveBigBlockHash())) {
throw new IllegalStateException("Hash предыдущего big block не совпадает с FULL reference table: "
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
}
if (ch.previousChunkOffset() == null || ch.previousChunkSize() == null) {
throw new IllegalStateException("Нет предыдущих chunk offset/size: " + ch.blockchainName());
}
writeU32(out, refIndex);
writeU32(out, ch.previousChunkOffset());
writeU32(out, ch.previousChunkSize());
}
positions.put(ch.blockchainName(), new ChunkPosition(chunkOffset, chunkSize));
}
// ---------- footer, login входит в hash ----------
out.writeByte(loginBytes.length);
out.write(loginBytes);
out.flush();
byte[] archiveHash = digest.digest();
byte[] signedPayload = new byte[SIGNATURE_DOMAIN.length + archiveHash.length];
System.arraycopy(SIGNATURE_DOMAIN, 0, signedPayload, 0, SIGNATURE_DOMAIN.length);
System.arraycopy(archiveHash, 0, signedPayload, SIGNATURE_DOMAIN.length, archiveHash.length);
byte[] signature = Ed25519Util.sign(signedPayload, signingPrivateSeed32);
// hash/signature не должны попасть в hash самого блока.
digestOut.on(false);
out.write(archiveHash);
out.write(signature);
out.flush();
long finalSize = counting.getCount();
if (finalSize >= 0x1_0000_0000L || finalSize > maxFileBytes) {
throw new ArchiveTooLargeException("Размер SHINE-ARCHIVE v1 превышает лимит: " + finalSize);
}
} catch (Exception e) {
Files.deleteIfExists(tmp);
if (e instanceof IOException ioe) throw ioe;
throw new IOException("Не удалось собрать SHINE-ARCHIVE", e);
}
// До Arweave upload существует уже готовый локальный файл с окончательным именем.
try {
Files.move(tmp, finalPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, finalPath, StandardCopyOption.REPLACE_EXISTING);
}
byte[] hash = readStoredArchiveHash(finalPath);
long size = Files.size(finalPath);
return new BuildResult(finalPath, hash, Collections.unmodifiableMap(positions), size);
}
/** Проверяет локальный файл перед оплатой Arweave. */
public static byte[] verifyLocalFile(Path file) throws IOException {
long size = Files.size(file);
if (size < MAGIC.length + 2 + 96) throw new IOException("Слишком короткий SHINE-ARCHIVE: " + file);
byte[] stored = readStoredArchiveHash(file);
MessageDigest md = sha256();
long hashInputSize = size - 96; // [archive_hash 32][signature 64]
try (InputStream in = Files.newInputStream(file)) {
byte[] buf = new byte[1024 * 1024];
long left = hashInputSize;
while (left > 0) {
int n = in.read(buf, 0, (int)Math.min(buf.length, left));
if (n < 0) throw new EOFException("Неожиданный EOF при проверке архива");
md.update(buf, 0, n);
left -= n;
}
}
byte[] actual = md.digest();
if (!Arrays.equals(stored, actual)) throw new IOException("SHA-256 локального SHINE-ARCHIVE не совпадает с footer");
return actual;
}
public static byte[] readStoredArchiveHash(Path file) throws IOException {
long size = Files.size(file);
if (size < 96) throw new EOFException("Файл слишком короткий");
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
raf.seek(size - 96);
byte[] hash = new byte[32];
raf.readFully(hash);
return hash;
}
}
private static long fixedHeaderSize() {
// magic + major/minor + headerSize + blockNo + time + creatorLen + mode + refCount + refSize + parentIndex + chunkCount + recordCount
return 13L + 1 + 1 + 4 + 4 + 8 + 1 + 1 + 4 + 2 + 4 + 4 + 4;
}
private static long calculateChunkSize(int nameLen, List<BlockEntry> records) {
long size = 4L + 1L + nameLen + 4L + 12L;
for (BlockEntry e : records) {
byte[] raw = e.getBlockBytes();
if (raw == null) throw new IllegalArgumentException("raw record is null");
size += 4L + raw.length;
}
return size;
}
private static void validateFrozenRange(ArchivePublishJobChain ch, List<BlockEntry> records) {
long expected = ch.toSourceBlockNumber() - ch.fromSourceBlockNumber() + 1;
if (expected <= 0 || expected != records.size()) {
throw new IllegalArgumentException("Frozen range неполон для " + ch.blockchainName()
+ ": ожидалось=" + expected + ", получено=" + records.size());
}
long n = ch.fromSourceBlockNumber();
for (BlockEntry e : records) {
if (Integer.toUnsignedLong(e.getBlockNumber()) != n) {
throw new IllegalArgumentException("Нарушена непрерывность " + ch.blockchainName()
+ ": ожидался block " + n + ", получен " + Integer.toUnsignedLong(e.getBlockNumber()));
}
n++;
}
}
private static byte[] utf8U8(String value, String name) {
if (value == null) throw new IllegalArgumentException(name + " == null");
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0 || bytes.length > 255) throw new IllegalArgumentException(name + " должен занимать 1..255 UTF-8 байт");
return bytes;
}
private static void require32(byte[] v, String name) {
if (v == null || v.length != 32) throw new IllegalArgumentException(name + " должен быть 32 bytes");
}
private static void requireU32(long v, String name) {
if (v < 0 || v > 0xffff_ffffL) throw new IllegalArgumentException(name + " вне u32: " + v);
}
private static void writeU32(DataOutputStream out, long v) throws IOException {
requireU32(v, "u32");
out.writeInt((int)v); // DataOutputStream = big-endian; битовый шаблон корректен и для unsigned > 2^31.
}
private static MessageDigest sha256() {
try { return MessageDigest.getInstance("SHA-256"); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
/** OutputStream со счётчиком без ограничения int. */
private static final class CountingOutputStream extends FilterOutputStream {
private long count;
CountingOutputStream(OutputStream out) { super(out); }
long getCount() { return count; }
@Override public void write(int b) throws IOException { out.write(b); count++; }
@Override public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); count += len; }
}
public static final class ArchiveTooLargeException extends IOException {
public ArchiveTooLargeException(String message) { super(message); }
}
}
@@ -0,0 +1,373 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sync.codec.ShineUsersCodec;
import sync.codec.ShineUsersCodec.UserPdaSnapshot;
import sync.util.Base58Util;
import sync.util.SolanaPdaUtil;
import utils.crypto.Ed25519Util;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.*;
/**
* Обновляет ArchiveHeadBlock (type=100) обычной инструкцией update_user_pda.
* Отдельной Solana-инструкции для архива нет.
*/
public final class SolanaArchiveHeadWriter {
private static final Logger log = LoggerFactory.getLogger(SolanaArchiveHeadWriter.class);
private static final ObjectMapper JSON = new ObjectMapper();
private static final String SYSTEM_PROGRAM = "11111111111111111111111111111111";
private static final String ED25519_PROGRAM = "Ed25519SigVerify111111111111111111111111111";
private static final String COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111";
private static final String SYSVAR_INSTRUCTIONS = "Sysvar1nstructions1111111111111111111111111";
private static final String USER_SEED_PREFIX = "user_login=";
private static final String ECONOMY_SEED = "shine_users_economy_config";
private static final String INFLOW_SEED = "shine_payments_inflow_vault";
private static final byte[] LAST_BLOCK_STATE_PREFIX = "SHiNE_LAST_BLOCK".getBytes(StandardCharsets.UTF_8);
private final ArchivePublisherConfig cfg;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
private final byte[] rootSeed32;
private final byte[] clientSeed32;
public SolanaArchiveHeadWriter(ArchivePublisherConfig cfg) throws IOException {
this.cfg = Objects.requireNonNull(cfg);
this.rootSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
this.clientSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaClientKeyPath());
}
public record SubmitResult(String signature, String userPda) {}
/** Preflight: проверяет, что локальные root/client private keys соответствуют текущему User PDA. */
public void validateKeysAgainstPda() throws Exception {
Current current = loadCurrent();
byte[] expectedRoot = Base58Util.decode(current.snapshot.rootKey());
byte[] expectedClient = Base58Util.decode(current.snapshot.clientKey());
if (!Arrays.equals(expectedRoot, Ed25519Util.derivePublicKey(rootSeed32))) {
throw new IllegalStateException("archive.solana.rootKeyPath не соответствует root_key User PDA " + cfg.serverLogin());
}
if (!Arrays.equals(expectedClient, Ed25519Util.derivePublicKey(clientSeed32))) {
throw new IllegalStateException("archive.solana.clientKeyPath не соответствует client_key User PDA " + cfg.serverLogin());
}
}
public SubmitResult submitArchiveHead(byte[] arweaveTxId32, byte[] archiveHash32) throws Exception {
require32(arweaveTxId32, "Arweave TX ID");
require32(archiveHash32, "archive hash");
Current current = loadCurrent();
UserPdaSnapshot old = current.snapshot;
byte[] rootPub = Ed25519Util.derivePublicKey(rootSeed32);
byte[] clientPub = Ed25519Util.derivePublicKey(clientSeed32);
if (!Arrays.equals(rootPub, Base58Util.decode(old.rootKey()))) {
throw new IllegalStateException("Root key archive publisher-а не совпадает с User PDA");
}
if (!Arrays.equals(clientPub, Base58Util.decode(old.clientKey()))) {
throw new IllegalStateException("Client key archive publisher-а не совпадает с User PDA");
}
byte[] currentRaw = Base64.getDecoder().decode(old.rawDataBase64());
int currentRecordLen = u16le(currentRaw, 7);
byte[] currentUnsigned = Arrays.copyOf(currentRaw, currentRecordLen - 64);
byte[] prevHash = sha256(currentUnsigned);
long updatedAt = System.currentTimeMillis();
String txB64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(arweaveTxId32);
String archiveHashHex = hex(archiveHash32);
String zeroSig = Base58Util.encode(new byte[64]);
UserPdaSnapshot nextUnsignedSnapshot = new UserPdaSnapshot(
old.pdaAddress(), old.login(), old.recordNumber() + 1, old.slot(), "",
old.recoveryKey(), old.rootKey(), old.clientKey(), old.blockchainName(), old.blockchainKey(),
old.paidLimitBytes(), old.usedBytes(), old.lastBlockNumber(), old.lastBlockHash(),
old.lastBlockSignature(), old.arweaveTxId(), txB64Url, archiveHashHex,
old.isServer(), old.addressFormatType(), old.addressFormatVersion(), old.serverAddress(),
old.syncServers(), old.accessServers(), old.sessionsMode(), old.sessions(), old.trustedCount(),
old.createdAtMs(), updatedAt, hex(prevHash), zeroSig, ""
);
byte[] nextFull = Base64.getDecoder().decode(ShineUsersCodec.serializeSnapshotToBase64(nextUnsignedSnapshot));
int nextRecordLen = u16le(nextFull, 7);
byte[] nextUnsigned = Arrays.copyOf(nextFull, nextRecordLen - 64);
byte[] nextUnsignedHash = sha256(nextUnsigned);
byte[] rootSignature = Ed25519Util.sign(nextUnsignedHash, rootSeed32);
byte[] updateData = buildUpdateInstruction(old, updatedAt, prevHash, arweaveTxId32, archiveHash32, rootSignature);
byte[] lastStateHash = sha256(buildLastBlockState(old));
byte[] lastBlockSignature = Base58Util.decode(old.lastBlockSignature());
byte[] blockchainPub = Base58Util.decode(old.blockchainKey());
byte[] rootEdData = buildEd25519Instruction(rootSignature, rootPub, nextUnsignedHash);
byte[] bchEdData = buildEd25519Instruction(lastBlockSignature, blockchainPub, lastStateHash);
String blockhash = getLatestBlockhash();
byte[] message = buildLegacyMessage(current.userPda, updateData, rootEdData, bchEdData, blockhash, clientPub);
byte[] clientSignature = Ed25519Util.sign(message, clientSeed32);
byte[] transaction = serializeTransaction(clientSignature, message);
String signature = sendTransaction(transaction);
log.info("Archive head отправлен в Solana: login={} pda={} signature={}", cfg.serverLogin(), current.userPda, signature);
return new SubmitResult(signature, current.userPda);
}
public void waitFinalized(String signature) throws Exception {
long deadline = System.currentTimeMillis() + cfg.solanaConfirmTimeoutMinutes() * 60_000L;
while (System.currentTimeMillis() < deadline) {
JsonNode result = rpc("getSignatureStatuses", List.of(List.of(signature), Map.of("searchTransactionHistory", true))).path("result");
JsonNode values = result.path("value");
JsonNode status = values.isArray() && values.size() > 0 ? values.get(0) : null;
if (status != null && !status.isNull()) {
if (status.hasNonNull("err")) {
throw new IOException("Solana archive-head tx завершилась ошибкой: " + status.path("err"));
}
String confirmation = status.path("confirmationStatus").asText("");
if ("finalized".equals(confirmation)) return;
}
Thread.sleep(cfg.solanaConfirmPollSeconds() * 1000L);
}
throw new IOException("Timeout ожидания Solana finalized: " + signature);
}
public boolean currentHeadMatches(byte[] txId32, byte[] hash32) throws Exception {
Current current = loadCurrent();
if (current.snapshot.archiveHeadTxId() == null || current.snapshot.archiveHeadTxId().isBlank()) return false;
byte[] currentTx = Base64.getUrlDecoder().decode(current.snapshot.archiveHeadTxId());
byte[] currentHash = unhex(current.snapshot.archiveHeadHash());
return Arrays.equals(currentTx, txId32) && Arrays.equals(currentHash, hash32);
}
private Current loadCurrent() throws Exception {
String normalized = cfg.serverLogin().trim().toLowerCase(Locale.ROOT);
String userPda = SolanaPdaUtil.findProgramAddress(
List.of(USER_SEED_PREFIX.getBytes(StandardCharsets.UTF_8), normalized.getBytes(StandardCharsets.UTF_8)),
cfg.solanaUsersProgramId());
JsonNode accountResult = rpc("getAccountInfo", List.of(userPda, Map.of("encoding", "base64", "commitment", cfg.solanaCommitment()))).path("result");
JsonNode value = accountResult.path("value");
if (value.isMissingNode() || value.isNull()) throw new IOException("User PDA не найден для " + cfg.serverLogin());
String owner = value.path("owner").asText("");
if (!cfg.solanaUsersProgramId().equals(owner)) throw new IOException("User PDA принадлежит другой программе: " + owner);
JsonNode data = value.path("data");
if (!data.isArray() || data.size() < 1) throw new IOException("Некорректный getAccountInfo.data");
String rawB64 = data.get(0).asText();
UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(userPda, 0L, rawB64, "");
return new Current(userPda, snapshot);
}
private byte[] buildUpdateInstruction(UserPdaSnapshot s, long updatedAt, byte[] prevHash,
byte[] archiveTx, byte[] archiveHash, byte[] rootSig) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(4); // IX_UPDATE_USER_PDA
strU8(out, s.login());
out.write(Base58Util.decode(s.recoveryKey()));
out.write(Base58Util.decode(s.rootKey()));
u64le(out, s.createdAtMs());
u64le(out, updatedAt);
u32le(out, Integer.toUnsignedLong(s.recordNumber() + 1));
out.write(prevHash);
u64le(out, 0L); // additional_limit
out.write(Base58Util.decode(s.clientKey()));
out.write(Base58Util.decode(s.blockchainKey()));
strU8(out, s.blockchainName());
u64le(out, s.usedBytes());
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
out.write(unhex(s.lastBlockHash()));
out.write(Base58Util.decode(s.lastBlockSignature()));
strU8(out, s.arweaveTxId());
out.write(s.isServer() ? 1 : 0);
if (s.isServer()) {
out.write(s.addressFormatType() & 0xff);
out.write(s.addressFormatVersion() & 0xff);
strU8(out, s.serverAddress());
if (s.syncServers().size() > 255) throw new IOException("Слишком много sync_servers");
out.write(s.syncServers().size());
for (String v : s.syncServers()) strU8(out, v);
}
if (s.accessServers().size() > 255) throw new IOException("Слишком много access_servers");
out.write(s.accessServers().size());
for (String v : s.accessServers()) strU8(out, v);
out.write(s.sessionsMode() & 0xff);
if (s.sessions().size() > 255) throw new IOException("Слишком много sessions");
out.write(s.sessions().size());
for (ShineUsersCodec.UserSessionSnapshot session : s.sessions()) {
out.write(session.sessionType() & 0xff);
out.write(session.sessionVersion() & 0xff);
strU8(out, session.sessionName());
out.write(Base58Util.decode(session.sessionPubKey()));
}
out.write(s.trustedCount() & 0xff);
// Archive extension к обычному update_user_pda: marker=1 + tx32 + hash32.
out.write(1);
out.write(archiveTx);
out.write(archiveHash);
out.write(rootSig);
return out.toByteArray();
}
private static byte[] buildLastBlockState(UserPdaSnapshot s) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(LAST_BLOCK_STATE_PREFIX);
strU8(out, s.login());
strU8(out, s.blockchainName());
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
out.write(unhex(s.lastBlockHash()));
u64le(out, s.usedBytes());
return out.toByteArray();
}
private byte[] buildLegacyMessage(String userPda, byte[] updateData, byte[] rootEd, byte[] bchEd,
String recentBlockhash, byte[] clientPub) throws IOException {
String economy = SolanaPdaUtil.findProgramAddress(List.of(ECONOMY_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaUsersProgramId());
String inflow = SolanaPdaUtil.findProgramAddress(List.of(INFLOW_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaPaymentsProgramId());
List<Account> accounts = List.of(
new Account(Base58Util.encode(clientPub), true, true),
new Account(userPda, false, true),
new Account(inflow, false, true),
new Account(SYSTEM_PROGRAM, false, false),
new Account(SYSVAR_INSTRUCTIONS, false, false),
new Account(economy, false, false),
new Account(COMPUTE_BUDGET_PROGRAM, false, false),
new Account(ED25519_PROGRAM, false, false),
new Account(cfg.solanaUsersProgramId(), false, false)
);
Map<String,Integer> ix = new HashMap<>();
for (int i=0;i<accounts.size();i++) ix.put(accounts.get(i).pubkey, i);
List<CompiledInstruction> instructions = new ArrayList<>();
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeHeapData(262_144)));
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeLimitData(800_000)));
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], rootEd));
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], bchEd));
instructions.add(new CompiledInstruction(ix.get(cfg.solanaUsersProgramId()), new int[]{
ix.get(Base58Util.encode(clientPub)), ix.get(userPda), ix.get(SYSTEM_PROGRAM), ix.get(inflow),
ix.get(SYSVAR_INSTRUCTIONS), ix.get(economy)
}, updateData));
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(1); // numRequiredSignatures
out.write(0); // numReadonlySignedAccounts
out.write(6); // readonly unsigned: system, sysvar, economy, compute, ed25519, users
shortVec(out, accounts.size());
for (Account a : accounts) {
byte[] pk = Base58Util.decode(a.pubkey);
if (pk.length != 32) throw new IOException("Solana pubkey !=32: " + a.pubkey);
out.write(pk);
}
byte[] bh = Base58Util.decode(recentBlockhash);
if (bh.length != 32) throw new IOException("recentBlockhash !=32");
out.write(bh);
shortVec(out, instructions.size());
for (CompiledInstruction ci : instructions) {
out.write(ci.programIdIndex);
shortVec(out, ci.accounts.length);
for (int a : ci.accounts) out.write(a);
shortVec(out, ci.data.length);
out.write(ci.data);
}
return out.toByteArray();
}
private String getLatestBlockhash() throws Exception {
JsonNode result = rpc("getLatestBlockhash", List.of(Map.of("commitment", cfg.solanaCommitment()))).path("result");
String blockhash = result.path("value").path("blockhash").asText("");
if (blockhash.isBlank()) throw new IOException("Solana getLatestBlockhash не вернул blockhash");
return blockhash;
}
private String sendTransaction(byte[] tx) throws Exception {
String txB64 = Base64.getEncoder().encodeToString(tx);
JsonNode result = rpc("sendTransaction", List.of(txB64, Map.of(
"encoding", "base64",
"skipPreflight", false,
"preflightCommitment", "confirmed",
"maxRetries", 5
))).path("result");
String sig = result.asText("");
if (sig.isBlank()) throw new IOException("Solana sendTransaction не вернул signature");
return sig;
}
private JsonNode rpc(String method, List<?> params) throws Exception {
Map<String,Object> body = new LinkedHashMap<>();
body.put("jsonrpc", "2.0"); body.put("id", 1); body.put("method", method); body.put("params", params);
HttpRequest req = HttpRequest.newBuilder(URI.create(cfg.solanaRpcUrl()))
.timeout(Duration.ofSeconds(60))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body), StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Solana RPC HTTP " + resp.statusCode() + ": " + safe(resp.body()));
JsonNode root = JSON.readTree(resp.body());
if (root.hasNonNull("error")) throw new IOException("Solana RPC " + method + " error: " + root.path("error"));
return root;
}
private static byte[] serializeTransaction(byte[] signature64, byte[] message) throws IOException {
if (signature64.length != 64) throw new IOException("Solana signature !=64");
ByteArrayOutputStream out = new ByteArrayOutputStream();
shortVec(out, 1);
out.write(signature64);
out.write(message);
return out.toByteArray();
}
private static byte[] buildEd25519Instruction(byte[] signature64, byte[] publicKey32, byte[] message) {
if (signature64.length != 64 || publicKey32.length != 32) throw new IllegalArgumentException("Некорректный Ed25519 ix key/signature");
int sigOff=16, pkOff=sigOff+64, msgOff=pkOff+32;
if (message.length > 0xffff) throw new IllegalArgumentException("Ed25519 message слишком длинный");
byte[] data = new byte[msgOff + message.length];
data[0]=1; data[1]=0;
putU16le(data,2,sigOff); putU16le(data,4,0xffff);
putU16le(data,6,pkOff); putU16le(data,8,0xffff);
putU16le(data,10,msgOff); putU16le(data,12,message.length); putU16le(data,14,0xffff);
System.arraycopy(signature64,0,data,sigOff,64);
System.arraycopy(publicKey32,0,data,pkOff,32);
System.arraycopy(message,0,data,msgOff,message.length);
return data;
}
private static byte[] computeHeapData(int bytes) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(1); u32le(out, Integer.toUnsignedLong(bytes)); return out.toByteArray();
}
private static byte[] computeLimitData(int units) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(2); u32le(out, Integer.toUnsignedLong(units)); return out.toByteArray();
}
private static void shortVec(ByteArrayOutputStream out, int value) {
int rem=value;
while (true) {
int elem=rem & 0x7f; rem >>>=7;
if (rem==0) { out.write(elem); return; }
out.write(elem | 0x80);
}
}
private static void strU8(ByteArrayOutputStream out, String value) throws IOException {
byte[] b=String.valueOf(value==null?"":value).getBytes(StandardCharsets.UTF_8);
if (b.length>255) throw new IOException("Строка >255 bytes"); out.write(b.length); out.write(b);
}
private static void u32le(ByteArrayOutputStream out, long v) { for(int i=0;i<4;i++) out.write((int)(v >>> (8*i)) & 0xff); }
private static void u64le(ByteArrayOutputStream out, long v) { for(int i=0;i<8;i++) out.write((int)(v >>> (8*i)) & 0xff); }
private static void putU16le(byte[] d,int o,int v){d[o]=(byte)v;d[o+1]=(byte)(v>>>8);}
private static int u16le(byte[] b,int o){return (b[o]&255)|((b[o+1]&255)<<8);}
private static byte[] sha256(byte[] bytes) throws Exception { return MessageDigest.getInstance("SHA-256").digest(bytes); }
private static String hex(byte[] b){StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
private static byte[] unhex(String s){String x=String.valueOf(s==null?"":s).trim(); if(x.length()!=64) throw new IllegalArgumentException("hex32 expected");byte[]o=new byte[32];for(int i=0;i<32;i++)o[i]=(byte)Integer.parseInt(x.substring(i*2,i*2+2),16);return o;}
private static void require32(byte[] b,String n){if(b==null||b.length!=32)throw new IllegalArgumentException(n+" должен быть 32 bytes");}
private static String safe(String s){String x=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return x.length()<=500?x:x.substring(0,500)+"...";}
private record Current(String userPda, UserPdaSnapshot snapshot) {}
private record Account(String pubkey, boolean signer, boolean writable) {}
private record CompiledInstruction(int programIdIndex, int[] accounts, byte[] data) {}
}
@@ -0,0 +1,34 @@
package server.archive;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ArchiveFileNamesTest {
private static final long CREATED_AT = Instant.parse("2026-09-11T00:00:00Z").toEpochMilli();
private static final ZoneId UTC = ZoneId.of("UTC");
@Test
void usesTemporaryAndFinalArweaveNames() {
byte[] tx = new byte[32];
for (int i = 0; i < tx.length; i++) tx[i] = (byte) i;
String txText = Base64.getUrlEncoder().withoutPadding().encodeToString(tx);
assertEquals("archive01.00001.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive01", 1, CREATED_AT, UTC));
assertEquals("archive01.00001.11.09.26." + txText + ".SHiNE-archive",
ArchiveFileNames.uploadedFileName("archive01", 1, CREATED_AT, UTC, tx));
assertEquals("archive01.100000.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive01", 100000, CREATED_AT, UTC));
}
@Test
void sanitizesLoginForFileSystemOnly() {
assertEquals("archive_user.00002.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive/user", 2, CREATED_AT, UTC));
}
}
@@ -0,0 +1,83 @@
package server.archive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchivePublishJobChain;
import shine.db.entities.BlockEntry;
import java.io.DataInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class ShineArchiveWriterTest {
@TempDir Path dir;
@Test
void writesOneChunkWithAllRecordsAndOneBacklink() throws Exception {
Path file = ArchiveFileNames.pendingFilePath(dir, "serverA", 2, 123456789L, ZoneId.of("UTC"));
ArchiveBigBlockRef previous = new ArchiveBigBlockRef(1, new byte[32], new byte[32]);
ArchivePublishJobChain chain = new ArchivePublishJobChain(
1, "alice-001", 10, 11, new byte[32], new byte[32],
1L, new byte[32], 100L, 55L, null, null);
ShineArchiveWriter.BuildResult result = new ShineArchiveWriter().build(
file, "serverA", 2, 123456789L,
List.of(previous), List.of(chain),
Map.of("alice-001", List.of(block(10, new byte[]{1,2,3}), block(11, new byte[]{4,5}))),
new byte[32], 4_000_000_000L);
assertArrayEquals(result.archiveHash(), ShineArchiveWriter.verifyLocalFile(file));
assertEquals("serverA.00002.02.01.70.tmp.SHiNE-archive", file.getFileName().toString());
try (InputStream raw = java.nio.file.Files.newInputStream(file); DataInputStream in = new DataInputStream(raw)) {
assertEquals("SHINE-ARCHIVE", new String(in.readNBytes(13), StandardCharsets.US_ASCII));
assertEquals(1, in.readUnsignedByte());
assertEquals(0, in.readUnsignedByte());
long headerSize = Integer.toUnsignedLong(in.readInt());
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertEquals(123456789L, in.readLong());
int creatorLen = in.readUnsignedByte();
assertEquals(ShineArchiveWriter.REFERENCES_FULL, in.readUnsignedByte());
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
assertEquals(68, in.readUnsignedShort());
assertEquals(0, Integer.toUnsignedLong(in.readInt()));
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertEquals("serverA", new String(in.readNBytes(creatorLen), StandardCharsets.UTF_8));
assertEquals(headerSize, 51L + creatorLen);
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
in.skipNBytes(64); // previous hash + txid
long chunkSize = Integer.toUnsignedLong(in.readInt());
int nameLen = in.readUnsignedByte();
assertEquals("alice-001", new String(in.readNBytes(nameLen), StandardCharsets.UTF_8));
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertEquals(3, Integer.toUnsignedLong(in.readInt()));
assertArrayEquals(new byte[]{1,2,3}, in.readNBytes(3));
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertArrayEquals(new byte[]{4,5}, in.readNBytes(2));
assertEquals(0, Integer.toUnsignedLong(in.readInt())); // previous ref index
assertEquals(100, Integer.toUnsignedLong(in.readInt()));
assertEquals(55, Integer.toUnsignedLong(in.readInt()));
assertEquals(4L + 1 + nameLen + 4 + (4+3) + (4+2) + 12, chunkSize);
}
}
private static BlockEntry block(int number, byte[] bytes) {
BlockEntry e = new BlockEntry();
e.setBlockNumber(number);
e.setBlockBytes(bytes);
e.setBlockHash(new byte[32]);
e.setBlockSignature(new byte[64]);
return e;
}
}
@@ -66,19 +66,20 @@ public final class AppConfig {
/** Вернёт строку или пустую строку, если параметр не найден. */
public String getStringOrEmpty(String name) {
String value = properties.getProperty(name);
String value = getParam(name);
return value == null ? "" : value.trim();
}
/** Можно добавить методы для удобства */
/** Целочисленный параметр с тем же приоритетом: system property -> env -> properties. */
public int getInt(String name, int defaultValue) {
String v = properties.getProperty(name);
return v == null ? defaultValue : Integer.parseInt(v);
String v = getParam(name);
return v == null || v.isBlank() ? defaultValue : Integer.parseInt(v.trim());
}
/** Boolean-параметр с тем же приоритетом: system property -> env -> properties. */
public boolean getBoolean(String name, boolean defaultValue) {
String v = properties.getProperty(name);
return v == null ? defaultValue : Boolean.parseBoolean(v);
String v = getParam(name);
return v == null || v.isBlank() ? defaultValue : Boolean.parseBoolean(v.trim());
}
private static String toEnvName(String name) {
@@ -38,6 +38,8 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_19 = 19;
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";
@@ -59,6 +61,8 @@ public final class DatabaseInitializer {
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
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() {}
@@ -212,6 +216,14 @@ public final class DatabaseInitializer {
runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE);
currentVersion = SCHEMA_VERSION_21;
}
if (currentVersion < SCHEMA_VERSION_22) {
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,8 @@
package shine.db.archive;
/** Ссылка на уже успешно опубликованный большой архивный блок. */
public record ArchiveBigBlockRef(
long bigBlockNumber,
byte[] archiveHash,
byte[] arweaveTxId
) {}
@@ -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,15 @@
package shine.db.archive;
/**
* Последнее окончательно опубликованное архивное положение одной SHiNE blockchain.
*/
public record ArchiveChainCursor(
String blockchainName,
long lastArchivedSourceBlockNumber,
byte[] lastArchivedSourceBlockHash,
long lastArchiveBigBlockNumber,
byte[] lastArchiveBigBlockHash,
long lastChunkOffset,
long lastChunkSize,
long updatedAtMs
) {}
@@ -0,0 +1,16 @@
package shine.db.archive;
/** Crash-safe состояние публикации одного большого SHINE-ARCHIVE блока. */
public record ArchivePublishJob(
long id,
long bigBlockNumber,
String status,
long createdAtMs,
String localArchivePath,
byte[] archiveHash,
byte[] arweaveTxId,
Integer arweaveConfirmations,
String solanaSignature,
String errorText,
long updatedAtMs
) {}
@@ -0,0 +1,17 @@
package shine.db.archive;
/** Замороженный диапазон одной blockchain внутри конкретной архивной публикации. */
public record ArchivePublishJobChain(
long jobId,
String blockchainName,
long fromSourceBlockNumber,
long toSourceBlockNumber,
byte[] previousSourceBlockHash,
byte[] lastSourceBlockHash,
Long previousArchiveBigBlockNumber,
byte[] previousArchiveBigBlockHash,
Long previousChunkOffset,
Long previousChunkSize,
Long newChunkOffset,
Long newChunkSize
) {}
@@ -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,472 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchiveChainCursor;
import shine.db.archive.ArchivePublishJob;
import shine.db.archive.ArchivePublishJobChain;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
/**
* DAO архивного publisher-а. Все курсоры считаются подтверждёнными только после Solana finalized.
*/
public final class ArchivePublicationDAO {
private static volatile ArchivePublicationDAO instance;
private final DbController db = DbController.getInstance();
private ArchivePublicationDAO() {}
public static ArchivePublicationDAO getInstance() {
if (instance == null) {
synchronized (ArchivePublicationDAO.class) {
if (instance == null) instance = new ArchivePublicationDAO();
}
}
return instance;
}
public ArchiveChainCursor getCursor(String blockchainName) throws SQLException {
try (Connection c = db.getConnection()) {
return getCursor(c, blockchainName);
}
}
public ArchiveChainCursor getCursor(Connection c, String blockchainName) throws SQLException {
String sql = """
SELECT blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
last_archive_big_block_number, last_archive_big_block_hash,
last_chunk_offset, last_chunk_size, updated_at_ms
FROM archive_chain_cursor
WHERE blockchain_name = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, blockchainName);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapCursor(rs);
}
}
}
public long nextBigBlockNumber() throws SQLException {
String sql = """
SELECT COALESCE(MAX(big_block_number), 0) + 1
FROM archive_publish_job
WHERE status = 'CURSORS_COMMITTED'
""";
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getLong(1);
}
}
public List<ArchiveBigBlockRef> listFinalizedBigBlocks() throws SQLException {
String sql = """
SELECT big_block_number, archive_hash, arweave_tx_id
FROM archive_publish_job
WHERE status = 'CURSORS_COMMITTED'
AND archive_hash IS NOT NULL
AND arweave_tx_id IS NOT NULL
ORDER BY big_block_number ASC
""";
List<ArchiveBigBlockRef> out = new ArrayList<>();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
out.add(new ArchiveBigBlockRef(
rs.getLong("big_block_number"),
rs.getBytes("archive_hash"),
rs.getBytes("arweave_tx_id")
));
}
}
return out;
}
public ArchivePublishJob findUnfinishedJob() throws SQLException {
String sql = """
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
error_text, updated_at_ms
FROM archive_publish_job
WHERE status NOT IN ('CURSORS_COMMITTED', 'FAILED')
ORDER BY id ASC
LIMIT 1
""";
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapJob(rs) : null;
}
}
public ArchivePublishJob getJob(long id) throws SQLException {
String sql = """
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
error_text, updated_at_ms
FROM archive_publish_job
WHERE id = ?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapJob(rs) : null;
}
}
}
/** Атомарно создаёт frozen archive job и все его blockchain ranges. */
public long createJobWithChains(long bigBlockNumber, long createdAtMs, List<ArchivePublishJobChain> chains) throws SQLException {
if (chains == null || chains.isEmpty()) throw new IllegalArgumentException("Archive job без chains не создаётся");
try (Connection c = db.getConnection()) {
boolean oldAuto = c.getAutoCommit();
c.setAutoCommit(false);
try {
long jobId;
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
RETURNING id
""")) {
ps.setLong(1, bigBlockNumber);
ps.setLong(2, createdAtMs);
ps.setLong(3, createdAtMs);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
jobId = rs.getLong(1);
}
}
String sql = """
INSERT INTO archive_publish_job_chain(
job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size, new_chunk_offset, new_chunk_size
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
for (ArchivePublishJobChain ch : chains) {
bindJobChain(ps, new ArchivePublishJobChain(
jobId, ch.blockchainName(), ch.fromSourceBlockNumber(), ch.toSourceBlockNumber(),
ch.previousSourceBlockHash(), ch.lastSourceBlockHash(),
ch.previousArchiveBigBlockNumber(), ch.previousArchiveBigBlockHash(),
ch.previousChunkOffset(), ch.previousChunkSize(), null, null));
ps.addBatch();
}
ps.executeBatch();
}
c.commit();
return jobId;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException se) throw se;
throw new SQLException("Не удалось создать frozen archive job", e);
} finally {
c.setAutoCommit(oldAuto);
}
}
}
public long createJob(long bigBlockNumber, long createdAtMs) throws SQLException {
String sql = """
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
RETURNING id
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, bigBlockNumber);
ps.setLong(2, createdAtMs);
ps.setLong(3, createdAtMs);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
return rs.getLong(1);
}
}
}
public void insertJobChain(ArchivePublishJobChain e) throws SQLException {
String sql = """
INSERT INTO archive_publish_job_chain(
job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size,
new_chunk_offset, new_chunk_size
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
bindJobChain(ps, e);
ps.executeUpdate();
}
}
public List<ArchivePublishJobChain> listJobChains(long jobId) throws SQLException {
String sql = """
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size,
new_chunk_offset, new_chunk_size
FROM archive_publish_job_chain
WHERE job_id = ?
ORDER BY blockchain_name ASC
""";
List<ArchivePublishJobChain> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, jobId);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapJobChain(rs));
}
}
return out;
}
public void updateNewChunkPosition(long jobId, String blockchainName, long offset, long size) throws SQLException {
String sql = """
UPDATE archive_publish_job_chain
SET new_chunk_offset=?, new_chunk_size=?
WHERE job_id=? AND blockchain_name=?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, offset);
ps.setLong(2, size);
ps.setLong(3, jobId);
ps.setString(4, blockchainName);
if (ps.executeUpdate() != 1) throw new SQLException("Не найдена archive_publish_job_chain для " + blockchainName);
}
}
public void markFileBuilt(long jobId, String localPath, byte[] hash) throws SQLException {
updateJob(jobId, "FILE_BUILT", "local_archive_path=? , archive_hash=?", ps -> {
ps.setString(1, localPath);
ps.setBytes(2, hash);
});
}
public void markArweaveUploaded(long jobId, byte[] txId) throws SQLException {
updateJob(jobId, "ARWEAVE_UPLOADED", "arweave_tx_id=?", ps -> ps.setBytes(1, txId));
}
/** Обновляет только локальное имя/путь уже собранного archive-файла. */
public void updateLocalArchivePath(long jobId, String localPath) throws SQLException {
updateJob(jobId, null, "local_archive_path=?", ps -> ps.setString(1, localPath));
}
public void markArweaveConfirmed(long jobId, int confirmations) throws SQLException {
updateJob(jobId, "ARWEAVE_CONFIRMED", "arweave_confirmations=?", ps -> ps.setInt(1, confirmations));
}
public void markSolanaSubmitted(long jobId, String signature) throws SQLException {
updateJob(jobId, "SOLANA_SUBMITTED", "solana_signature=?", ps -> ps.setString(1, signature));
}
public void markSolanaFinalized(long jobId) throws SQLException {
updateJob(jobId, "SOLANA_FINALIZED", null, null);
}
public void markFailed(long jobId, String error) throws SQLException {
updateJob(jobId, "FAILED", "error_text=?", ps -> ps.setString(1, error == null ? "" : error));
}
/**
* После Solana finalized атомарно переводит курсоры всех blockchain на новый chunk и закрывает job.
*/
public void commitCursors(long jobId, byte[] bigBlockHash) throws SQLException {
try (Connection c = db.getConnection()) {
boolean oldAuto = c.getAutoCommit();
c.setAutoCommit(false);
try {
ArchivePublishJob job = getJob(c, jobId);
if (job == null) throw new SQLException("Archive job не найден: " + jobId);
List<ArchivePublishJobChain> chains = listJobChains(c, jobId);
long now = System.currentTimeMillis();
String upsert = """
INSERT INTO archive_chain_cursor(
blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
last_archive_big_block_number, last_archive_big_block_hash,
last_chunk_offset, last_chunk_size, updated_at_ms
) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(blockchain_name) DO UPDATE SET
last_archived_source_block_number=EXCLUDED.last_archived_source_block_number,
last_archived_source_block_hash=EXCLUDED.last_archived_source_block_hash,
last_archive_big_block_number=EXCLUDED.last_archive_big_block_number,
last_archive_big_block_hash=EXCLUDED.last_archive_big_block_hash,
last_chunk_offset=EXCLUDED.last_chunk_offset,
last_chunk_size=EXCLUDED.last_chunk_size,
updated_at_ms=EXCLUDED.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(upsert)) {
for (ArchivePublishJobChain ch : chains) {
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) {
throw new SQLException("Не сохранены координаты нового chunk: " + ch.blockchainName());
}
ps.setString(1, ch.blockchainName());
ps.setLong(2, ch.toSourceBlockNumber());
ps.setBytes(3, ch.lastSourceBlockHash());
ps.setLong(4, job.bigBlockNumber());
ps.setBytes(5, bigBlockHash);
ps.setLong(6, ch.newChunkOffset());
ps.setLong(7, ch.newChunkSize());
ps.setLong(8, now);
ps.addBatch();
}
ps.executeBatch();
}
try (PreparedStatement ps = c.prepareStatement(
"UPDATE archive_publish_job SET status='CURSORS_COMMITTED', updated_at_ms=? WHERE id=?")) {
ps.setLong(1, now);
ps.setLong(2, jobId);
ps.executeUpdate();
}
c.commit();
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException se) throw se;
throw new SQLException("Не удалось commit archive cursors", e);
} finally {
c.setAutoCommit(oldAuto);
}
}
}
private ArchivePublishJob getJob(Connection c, long id) throws SQLException {
String sql = """
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
error_text, updated_at_ms
FROM archive_publish_job WHERE id=?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? mapJob(rs) : null; }
}
}
private List<ArchivePublishJobChain> listJobChains(Connection c, long jobId) throws SQLException {
String sql = """
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size,
new_chunk_offset, new_chunk_size
FROM archive_publish_job_chain
WHERE job_id=? ORDER BY blockchain_name ASC
""";
List<ArchivePublishJobChain> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, jobId);
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapJobChain(rs)); }
}
return out;
}
@FunctionalInterface
private interface Binder { void bind(PreparedStatement ps) throws SQLException; }
private void updateJob(long jobId, String status, String setFragment, Binder binder) throws SQLException {
if (setFragment != null && !setFragment.isBlank()) {
try (Connection c = db.getConnection()) {
updateJobWithExtra(c, jobId, status, setFragment, binder);
}
return;
}
String sql = status == null
? "UPDATE archive_publish_job SET updated_at_ms=? WHERE id=?"
: "UPDATE archive_publish_job SET status=?, updated_at_ms=? WHERE id=?";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
if (status != null) ps.setString(i++, status);
ps.setLong(i++, System.currentTimeMillis());
ps.setLong(i, jobId);
ps.executeUpdate();
}
}
private void updateJobWithExtra(Connection c, long jobId, String status, String setFragment, Binder binder) throws SQLException {
String statusSql = status == null ? "" : ", status=?";
String sql = "UPDATE archive_publish_job SET " + setFragment + statusSql + ", updated_at_ms=? WHERE id=?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
binder.bind(ps);
int count = countParameters(setFragment);
int i = count + 1;
if (status != null) ps.setString(i++, status);
ps.setLong(i++, System.currentTimeMillis());
ps.setLong(i, jobId);
ps.executeUpdate();
}
}
private static int countParameters(String sqlPart) {
int n = 0;
for (int i = 0; i < sqlPart.length(); i++) if (sqlPart.charAt(i) == '?') n++;
return n;
}
private static void bindJobChain(PreparedStatement ps, ArchivePublishJobChain e) throws SQLException {
int i=1;
ps.setLong(i++, e.jobId());
ps.setString(i++, e.blockchainName());
ps.setLong(i++, e.fromSourceBlockNumber());
ps.setLong(i++, e.toSourceBlockNumber());
ps.setBytes(i++, e.previousSourceBlockHash());
ps.setBytes(i++, e.lastSourceBlockHash());
setLongNullable(ps, i++, e.previousArchiveBigBlockNumber());
ps.setBytes(i++, e.previousArchiveBigBlockHash());
setLongNullable(ps, i++, e.previousChunkOffset());
setLongNullable(ps, i++, e.previousChunkSize());
setLongNullable(ps, i++, e.newChunkOffset());
setLongNullable(ps, i, e.newChunkSize());
}
private static ArchiveChainCursor mapCursor(ResultSet rs) throws SQLException {
return new ArchiveChainCursor(
rs.getString("blockchain_name"),
rs.getLong("last_archived_source_block_number"),
rs.getBytes("last_archived_source_block_hash"),
rs.getLong("last_archive_big_block_number"),
rs.getBytes("last_archive_big_block_hash"),
rs.getLong("last_chunk_offset"),
rs.getLong("last_chunk_size"),
rs.getLong("updated_at_ms")
);
}
private static ArchivePublishJob mapJob(ResultSet rs) throws SQLException {
int conf = rs.getInt("arweave_confirmations");
Integer confObj = rs.wasNull() ? null : conf;
return new ArchivePublishJob(
rs.getLong("id"), rs.getLong("big_block_number"), rs.getString("status"),
rs.getLong("created_at_ms"), rs.getString("local_archive_path"),
rs.getBytes("archive_hash"), rs.getBytes("arweave_tx_id"), confObj,
rs.getString("solana_signature"), rs.getString("error_text"), rs.getLong("updated_at_ms")
);
}
private static ArchivePublishJobChain mapJobChain(ResultSet rs) throws SQLException {
return new ArchivePublishJobChain(
rs.getLong("job_id"), rs.getString("blockchain_name"),
rs.getLong("from_source_block_number"), rs.getLong("to_source_block_number"),
rs.getBytes("previous_source_block_hash"), rs.getBytes("last_source_block_hash"),
getLongNullable(rs, "previous_archive_big_block_number"), rs.getBytes("previous_archive_big_block_hash"),
getLongNullable(rs, "previous_chunk_offset"), getLongNullable(rs, "previous_chunk_size"),
getLongNullable(rs, "new_chunk_offset"), getLongNullable(rs, "new_chunk_size")
);
}
private static Long getLongNullable(ResultSet rs, String name) throws SQLException {
long v = rs.getLong(name);
return rs.wasNull() ? null : v;
}
private static void setLongNullable(PreparedStatement ps, int idx, Long v) throws SQLException {
if (v == null) ps.setNull(idx, Types.BIGINT); else ps.setLong(idx, v);
}
}
@@ -0,0 +1,56 @@
CREATE TABLE IF NOT EXISTS archive_chain_cursor (
blockchain_name TEXT PRIMARY KEY,
last_archived_source_block_number BIGINT NOT NULL,
last_archived_source_block_hash BYTEA NOT NULL,
last_archive_big_block_number BIGINT NOT NULL,
last_archive_big_block_hash BYTEA NOT NULL,
last_chunk_offset BIGINT NOT NULL,
last_chunk_size BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS archive_publish_job (
id BIGSERIAL PRIMARY KEY,
big_block_number BIGINT NOT NULL,
status TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
local_archive_path TEXT,
archive_hash BYTEA,
arweave_tx_id BYTEA,
arweave_confirmations INTEGER,
solana_signature TEXT,
error_text TEXT,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_status
ON archive_publish_job(status);
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_big_block_number
ON archive_publish_job(big_block_number);
CREATE TABLE IF NOT EXISTS archive_publish_job_chain (
job_id BIGINT NOT NULL REFERENCES archive_publish_job(id) ON DELETE CASCADE,
blockchain_name TEXT NOT NULL,
from_source_block_number BIGINT NOT NULL,
to_source_block_number BIGINT NOT NULL,
previous_source_block_hash BYTEA NOT NULL,
last_source_block_hash BYTEA NOT NULL,
previous_archive_big_block_number BIGINT,
previous_archive_big_block_hash BYTEA,
previous_chunk_offset BIGINT,
previous_chunk_size BIGINT,
new_chunk_offset BIGINT,
new_chunk_size BIGINT,
PRIMARY KEY (job_id, blockchain_name)
);
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE solana_user_pda_history
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
ALTER TABLE solana_user_pda_history
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
UPDATE db_schema_version SET schema_version = 22, updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id = 1;
@@ -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;
@@ -2028,8 +2028,80 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state (
PRIMARY KEY (owner_login, category)
);
CREATE TABLE IF NOT EXISTS archive_chain_cursor (
blockchain_name TEXT PRIMARY KEY,
last_archived_source_block_number BIGINT NOT NULL,
last_archived_source_block_hash BYTEA NOT NULL,
last_archive_big_block_number BIGINT NOT NULL,
last_archive_big_block_hash BYTEA NOT NULL,
last_chunk_offset BIGINT NOT NULL,
last_chunk_size BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS archive_publish_job (
id BIGSERIAL PRIMARY KEY,
big_block_number BIGINT NOT NULL,
status TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
local_archive_path TEXT,
archive_hash BYTEA,
arweave_tx_id BYTEA,
arweave_confirmations INTEGER,
solana_signature TEXT,
error_text TEXT,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_status
ON archive_publish_job(status);
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_big_block_number
ON archive_publish_job(big_block_number);
CREATE TABLE IF NOT EXISTS archive_publish_job_chain (
job_id BIGINT NOT NULL REFERENCES archive_publish_job(id) ON DELETE CASCADE,
blockchain_name TEXT NOT NULL,
from_source_block_number BIGINT NOT NULL,
to_source_block_number BIGINT NOT NULL,
previous_source_block_hash BYTEA NOT NULL,
last_source_block_hash BYTEA NOT NULL,
previous_archive_big_block_number BIGINT,
previous_archive_big_block_hash BYTEA,
previous_chunk_offset BIGINT,
previous_chunk_size BIGINT,
new_chunk_offset BIGINT,
new_chunk_size BIGINT,
PRIMARY KEY (job_id, blockchain_name)
);
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE solana_user_pda_history
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
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,21,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,15 +42,13 @@ 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.Net_TestGetFreeAvatarQuota_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request;
// --- NEW: SearchUsers ---
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler;
@@ -165,8 +163,6 @@ public final class JsonHandlerRegistry {
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
Map.entry("GetUser", new Net_GetUser_Handler()),
Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()),
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
// --- auth ---
Map.entry("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()),
@@ -197,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()),
@@ -259,8 +256,6 @@ public final class JsonHandlerRegistry {
private static final Map<String, Class<? extends Net_Request>> REQUEST_TYPES = Map.ofEntries(
Map.entry("GetUser", Net_GetUser_Request.class),
Map.entry("SearchUsers", Net_SearchUsers_Request.class),
Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class),
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
// --- auth ---
Map.entry("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class),
@@ -291,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;}
}
@@ -147,7 +147,7 @@ public final class SignedMessagesRealtime {
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
return false;
}
String text = "Вам пришло новое личное сообщение от " + message.getFromLogin() + ".";
String text = "Новое сообщение от " + message.getFromLogin();
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
return WebPushSender.sendBase64Payload(
session.getPushEndpoint(),
@@ -3,6 +3,7 @@ package server.logic.ws_protocol.JSON.push;
import nl.martijndwars.webpush.Notification;
import nl.martijndwars.webpush.PushService;
import nl.martijndwars.webpush.Subscription;
import nl.martijndwars.webpush.Urgency;
import org.jose4j.lang.JoseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -61,7 +62,7 @@ public final class WebPushSender {
endpoint,
new Subscription.Keys(p256dhKey, authKey)
);
Notification notification = new Notification(subscription, payloadB64);
Notification notification = new Notification(subscription, payloadB64, Urgency.HIGH);
var response = service().send(notification);
int code = response.getStatusLine().getStatusCode();
return code >= 200 && code < 300;
@@ -59,6 +59,9 @@ public final class ShineUsersCodec {
private static final int BLOCK_TYPE_TRUSTED_STATE =
70;
private static final int BLOCK_TYPE_ARCHIVE_HEAD =
100;
private static final int BLOCK_VERSION_0 =
0;
@@ -306,6 +309,12 @@ public final class ShineUsersCodec {
int trustedCount =
0;
String archiveHeadTxId =
"";
String archiveHeadHash =
"";
for (int i = 0; i < blocksCount; i++) {
int blockType =
@@ -455,6 +464,13 @@ public final class ShineUsersCodec {
trustedCount =
reader.readU8();
case BLOCK_TYPE_ARCHIVE_HEAD -> {
archiveHeadTxId =
Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32));
archiveHeadHash =
toHex(reader.readFixed(32));
}
default ->
throw new IllegalArgumentException(
"Unsupported block type: " + blockType
@@ -484,6 +500,8 @@ public final class ShineUsersCodec {
lastBlockHash,
lastBlockSignature,
arweaveTxId,
archiveHeadTxId,
archiveHeadHash,
isServer,
addressFormatType,
addressFormatVersion,
@@ -520,7 +538,9 @@ public final class ShineUsersCodec {
mutation.createdAtMs(),
mutation.createdAtMs(),
toHex(ZERO_HASH),
paidLimitBytes
paidLimitBytes,
mutation.fields().archiveHeadSupplied() ? mutation.fields().archiveHeadTxId() : "",
mutation.fields().archiveHeadSupplied() ? mutation.fields().archiveHeadHash() : ""
);
}
@@ -535,6 +555,13 @@ public final class ShineUsersCodec {
previous.paidLimitBytes()
+ mutation.additionalLimit();
String archiveTx = mutation.fields().archiveHeadSupplied()
? mutation.fields().archiveHeadTxId()
: previous.archiveHeadTxId();
String archiveHash = mutation.fields().archiveHeadSupplied()
? mutation.fields().archiveHeadHash()
: previous.archiveHeadHash();
return buildSnapshot(
mutation,
mutation.version(),
@@ -543,7 +570,9 @@ public final class ShineUsersCodec {
mutation.createdAtMs(),
mutation.updatedAtMs(),
toHex(mutation.prevHash()),
paidLimitBytes
paidLimitBytes,
archiveTx,
archiveHash
);
}
@@ -617,10 +646,13 @@ public final class ShineUsersCodec {
pushFixed(out, prevHash);
pushStringU8(out, loginBytes);
boolean hasArchiveHead =
snapshot.archiveHeadTxId() != null
&& !snapshot.archiveHeadTxId().isBlank();
int blocksCount =
snapshot.isServer()
? 8
: 7;
(snapshot.isServer() ? 8 : 7)
+ (hasArchiveHead ? 1 : 0);
out.add((byte) blocksCount);
@@ -695,6 +727,18 @@ public final class ShineUsersCodec {
out.add((byte) BLOCK_VERSION_0);
out.add((byte) snapshot.trustedCount());
if (hasArchiveHead) {
byte[] txId = Base64.getUrlDecoder().decode(snapshot.archiveHeadTxId());
byte[] archiveHash = fromHex(snapshot.archiveHeadHash());
if (txId.length != 32 || archiveHash.length != 32) {
throw new IllegalArgumentException("Archive head должен содержать txId/hash по 32 байта");
}
out.add((byte) BLOCK_TYPE_ARCHIVE_HEAD);
out.add((byte) BLOCK_VERSION_0);
pushFixed(out, txId);
pushFixed(out, archiveHash);
}
int recordLength =
out.size() + 64;
@@ -871,6 +915,8 @@ public final class ShineUsersCodec {
UserFields fields =
parseFields(reader);
fields = parseOptionalArchiveExtension(reader, fields);
String recordSignature =
Base58Util.encode(
reader.readFixed(64)
@@ -1008,7 +1054,35 @@ public final class ShineUsersCodec {
List.copyOf(accessServers),
sessionsMode,
List.copyOf(sessions),
trustedCount
trustedCount,
false,
"",
""
);
}
private static UserFields parseOptionalArchiveExtension(Reader reader, UserFields fields) {
if (reader.remaining() == 64) {
return fields;
}
if (reader.remaining() < 65) {
throw new IllegalArgumentException("Некорректная archive extension в update_user_pda");
}
int present = reader.readU8();
String tx = "";
String hash = "";
if (present == 1) {
tx = Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32));
hash = toHex(reader.readFixed(32));
} else if (present != 0) {
throw new IllegalArgumentException("Некорректный archive head marker: " + present);
}
return new UserFields(
fields.clientKey(), fields.blockchainKey(), fields.blockchainName(), fields.usedBytes(),
fields.lastBlockNumber(), fields.lastBlockHash(), fields.lastBlockSignature(), fields.arweaveTxId(),
fields.isServer(), fields.addressFormatType(), fields.addressFormatVersion(), fields.serverAddress(),
fields.syncServers(), fields.accessServers(), fields.sessionsMode(), fields.sessions(), fields.trustedCount(),
true, tx, hash
);
}
@@ -1020,7 +1094,9 @@ public final class ShineUsersCodec {
long createdAtMs,
long updatedAtMs,
String prevRecordHash,
long paidLimitBytes
long paidLimitBytes,
String archiveHeadTxId,
String archiveHeadHash
) {
UserFields fields =
@@ -1044,6 +1120,8 @@ public final class ShineUsersCodec {
fields.lastBlockHash(),
fields.lastBlockSignature(),
fields.arweaveTxId(),
archiveHeadTxId == null ? "" : archiveHeadTxId,
archiveHeadHash == null ? "" : archiveHeadHash,
fields.isServer(),
fields.addressFormatType(),
fields.addressFormatVersion(),
@@ -1266,6 +1344,10 @@ public final class ShineUsersCodec {
) {
cursor += bytes;
}
private int remaining() {
return data.length - cursor;
}
}
public enum TxKind {
@@ -1327,7 +1409,10 @@ public final class ShineUsersCodec {
List<String> accessServers,
int sessionsMode,
List<UserSessionSnapshot> sessions,
int trustedCount
int trustedCount,
boolean archiveHeadSupplied,
String archiveHeadTxId,
String archiveHeadHash
) {
}
@@ -1389,6 +1474,8 @@ public final class ShineUsersCodec {
String lastBlockHash,
String lastBlockSignature,
String arweaveTxId,
String archiveHeadTxId,
String archiveHeadHash,
boolean isServer,
int addressFormatType,
int addressFormatVersion,
@@ -1424,6 +1511,8 @@ public final class ShineUsersCodec {
lastBlockHash,
lastBlockSignature,
arweaveTxId,
archiveHeadTxId,
archiveHeadHash,
isServer,
addressFormatType,
addressFormatVersion,
@@ -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
@@ -425,13 +419,13 @@ public final class PostgresStorageRepository
"record_number, recovery_key, root_key, client_key, " +
"blockchain_name, blockchain_key, paid_limit_bytes, " +
"used_bytes, last_block_number, last_block_hash, " +
"last_block_signature, arweave_tx_id, is_server, " +
"last_block_signature, arweave_tx_id, archive_head_tx_id, archive_head_hash, is_server, " +
"address_format_type, address_format_version, " +
"server_address, sync_servers_json, access_servers_json, " +
"sessions_mode, sessions_json, trusted_count, " +
"created_at_ms, updated_at_ms, prev_record_hash, " +
"record_signature, raw_data_base64, saved_at_ms" +
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT (pda_address, record_number) DO NOTHING";
try (PreparedStatement statement =
@@ -470,13 +464,13 @@ public final class PostgresStorageRepository
"recovery_key, root_key, client_key, blockchain_name, " +
"blockchain_key, paid_limit_bytes, used_bytes, " +
"last_block_number, last_block_hash, last_block_signature, " +
"arweave_tx_id, is_server, address_format_type, " +
"arweave_tx_id, archive_head_tx_id, archive_head_hash, is_server, address_format_type, " +
"address_format_version, server_address, sync_servers_json, " +
"access_servers_json, sessions_mode, sessions_json, " +
"trusted_count, created_at_ms, updated_at_ms, " +
"prev_record_hash, record_signature, raw_data_base64, " +
"first_seen_at_ms, last_synced_at_ms" +
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT (pda_address) DO UPDATE SET " +
"login = EXCLUDED.login, " +
"normalized_login = EXCLUDED.normalized_login, " +
@@ -494,6 +488,16 @@ 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, " +
"address_format_type = EXCLUDED.address_format_type, " +
"address_format_version = EXCLUDED.address_format_version, " +
@@ -536,141 +540,41 @@ public final class PostgresStorageRepository
ShineUsersCodec.UserPdaSnapshot snapshot,
long nowMs
) throws Exception {
statement.setString(
1,
snapshot.lastTxSignature()
);
statement.setLong(
2,
snapshot.slot()
);
statement.setNull(
3,
Types.BIGINT
);
statement.setString(
4,
snapshot.pdaAddress()
);
statement.setString(
5,
snapshot.login()
);
statement.setInt(
6,
snapshot.recordNumber()
);
statement.setString(
7,
snapshot.recoveryKey()
);
statement.setString(
8,
snapshot.rootKey()
);
statement.setString(
9,
snapshot.clientKey()
);
statement.setString(
10,
snapshot.blockchainName()
);
statement.setString(
11,
snapshot.blockchainKey()
);
statement.setLong(
12,
snapshot.paidLimitBytes()
);
statement.setLong(
13,
snapshot.usedBytes()
);
statement.setInt(
14,
snapshot.lastBlockNumber()
);
statement.setString(
15,
snapshot.lastBlockHash()
);
statement.setString(
16,
snapshot.lastBlockSignature()
);
statement.setString(
17,
snapshot.arweaveTxId()
);
statement.setBoolean(
18,
snapshot.isServer()
);
statement.setInt(
19,
snapshot.addressFormatType()
);
statement.setInt(
20,
snapshot.addressFormatVersion()
);
statement.setString(
21,
snapshot.serverAddress()
);
statement.setString(
22,
writeJson(
snapshot.syncServers()
)
);
statement.setString(
23,
writeJson(
snapshot.accessServers()
)
);
statement.setInt(
24,
snapshot.sessionsMode()
);
statement.setString(
25,
writeJson(
snapshot.sessions()
)
);
statement.setInt(
26,
snapshot.trustedCount()
);
statement.setLong(
27,
snapshot.createdAtMs()
);
statement.setLong(
28,
snapshot.updatedAtMs()
);
statement.setString(
29,
snapshot.prevRecordHash()
);
statement.setString(
30,
snapshot.recordSignature()
);
statement.setString(
31,
snapshot.rawDataBase64()
);
statement.setLong(
32,
nowMs
);
int i = 1;
statement.setString(i++, snapshot.lastTxSignature());
statement.setLong(i++, snapshot.slot());
statement.setNull(i++, Types.BIGINT);
statement.setString(i++, snapshot.pdaAddress());
statement.setString(i++, snapshot.login());
statement.setInt(i++, snapshot.recordNumber());
statement.setString(i++, snapshot.recoveryKey());
statement.setString(i++, snapshot.rootKey());
statement.setString(i++, snapshot.clientKey());
statement.setString(i++, snapshot.blockchainName());
statement.setString(i++, snapshot.blockchainKey());
statement.setLong(i++, snapshot.paidLimitBytes());
statement.setLong(i++, snapshot.usedBytes());
statement.setInt(i++, snapshot.lastBlockNumber());
statement.setString(i++, snapshot.lastBlockHash());
statement.setString(i++, snapshot.lastBlockSignature());
statement.setString(i++, snapshot.arweaveTxId());
statement.setString(i++, snapshot.archiveHeadTxId());
statement.setString(i++, snapshot.archiveHeadHash());
statement.setBoolean(i++, snapshot.isServer());
statement.setInt(i++, snapshot.addressFormatType());
statement.setInt(i++, snapshot.addressFormatVersion());
statement.setString(i++, snapshot.serverAddress());
statement.setString(i++, writeJson(snapshot.syncServers()));
statement.setString(i++, writeJson(snapshot.accessServers()));
statement.setInt(i++, snapshot.sessionsMode());
statement.setString(i++, writeJson(snapshot.sessions()));
statement.setInt(i++, snapshot.trustedCount());
statement.setLong(i++, snapshot.createdAtMs());
statement.setLong(i++, snapshot.updatedAtMs());
statement.setString(i++, snapshot.prevRecordHash());
statement.setString(i++, snapshot.recordSignature());
statement.setString(i++, snapshot.rawDataBase64());
statement.setLong(i, nowMs);
}
private void bindCurrentSnapshot(
@@ -678,40 +582,42 @@ public final class PostgresStorageRepository
ShineUsersCodec.UserPdaSnapshot snapshot,
long nowMs
) throws Exception {
statement.setString(1, snapshot.pdaAddress());
statement.setString(2, snapshot.login());
statement.setString(3, normalizeLogin(snapshot.login()));
statement.setInt(4, snapshot.recordNumber());
statement.setLong(5, snapshot.slot());
statement.setString(6, snapshot.lastTxSignature());
statement.setString(7, snapshot.recoveryKey());
statement.setString(8, snapshot.rootKey());
statement.setString(9, snapshot.clientKey());
statement.setString(10, snapshot.blockchainName());
statement.setString(11, snapshot.blockchainKey());
statement.setLong(12, snapshot.paidLimitBytes());
statement.setLong(13, snapshot.usedBytes());
statement.setInt(14, snapshot.lastBlockNumber());
statement.setString(15, snapshot.lastBlockHash());
statement.setString(16, snapshot.lastBlockSignature());
statement.setString(17, snapshot.arweaveTxId());
statement.setBoolean(18, snapshot.isServer());
statement.setInt(19, snapshot.addressFormatType());
statement.setInt(20, snapshot.addressFormatVersion());
statement.setString(21, snapshot.serverAddress());
statement.setString(22, writeJson(snapshot.syncServers()));
statement.setString(23, writeJson(snapshot.accessServers()));
statement.setInt(24, snapshot.sessionsMode());
statement.setString(25, writeJson(snapshot.sessions()));
statement.setInt(26, snapshot.trustedCount());
statement.setLong(27, snapshot.createdAtMs());
statement.setLong(28, snapshot.updatedAtMs());
statement.setString(29, snapshot.prevRecordHash());
statement.setString(30, snapshot.recordSignature());
statement.setString(31, snapshot.rawDataBase64());
statement.setLong(32, nowMs);
statement.setLong(33, nowMs);
int i=1;
statement.setString(i++, snapshot.pdaAddress());
statement.setString(i++, snapshot.login());
statement.setString(i++, normalizeLogin(snapshot.login()));
statement.setInt(i++, snapshot.recordNumber());
statement.setLong(i++, snapshot.slot());
statement.setString(i++, snapshot.lastTxSignature());
statement.setString(i++, snapshot.recoveryKey());
statement.setString(i++, snapshot.rootKey());
statement.setString(i++, snapshot.clientKey());
statement.setString(i++, snapshot.blockchainName());
statement.setString(i++, snapshot.blockchainKey());
statement.setLong(i++, snapshot.paidLimitBytes());
statement.setLong(i++, snapshot.usedBytes());
statement.setInt(i++, snapshot.lastBlockNumber());
statement.setString(i++, snapshot.lastBlockHash());
statement.setString(i++, snapshot.lastBlockSignature());
statement.setString(i++, snapshot.arweaveTxId());
statement.setString(i++, snapshot.archiveHeadTxId());
statement.setString(i++, snapshot.archiveHeadHash());
statement.setBoolean(i++, snapshot.isServer());
statement.setInt(i++, snapshot.addressFormatType());
statement.setInt(i++, snapshot.addressFormatVersion());
statement.setString(i++, snapshot.serverAddress());
statement.setString(i++, writeJson(snapshot.syncServers()));
statement.setString(i++, writeJson(snapshot.accessServers()));
statement.setInt(i++, snapshot.sessionsMode());
statement.setString(i++, writeJson(snapshot.sessions()));
statement.setInt(i++, snapshot.trustedCount());
statement.setLong(i++, snapshot.createdAtMs());
statement.setLong(i++, snapshot.updatedAtMs());
statement.setString(i++, snapshot.prevRecordHash());
statement.setString(i++, snapshot.recordSignature());
statement.setString(i++, snapshot.rawDataBase64());
statement.setLong(i++, nowMs);
statement.setLong(i, nowMs);
}
private String normalizeLogin(String login) {
@@ -763,6 +669,8 @@ public final class PostgresStorageRepository
resultSet.getString("last_block_hash"),
resultSet.getString("last_block_signature"),
resultSet.getString("arweave_tx_id"),
resultSet.getString("archive_head_tx_id"),
resultSet.getString("archive_head_hash"),
resultSet.getBoolean("is_server"),
resultSet.getInt("address_format_type"),
resultSet.getInt("address_format_version"),
@@ -933,6 +841,8 @@ public final class PostgresStorageRepository
"last_block_hash TEXT NOT NULL, " +
"last_block_signature TEXT NOT NULL, " +
"arweave_tx_id TEXT NOT NULL, " +
"archive_head_tx_id TEXT NOT NULL DEFAULT '', " +
"archive_head_hash TEXT NOT NULL DEFAULT '', " +
"is_server BOOLEAN NOT NULL, " +
"address_format_type INTEGER NOT NULL, " +
"address_format_version INTEGER NOT NULL, " +
@@ -962,6 +872,9 @@ public final class PostgresStorageRepository
" OR normalized_login <> LOWER(BTRIM(login))"
);
statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''");
statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''");
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot " +
"ON solana_user_pda_current(slot)"
@@ -995,6 +908,8 @@ public final class PostgresStorageRepository
"last_block_hash TEXT NOT NULL, " +
"last_block_signature TEXT NOT NULL, " +
"arweave_tx_id TEXT NOT NULL, " +
"archive_head_tx_id TEXT NOT NULL DEFAULT '', " +
"archive_head_hash TEXT NOT NULL DEFAULT '', " +
"is_server BOOLEAN NOT NULL, " +
"address_format_type INTEGER NOT NULL, " +
"address_format_version INTEGER NOT NULL, " +
@@ -1014,6 +929,9 @@ public final class PostgresStorageRepository
")"
);
statement.executeUpdate("ALTER TABLE solana_user_pda_history ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''");
statement.executeUpdate("ALTER TABLE solana_user_pda_history ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''");
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_user_pda_history_login " +
"ON solana_user_pda_history(login)"
@@ -87,7 +87,7 @@ public final class SolanaPdaUtil {
return false;
}
return Ed25519.validatePublicKeyFull(
return Ed25519.validatePublicKeyPartial(
publicKey,
0
);
@@ -0,0 +1,27 @@
package sync.util;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SolanaPdaUtilTest {
@Test
void findProgramAddressMatchesSolanaRuntimeForUserLoginSeed() {
String pda = SolanaPdaUtil.findProgramAddress(
List.of(
"user_login=".getBytes(StandardCharsets.UTF_8),
"server_t2".getBytes(StandardCharsets.UTF_8)
),
"SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6"
);
assertEquals(
"EJCW7cPXgEMvQAzmhJMZ7pxQxxxFbQYZoJNhcfJpWnz8",
pda
);
}
}
@@ -0,0 +1,14 @@
package server.files;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
/** Регистрирует HTTP API content-addressed хранилища DM-файлов. */
public final class DmFileApiConfigurator {
private DmFileApiConfigurator() {}
public static void register(ServletContextHandler context) {
context.addServlet(new ServletHolder(new DmFileServlet()), "/dm-files/*");
}
}
@@ -0,0 +1,414 @@
package server.files;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import server.logic.ws_protocol.Base64Ws;
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.entities.ActiveSessionEntry;
import utils.config.AppConfig;
import utils.crypto.Ed25519Util;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.SQLException;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* HTTP-хранилище зашифрованных файлов из личных сообщений.
*
* Сервер намеренно ничего не знает об исходном имени, MIME и ключе AES-GCM:
* он получает только ciphertext и хранит его под content-addressed именем
* Base58(SHA-256(ciphertext)). Ключ файла передаётся внутри E2EE DM.
*/
public final class DmFileServlet extends HttpServlet {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
private static final Pattern FILE_ID_PATTERN = Pattern.compile("^[1-9A-HJ-NP-Za-km-z]{32,44}$");
private static final String HEADER_SESSION_ID = "X-Shine-Session-Id";
private static final String HEADER_TIME_MS = "X-Shine-Time-Ms";
private static final String HEADER_CONTENT_LENGTH = "X-Shine-Content-Length";
private static final String HEADER_SIGNATURE = "X-Shine-Signature";
private static final long DEFAULT_MAX_BYTES = 50L * 1024L * 1024L + 16L;
private static final long MAX_AUTH_SKEW_MS = 60_000L;
private final boolean enabled;
private final Path storageDir;
private final long maxBytes;
public DmFileServlet() {
AppConfig config = AppConfig.getInstance();
this.enabled = readBoolean(config, "dm.files.enabled", true);
this.storageDir = Path.of(readString(config, "dm.files.storageDir", "data/dm-files"))
.toAbsolutePath()
.normalize();
this.maxBytes = Math.max(16L, readLong(config, "dm.files.maxBytes", DEFAULT_MAX_BYTES));
}
@Override
protected void doOptions(HttpServletRequest req, HttpServletResponse resp) {
applyCors(resp);
resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
}
@Override
protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws IOException {
applyCors(resp);
if (!enabled) {
writeError(resp, HttpServletResponse.SC_NOT_FOUND, "FILES_DISABLED", "Передача файлов отключена на сервере");
return;
}
String fileId = extractFileId(req);
if (fileId == null) {
writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "BAD_FILE_ID", "Некорректный идентификатор файла");
return;
}
UploadAuth auth;
try {
auth = readAndVerifyUploadAuth(req, fileId);
} catch (AuthFailure failure) {
writeError(resp, failure.httpStatus, failure.code, failure.getMessage());
return;
}
if (auth.declaredLength > maxBytes) {
writeError(resp, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "FILE_TOO_LARGE", "Файл превышает лимит сервера");
return;
}
Files.createDirectories(storageDir);
Path target = safeTarget(fileId);
if (target == null) {
writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "BAD_FILE_ID", "Некорректный идентификатор файла");
return;
}
// Content-addressed объект неизменяем. Нельзя доверять одному только имени файла:
// перед ответом «уже есть» повторно проверяем реальный SHA-256 сохранённых байтов.
// Если файл на диске повреждён/подменён, удаляем его и принимаем корректную загрузку заново.
if (Files.isRegularFile(target)) {
StoredObjectCheck stored = verifyStoredObject(target, fileId);
if (stored.valid()) {
if (stored.size() != auth.declaredLength) {
writeError(resp, HttpServletResponse.SC_CONFLICT, "FILE_ID_CONFLICT", "Файл с таким hash уже существует с другим размером");
return;
}
writeUploadOk(resp, fileId, auth.declaredLength, true);
return;
}
Files.deleteIfExists(target);
}
Path temp = Files.createTempFile(storageDir, ".dm-upload-", ".tmp");
boolean keepTemp = false;
try {
MessageDigest digest = sha256Digest();
long actualLength = 0L;
byte[] buffer = new byte[64 * 1024];
try (InputStream in = req.getInputStream(); OutputStream out = Files.newOutputStream(temp)) {
int read;
while ((read = in.read(buffer)) >= 0) {
if (read == 0) continue;
actualLength += read;
if (actualLength > maxBytes || actualLength > auth.declaredLength) {
writeError(resp, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "FILE_TOO_LARGE", "Получено больше байтов, чем разрешено");
return;
}
digest.update(buffer, 0, read);
out.write(buffer, 0, read);
}
}
if (actualLength != auth.declaredLength) {
writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "LENGTH_MISMATCH", "Размер загруженного файла не совпал с подписанным размером");
return;
}
String actualId = toBase58(digest.digest());
if (!Objects.equals(fileId, actualId)) {
writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "HASH_MISMATCH", "SHA-256 загруженного ciphertext не совпал с адресом файла");
return;
}
boolean alreadyExists = false;
try {
try {
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException ignored) {
Files.move(temp, target);
}
keepTemp = true; // temp уже перемещён, удалять нечего
} catch (FileAlreadyExistsException race) {
StoredObjectCheck stored = verifyStoredObject(target, fileId);
if (stored.valid() && stored.size() == actualLength) {
alreadyExists = true;
} else {
// Даже в редкой гонке не подтверждаем объект, пока его реальные байты не прошли hash-проверку.
Files.deleteIfExists(target);
try {
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException ignored) {
Files.move(temp, target);
}
keepTemp = true;
}
}
writeUploadOk(resp, fileId, actualLength, alreadyExists);
} finally {
if (!keepTemp) {
Files.deleteIfExists(temp);
}
}
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
serveFile(req, resp, false);
}
@Override
protected void doHead(HttpServletRequest req, HttpServletResponse resp) throws IOException {
serveFile(req, resp, true);
}
private void serveFile(HttpServletRequest req, HttpServletResponse resp, boolean headOnly) throws IOException {
applyCors(resp);
if (!enabled) {
writeError(resp, HttpServletResponse.SC_NOT_FOUND, "FILES_DISABLED", "Передача файлов отключена на сервере");
return;
}
String fileId = extractFileId(req);
Path target = fileId == null ? null : safeTarget(fileId);
if (target == null || !Files.isRegularFile(target)) {
writeError(resp, HttpServletResponse.SC_NOT_FOUND, "FILE_NOT_FOUND", "Файл не найден");
return;
}
StoredObjectCheck stored = verifyStoredObject(target, fileId);
if (!stored.valid()) {
// Повреждённый content-addressed объект нельзя подтверждать через HEAD и нельзя отдавать через GET.
Files.deleteIfExists(target);
writeError(resp, HttpServletResponse.SC_NOT_FOUND, "FILE_NOT_FOUND", "Файл не найден");
return;
}
long size = stored.size();
resp.setStatus(HttpServletResponse.SC_OK);
resp.setContentType("application/octet-stream");
resp.setHeader("Content-Length", Long.toString(size));
resp.setHeader("ETag", "\"" + fileId + "\"");
resp.setHeader("Cache-Control", "no-store");
resp.setHeader("X-Content-Type-Options", "nosniff");
resp.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
if (!headOnly) {
try (InputStream in = Files.newInputStream(target); OutputStream out = resp.getOutputStream()) {
in.transferTo(out);
}
}
}
private static StoredObjectCheck verifyStoredObject(Path target, String expectedFileId) throws IOException {
MessageDigest digest = sha256Digest();
long size = 0L;
byte[] buffer = new byte[64 * 1024];
try (InputStream in = Files.newInputStream(target)) {
int read;
while ((read = in.read(buffer)) >= 0) {
if (read == 0) continue;
size += read;
digest.update(buffer, 0, read);
}
}
String actualFileId = toBase58(digest.digest());
return new StoredObjectCheck(Objects.equals(expectedFileId, actualFileId), size);
}
private record StoredObjectCheck(boolean valid, long size) {}
private UploadAuth readAndVerifyUploadAuth(HttpServletRequest req, String fileId) throws AuthFailure {
String sessionId = requiredHeader(req, HEADER_SESSION_ID);
String timeRaw = requiredHeader(req, HEADER_TIME_MS);
String lengthRaw = requiredHeader(req, HEADER_CONTENT_LENGTH);
String signatureRaw = requiredHeader(req, HEADER_SIGNATURE);
long timeMs;
long declaredLength;
try {
timeMs = Long.parseLong(timeRaw);
declaredLength = Long.parseLong(lengthRaw);
} catch (NumberFormatException badNumber) {
throw new AuthFailure(HttpServletResponse.SC_BAD_REQUEST, "BAD_UPLOAD_HEADERS", "Некорректные числовые заголовки загрузки");
}
if (declaredLength < 16L) {
throw new AuthFailure(HttpServletResponse.SC_BAD_REQUEST, "BAD_UPLOAD_LENGTH", "Зашифрованный файл слишком мал");
}
long nowMs = System.currentTimeMillis();
if (timeMs < nowMs - MAX_AUTH_SKEW_MS || timeMs > nowMs + MAX_AUTH_SKEW_MS) {
throw new AuthFailure(HttpServletResponse.SC_UNAUTHORIZED, "UPLOAD_AUTH_EXPIRED", "Подпись загрузки устарела");
}
ActiveSessionEntry session;
try {
session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId);
} catch (SQLException dbError) {
throw new AuthFailure(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "SESSION_LOOKUP_FAILED", "Не удалось проверить пользовательскую сессию");
}
if (session == null || session.getSessionKey() == null || session.getSessionKey().isBlank()) {
throw new AuthFailure(HttpServletResponse.SC_UNAUTHORIZED, "SESSION_NOT_FOUND", "Активная сессия не найдена");
}
byte[] signature;
byte[] publicKey;
try {
signature = Base64Ws.decodeLen(signatureRaw, Ed25519Util.SIGNATURE_LEN, "signature");
publicKey = AuthKeyUtils.parseEd25519PublicKey(session.getSessionKey(), "sessionKey");
} catch (RuntimeException badKey) {
throw new AuthFailure(HttpServletResponse.SC_UNAUTHORIZED, "BAD_UPLOAD_SIGNATURE", "Некорректная подпись загрузки");
}
String preimage = uploadPreimage(sessionId, fileId, declaredLength, timeMs);
if (!Ed25519Util.verify(preimage.getBytes(StandardCharsets.UTF_8), signature, publicKey)) {
throw new AuthFailure(HttpServletResponse.SC_UNAUTHORIZED, "BAD_UPLOAD_SIGNATURE", "Подпись загрузки не прошла проверку");
}
return new UploadAuth(declaredLength);
}
private static String uploadPreimage(String sessionId, String fileId, long encryptedSize, long timeMs) {
return "DM_FILE_UPLOAD_V1:" + sessionId + ':' + fileId + ':' + encryptedSize + ':' + timeMs;
}
private String extractFileId(HttpServletRequest req) {
String pathInfo = String.valueOf(req.getPathInfo() == null ? "" : req.getPathInfo()).trim();
if (!pathInfo.startsWith("/") || pathInfo.indexOf('/', 1) >= 0) return null;
String fileId = pathInfo.substring(1);
return FILE_ID_PATTERN.matcher(fileId).matches() ? fileId : null;
}
private Path safeTarget(String fileId) {
if (fileId == null || !FILE_ID_PATTERN.matcher(fileId).matches()) return null;
Path target = storageDir.resolve(fileId).normalize();
return target.getParent() != null && target.getParent().equals(storageDir) ? target : null;
}
private static String requiredHeader(HttpServletRequest req, String name) throws AuthFailure {
String value = req.getHeader(name);
if (value == null || value.isBlank()) {
throw new AuthFailure(HttpServletResponse.SC_UNAUTHORIZED, "MISSING_UPLOAD_AUTH", "Не хватает заголовка " + name);
}
return value.trim();
}
private static MessageDigest sha256Digest() throws IOException {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException impossible) {
throw new IOException("SHA-256 недоступен", impossible);
}
}
static String toBase58(byte[] bytes) {
if (bytes == null || bytes.length == 0) return "";
BigInteger value = new BigInteger(1, bytes);
StringBuilder result = new StringBuilder();
BigInteger base = BigInteger.valueOf(58L);
while (value.signum() > 0) {
BigInteger[] divRem = value.divideAndRemainder(base);
result.append(BASE58_ALPHABET.charAt(divRem[1].intValue()));
value = divRem[0];
}
for (byte b : bytes) {
if (b != 0) break;
result.append('1');
}
return result.reverse().toString();
}
private static boolean readBoolean(AppConfig config, String key, boolean fallback) {
String value = config.getParam(key);
if (value == null || value.isBlank()) return fallback;
return Boolean.parseBoolean(value.trim());
}
private static long readLong(AppConfig config, String key, long fallback) {
String value = config.getParam(key);
if (value == null || value.isBlank()) return fallback;
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException ignored) {
return fallback;
}
}
private static String readString(AppConfig config, String key, String fallback) {
String value = config.getParam(key);
return value == null || value.isBlank() ? fallback : value.trim();
}
private static void applyCors(HttpServletResponse resp) {
resp.setHeader("Access-Control-Allow-Origin", "*");
resp.setHeader("Access-Control-Allow-Methods", "GET, HEAD, PUT, OPTIONS");
resp.setHeader("Access-Control-Allow-Headers",
"Content-Type, X-Shine-Session-Id, X-Shine-Time-Ms, X-Shine-Content-Length, X-Shine-Signature");
resp.setHeader("Access-Control-Expose-Headers", "Content-Length, ETag");
resp.setHeader("Vary", "Origin");
}
private static void writeUploadOk(HttpServletResponse resp, String fileId, long size, boolean alreadyExists) throws IOException {
ObjectNode payload = MAPPER.createObjectNode();
payload.put("ok", true);
payload.put("fileId", fileId);
payload.put("size", size);
payload.put("alreadyExists", alreadyExists);
writeJson(resp, HttpServletResponse.SC_OK, payload);
}
private static void writeError(HttpServletResponse resp, int status, String code, String message) throws IOException {
ObjectNode payload = MAPPER.createObjectNode();
payload.put("ok", false);
payload.put("error", code);
payload.put("message", message);
writeJson(resp, status, payload);
}
private static void writeJson(HttpServletResponse resp, int status, ObjectNode payload) throws IOException {
resp.setStatus(status);
resp.setCharacterEncoding(StandardCharsets.UTF_8.name());
resp.setContentType("application/json;charset=UTF-8");
MAPPER.writeValue(resp.getOutputStream(), payload);
}
private record UploadAuth(long declaredLength) {}
private static final class AuthFailure extends Exception {
private final int httpStatus;
private final String code;
private AuthFailure(int httpStatus, String code, String message) {
super(message);
this.httpStatus = httpStatus;
this.code = code;
}
}
}
@@ -5,7 +5,10 @@ import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
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;
import server.sync.PeriodicBlockchainSyncService;
import server.sync.PeriodicDmDeliveryService;
@@ -81,6 +84,11 @@ public final class WsServer {
// ============================================================
PeriodicBlockchainSyncService.startOrLog();
// Опциональная серверная публикация больших SHINE-ARCHIVE блоков.
ArchivePublisherScheduler.startOrLog();
// Импорт больших архивов только от publisher-ов из archive.import.allowedPublishers.
ArchiveImportScheduler.startOrLog();
// ============================================================
// 2) Запуск Jetty WS
// ============================================================
@@ -93,6 +101,9 @@ public final class WsServer {
// HTTP debug API
DebugApiConfigurator.register(context);
// Зашифрованные вложения личных сообщений: PUT/GET /dm-files/{sha256-base58}
DmFileApiConfigurator.register(context);
// Инициализация контейнера WebSocket
JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> {
// Таймаут простоя соединения (Jetty 11 синтаксис)
@@ -106,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(),
() -> { ServerConnectionPool.getInstance().close(); ArchivePublisherScheduler.close(); ArchiveImportScheduler.close(); },
"server-connection-pool-shutdown"));
PeriodicDmDeliveryService.startOrLog();
server.join();
@@ -14,6 +14,34 @@ solana.users.sync.dbUser=
solana.users.sync.dbPassword=
solana.users.sync.pollIntervalSeconds=300
# ------------------------------------------------------------
# SHiNE archive publisher (Arweave + User PDA archive head)
# По умолчанию выключен: большинство серверов только читают/синхронизируют блоки.
# При включении готовые большие файлы сначала сохраняются локально в data/archive,
# затем загружаются в Arweave и только после подтверждения фиксируются в Solana.
# ------------------------------------------------------------
archive.publish.enabled=false
archive.publish.time=00:00
# Пусто = системная timezone сервера. Можно задать, например Europe/Warsaw.
archive.publish.zoneId=
archive.maxFileBytes=4000000000
archive.workDir=data/archive
archive.arweave.gateway=https://arweave.net
archive.arweave.walletJwkPath=
archive.arweave.minConfirmations=1
archive.arweave.confirmPollSeconds=30
archive.arweave.confirmTimeoutMinutes=180
# Для записи archive head используется уже существующий Solana RPC:
# solana.users.sync.rpcUrl (если задан), иначе solana.rpcUrl.
# Отдельного archive.solana.rpcUrl нет.
archive.solana.rootKeyPath=
archive.solana.clientKeyPath=
archive.solana.confirmPollSeconds=5
archive.solana.confirmTimeoutMinutes=30
archive.solana.commitment=finalized
# ------------------------------------------------------------
# Межсерверная синхронизация: как создавать локальную запись пользователя,
# если во время sync пришла чужая цепочка, а у нас такого login ещё нет.
@@ -53,6 +81,18 @@ server.ui.indexPath=/home/player/SHiNE/shine-UI/index.html
server.ui.buildHash=
# Web Push (VAPID)
# ================================
# DM encrypted file storage
# ================================
# На диске лежит только AES-GCM ciphertext. Имя файла = Base58(SHA-256(ciphertext)).
dm.files.enabled=true
dm.files.storageDir=data/dm-files
# Лимит одного immutable ciphertext-объекта, НЕ всего файла.
# DM file v2 режет файл на 1 MiB части, поэтому общий размер файла этим параметром не ограничен.
# Значение 50 MiB сохранено для совместимости с DM file v1.
dm.files.maxBytes=52428816
webpush.vapid.public=BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI
webpush.vapid.private=3hCt7XxTvLzuoxinjT5QcKRQEBnGZHXn8ZilU31RPNE
webpush.vapid.subject=mailto:admin@shine.local
@@ -115,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
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.12.12
server.version=1.10.4
client.version=1.12.15
server.version=1.10.6
+1
View File
@@ -84,6 +84,7 @@ dependencies {
implementation project(':shine-server-net-protocol') // Модуль отвечающий за протокол (классы Net..Request/Response
implementation project(':shine-server-net-server') // Хэндлеры для обработки сетевых запросов
implementation project(':shine-server-solana-users-sync')
implementation project(':shine-server-archive')
+12 -2
View File
@@ -13,7 +13,13 @@ set -Eeuo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
OUT="${1:-SHiNE-bundle-$(date +%Y%m%d-%H%M%S).zip}"
DEFAULT_OUT=0
if [[ $# -gt 0 ]]; then
OUT="$1"
else
DEFAULT_OUT=1
OUT="SHiNE-bundle-$(date +%Y.%m.%d-%H.%M.%S).zip"
fi
case "$OUT" in
/*) ;;
*) OUT="$ROOT/$OUT" ;;
@@ -169,7 +175,11 @@ if [[ ! -s "$SAFE_LIST" ]]; then
exit 3
fi
rm -f -- "$OUT"
if (( DEFAULT_OUT == 1 )); then
rm -f -- "$ROOT"/SHiNE-bundle-*.zip
else
rm -f -- "$OUT"
fi
(
cd "$ROOT"
+6
View File
@@ -92,6 +92,12 @@ t2.shineup.me {
reverse_proxy 127.0.0.1:7102
}
# Зашифрованные вложения DM. Этот route должен идти в Java, а не в SPA.
@dmfiles path /dm-files /dm-files/*
handle @dmfiles {
reverse_proxy 127.0.0.1:7102
}
handle {
root * /home/player/t2/UI
try_files {path} /index.html
@@ -10,6 +10,11 @@ shineup.me {
reverse_proxy 127.0.0.1:7070
}
@dmfiles path /dm-files /dm-files/*
handle @dmfiles {
reverse_proxy 127.0.0.1:7070
}
handle {
root * /home/player/SHiNE/shine-ui
try_files {path} /index.html
@@ -31,6 +36,11 @@ shineup.me {
reverse_proxy 127.0.0.1:7070
}
@dmfiles path /dm-files /dm-files/*
handle @dmfiles {
reverse_proxy 127.0.0.1:7070
}
handle {
root * /home/player/SHiNE/shine-ui
try_files {path} /index.html
+7 -2
View File
@@ -9,6 +9,7 @@ TARGET_DOMAIN="${TARGET_DOMAIN:?TARGET_DOMAIN is required, example: shineup.me}"
REMOTE_SERVER_DIR="${REMOTE_SERVER_DIR:?REMOTE_SERVER_DIR is required}"
REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}"
REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}"
REMOTE_DM_FILES_DIR="${REMOTE_DM_FILES_DIR:-}"
REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:?REMOTE_SERVICE_NAME is required}"
SERVER_PORT="${SERVER_PORT:?SERVER_PORT is required}"
LOCAL_JAR="${LOCAL_JAR:-$ROOT_DIR/SHiNE-server/build/libs/shine-server.jar}"
@@ -54,7 +55,7 @@ Type=simple
User=player
Group=player
WorkingDirectory=$REMOTE_SERVER_DIR
ExecStart=/usr/bin/java -Dserver.port=$SERVER_PORT -jar $REMOTE_SERVER_DIR/shine-server.jar
ExecStart=/usr/bin/java -Dserver.port=$SERVER_PORT${REMOTE_DM_FILES_DIR:+ -Ddm.files.storageDir=$REMOTE_DM_FILES_DIR} -jar $REMOTE_SERVER_DIR/shine-server.jar
Restart=always
RestartSec=3
@@ -62,7 +63,11 @@ RestartSec=3
WantedBy=multi-user.target
EOF
ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_SERVER_DIR' '$REMOTE_DATA_DIR' '$REMOTE_LOGS_DIR'"
if [[ -n "$REMOTE_DM_FILES_DIR" ]]; then
ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_SERVER_DIR' '$REMOTE_DATA_DIR' '$REMOTE_LOGS_DIR' '$REMOTE_DM_FILES_DIR'"
else
ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_SERVER_DIR' '$REMOTE_DATA_DIR' '$REMOTE_LOGS_DIR'"
fi
rsync -az --timeout=120 "$LOCAL_JAR" "$REMOTE_HOST:$REMOTE_SERVER_DIR/shine-server.jar"
rsync -az "$TMP_DIR/$REMOTE_SERVICE_NAME.service" "$REMOTE_HOST:/tmp/$REMOTE_SERVICE_NAME.service"
+1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
REMOTE_HOST="player@t1.shineup.me" \
TARGET_DOMAIN="t1.shineup.me" \
REMOTE_SERVER_DIR="/home/player/t1/server" \
REMOTE_DM_FILES_DIR="/home/player/test-dm-files" \
REMOTE_SERVICE_NAME="shine-t1" \
SERVER_PORT="7101" \
bash "$(dirname "$0")/deploy_server.sh"
+1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
REMOTE_HOST="player@t2.shineup.me" \
TARGET_DOMAIN="t2.shineup.me" \
REMOTE_SERVER_DIR="/home/player/t2/server" \
REMOTE_DM_FILES_DIR="/home/player/test-dm-files" \
REMOTE_SERVICE_NAME="shine-t2" \
SERVER_PORT="7102" \
bash "$(dirname "$0")/deploy_server.sh"
+1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
REMOTE_HOST="player@t3.shineup.me" \
TARGET_DOMAIN="t3.shineup.me" \
REMOTE_SERVER_DIR="/home/player/t3/server" \
REMOTE_DM_FILES_DIR="/home/player/test-dm-files" \
REMOTE_SERVICE_NAME="shine-t3" \
SERVER_PORT="7103" \
bash "$(dirname "$0")/deploy_server.sh"
+1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
REMOTE_HOST="player@t4.shineup.me" \
TARGET_DOMAIN="t4.shineup.me" \
REMOTE_SERVER_DIR="/home/player/t4/server" \
REMOTE_DM_FILES_DIR="/home/player/test-dm-files" \
REMOTE_SERVICE_NAME="shine-t4" \
SERVER_PORT="7104" \
bash "$(dirname "$0")/deploy_server.sh"
+1 -3
View File
@@ -14,8 +14,6 @@
| --- | --- | --- |
| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна |
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
| `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара |
| `TestUploadFreeAvatar` | `14_Test_Free_Avatar_Upload_API.md` | временная тестовая бесплатная загрузка маленького аватара в Arweave |
| `ResolveLoginForAuth` | `02_Authentication_API.md` | проверка login перед входом: LOCAL / REMOTE / NOT_FOUND / NO_ACCESS_SERVER + URL правильного access server |
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
@@ -82,6 +80,6 @@
входящей копии на единственный access-сервер получателя.
- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее.
- `ServerHello` пока принимает заявленный `serverLogin` на доверии и не является криптографической аутентификацией.
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
- HTTP endpoints зашифрованных DM-файлов (`PUT/GET/HEAD/OPTIONS /dm-files/{fileId}`) не являются WebSocket `op` и описаны отдельно в `18_DM_File_Storage_API.md`.
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
+113
View File
@@ -0,0 +1,113 @@
# HTTP API зашифрованных файлов личных сообщений
## 1. Назначение
Файлы личных сообщений хранятся только на access-сервере отправителя и только в зашифрованном виде.
HTTP API не получает исходное имя файла, MIME-тип или ключ расшифрования.
Клиент:
1. генерирует случайный AES-256-GCM key и 96-bit IV;
2. шифрует файл в браузере;
3. считает `SHA-256(ciphertext)`;
4. переводит 32-byte hash в Base58 — это `fileId` и имя файла на диске сервера;
5. загружает ciphertext на свой access-сервер;
6. помещает URL, key, IV, исходное имя/MIME/размер в `<S:file...>` внутри plaintext DM;
7. обычный механизм E2EE DM шифрует эту техническую вставку отдельно для отправителя и получателя.
Следствие: сервер файлов не знает AES-key и не может расшифровать вложение.
## 2. Конфигурация сервера
```properties
dm.files.enabled=true
dm.files.storageDir=data/dm-files
dm.files.maxBytes=52428816
```
`dm.files.maxBytes` ограничивает размер одного ciphertext-объекта. Для legacy DM file v1 это фактически ограничивало целый файл. В DM file v2 файл состоит из 1-MiB ciphertext-объектов, поэтому общий размер логического файла этим параметром не ограничивается.
## 3. PUT `/dm-files/{fileId}`
Загружает immutable ciphertext.
`fileId` обязан быть равен:
```text
Base58(SHA-256(ciphertext))
```
### Заголовки авторизации
```text
Content-Type: application/octet-stream
X-Shine-Session-Id: <active session id>
X-Shine-Time-Ms: <unix time ms>
X-Shine-Content-Length: <ciphertext bytes>
X-Shine-Signature: <Ed25519 signature, Base64>
```
Подписываемая UTF-8 строка:
```text
DM_FILE_UPLOAD_V1:{sessionId}:{fileId}:{encryptedSize}:{timeMs}
```
Подпись проверяется публичным `session_key` из активной сессии. Допустимое отклонение времени — 60 секунд.
Сервер потоково пишет временный файл, одновременно считает SHA-256, проверяет фактический размер и только после успешной проверки атомарно перемещает объект под именем `{fileId}`.
Повторная корректно подписанная загрузка уже существующего immutable объекта идемпотентна. Перед ответом `alreadyExists=true` сервер повторно вычисляет `SHA-256` уже сохранённого объекта и сверяет его с `fileId`; одного совпадения имени файла на диске недостаточно. Если объект повреждён или подменён, сервер удаляет некорректную копию и принимает корректный `PUT` заново.
Успешный ответ:
```json
{
"ok": true,
"fileId": "...",
"size": 12345,
"alreadyExists": false
}
```
Основные ошибки: `BAD_FILE_ID`, `MISSING_UPLOAD_AUTH`, `UPLOAD_AUTH_EXPIRED`, `BAD_UPLOAD_SIGNATURE`, `FILE_TOO_LARGE`, `LENGTH_MISMATCH`, `HASH_MISMATCH`.
## 4. GET `/dm-files/{fileId}`
Возвращает только сохранённый ciphertext как `application/octet-stream`.
GET не требует пользовательской сессии: `fileId` является непредсказуемым 256-bit content address, а без AES-key содержимое остаётся зашифрованным. AES-key передаётся только внутри E2EE DM.
Ответ разрешает cross-origin чтение (`Access-Control-Allow-Origin: *`), потому что получатель может быть подключён к другому access-серверу и должен скачать ciphertext непосредственно с сервера отправителя.
Перед расшифрованием официальный UI повторно проверяет `Base58(SHA-256(downloadedCiphertext)) == fileId`.
## 5. HEAD и OPTIONS
- `HEAD /dm-files/{fileId}` возвращает метаданные ciphertext без тела только после повторной проверки `Base58(SHA-256(ciphertext)) == fileId`; официальный UI использует этот запрос перед `PUT`, чтобы не отправлять уже существующие байты повторно;
- `OPTIONS /dm-files/*` обслуживает CORS preflight для браузерного PUT.
## 6. Reverse proxy
Caddy/Nginx должен проксировать `/dm-files/*` в тот же Jetty, что обслуживает `/ws`. Route должен находиться до SPA fallback.
## 7. Ограничения legacy v1
- legacy-файл перед загрузкой целиком читается в память браузера и ограничен 50 MiB;
- новые отправки официального UI используют v2 и этого ограничения не имеют;
- удаления/TTL/garbage collection пока нет;
- если ciphertext уже загружен, а отправка E2EE DM затем не удалась, объект может остаться orphan-файлом;
- resumable/chunked upload не входит в v1.
## 8. DM file v2: большие файлы
Начиная с UI v2 один логический файл не загружается одним HTTP-объектом. Клиент режет plaintext на `1 MiB` части и каждый AES-GCM ciphertext-кусок загружает отдельным обычным `PUT /dm-files/{fileId}`.
Поэтому `dm.files.maxBytes` — это лимит **одного immutable HTTP-объекта**, а не всего пользовательского файла. При стандартном `chunkSize=1 MiB` общий размер файла этим параметром не ограничивается.
Манифест также хранится через тот же immutable API:
- encrypted manifest page — до 256 chunk descriptors;
- encrypted root manifest — ссылки на страницы + BitTorrent v2 root metadata.
Никаких новых доверенных серверных операций для v2 не требуется: сервер по-прежнему только проверяет подпись PUT, длину и `Base58(SHA-256(ciphertext))`.
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
# Карта реализации SHiNE Archive Publisher v1.0
Этот документ связывает протокол с конкретными файлами проекта. Он нужен, чтобы другой агент мог быстро понять, где искать каждую часть реализации.
## 1. Новый Java-модуль `shine-server-archive`
Путь: `SHiNE-server/shine-server-archive/`.
Основные классы:
- `ArchivePublisherScheduler` — включает publisher только при `archive.publish.enabled=true`, сразу продолжает незавершённый job после рестарта и планирует новый snapshot один раз в сутки в `archive.publish.time`. Следующая дата вычисляется по `ZoneId`, а не как `+24h`, поэтому DST не сдвигает локальную полночь.
- `ArchivePublisherService` — state machine: snapshot → локальный файл → Arweave → confirmations → User PDA → Solana finalized → cursor commit.
- `ArchivePublisherConfig` — читает настройки. Для Solana RPC отдельного archive URL нет: используется `solana.users.sync.rpcUrl`, затем fallback на `solana.rpcUrl`.
- `ShineArchiveWriter` — сериализует бинарный big block `SHINE-ARCHIVE v1.0`, FULL reference table, по одному chunk на `blockchain_name`, footer/hash/signature.
- `ArchiveFileNames` — временное и финальное локальные имена.
- `ArweaveArchiveService` + `ArweaveMerkle` — Arweave v2 transaction + chunk upload + polling confirmations.
- `SolanaArchiveHeadWriter` — обычный `update_user_pda`: root signature + текущая last-block signature + client key fee payer, затем ожидание `finalized`.
- `ArchiveKeyLoader` — читает Ed25519 seed/keypair из raw 32/64 bytes, Solana JSON 32/64 или Base64/PKCS8.
## 2. Локальное состояние PostgreSQL
Миграция: `SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql`.
Таблицы:
### `archive_chain_cursor`
Одна строка на `blockchain_name`. Хранит только **последнее окончательно опубликованное** состояние:
- последний source block number/hash;
- big block number/hash, где находится последний chunk;
- offset/size последнего chunk.
Если blockchain не попала в новый big block, эта строка не меняется.
### `archive_publish_job`
Crash-safe state одной большой публикации и путь к локальному файлу.
Основные состояния:
`SNAPSHOT_CREATED → FILE_BUILT → ARWEAVE_UPLOADED → ARWEAVE_CONFIRMED → SOLANA_SUBMITTED → SOLANA_FINALIZED → CURSORS_COMMITTED`.
### `archive_publish_job_chain`
Frozen range каждой blockchain текущего job + старые и новые координаты chunk. Пока job не finalized, `archive_chain_cursor` не двигается.
`DatabaseInitializer` автоматически применяет migration v22 при старте существующей БД. Новая БД создаётся уже со схемой v22.
## 3. Как считается первая дельта
`ArchivePublisherService.createFrozenJob()` проходит по `BlockchainStateDAO.listAll()`.
- Если cursor для `blockchain_name` отсутствует: `from = 0`, поэтому первый архив содержит всё локально известное состояние `0..head`.
- Если cursor существует: `from = last_archived + 1`.
- Перед продолжением проверяется hash cursor-блока.
Папка `data/archive` сама по себе **не является источником истины** о том, был ли первый архив. Источник истины — БД cursor/job. Поэтому удаление локального файла не приводит к ошибочной повторной полной публикации.
## 4. Локальный lifecycle файла
До появления Arweave TX ID:
`<login>.<00001>.<dd.MM.yy>.tmp.SHiNE-archive`
После полной успешной загрузки transaction header + chunks в Arweave:
`<login>.<00001>.<dd.MM.yy>.<ARWEAVE_TX_ID>.SHiNE-archive`
Дата — реальная дата freeze snapshot в timezone archive publisher-а. Номер начинается с `00001`. Пять цифр — минимальная ширина, а не лимит.
После rename файл остаётся локально. При crash после сохранения TX ID, но до rename, recovery переименует тот же файл и не загрузит его повторно.
## 5. User PDA block type `100`
Содержимое:
```text
u8 block_type = 100
u8 block_version = 0
bytes[32] archive_tx_id
bytes[32] archive_hash
```
Используется существующий `update_user_pda`; отдельной instruction нет.
Совместимость:
- legacy update без archive extension должен сохранить старый archive head;
- новый update может заменить/очистить block `100`;
- Java/JS codecs и PostgreSQL Solana sync умеют читать новый блок.
Ключевой Rust-файл: `shine-solana/shine/programs/shine_users/src/lib.rs`.
## 6. Startup сервера
`WsServer` после текущего Solana/users sync и inter-server blockchain sync вызывает `ArchivePublisherScheduler.startOrLog()`.
При `archive.publish.enabled=false` scheduler пишет лог о выключенной функции и больше ничего не делает. Ключи/Arweave wallet на обычном сервере тогда не требуются.
## 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-а.
+466
View File
@@ -0,0 +1,466 @@
# Деплой SHiNE Archive Publisher v1.0 на тестовый сервер
Документ рассчитан на человека или автономного coding/deploy агента. Выполнять шаги по порядку. Не включать publisher до проверки Solana-программы и ключей.
## 0. Что именно меняется
Нужны изменения одновременно в:
1. серверном Java-коде;
2. PostgreSQL schema v23;
3. Solana-программе `shine_users` (PDA block type `100` + backward-compatible update parser);
4. Java/JS User PDA codecs.
**Критично:** новый серверный writer отправляет расширенный обычный `update_user_pda`. Если в целевом кластере работает старая `shine_users`, включать publisher нельзя.
---
# 1. Применить пакет к исходникам
ZIP из этой поставки содержит только новые/изменённые файлы с путями относительно корня репозитория.
Сделать backup текущего проекта, затем распаковать ZIP поверх рабочего дерева.
После распаковки удалить legacy-файлы из списка `05_PATCH_CONTENTS_AND_REMOVALS.md`.
Проверить:
```bash
git status --short
```
или, если это не git checkout, сравнить список файлов с manifest из той же документации.
---
# 2. Обязательно обновить `shine_users` в нужном Solana-кластере
## 2.1. Проверить целевой кластер
Не деплоить вслепую. Сначала:
```bash
solana config get
```
и проверить RPC/кластер, upgrade authority и Program ID. В проекте `shine_users` использует Program ID:
```text
SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6
```
Если тестовый сервер использует mainnet RPC, обновляется именно mainnet-программа. Если тестовый контур использует devnet — сначала убедиться, что программа с нужным ID действительно существует в devnet.
## 2.2. Собрать Solana program
```bash
cd shine-solana/shine
anchor build
```
Минимальная host-проверка Rust, если Anchor/SBF toolchain временно недоступен:
```bash
cargo build -p shine_users
```
Но для реального deploy нужен SBF/Anchor build.
## 2.3. Обновить существующую программу
Использовать существующий project deploy/upgrade authority. Типовой вариант:
```bash
solana program deploy target/deploy/shine_users.so \
--program-id target/deploy/shine_users-keypair.json \
--upgrade-authority /PATH/TO/UPGRADE_AUTHORITY.json \
--url <TARGET_RPC_URL>
```
Если в проекте используется рабочий Anchor deploy workflow, допустимо использовать его вместо прямого `solana program deploy`; главное — сохранить тот же Program ID.
После обновления:
```bash
solana program show SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6 --url <TARGET_RPC_URL>
```
---
# 3. Собрать серверный JAR
Из корня репозитория:
```bash
./gradlew clean shadowJar
```
Ожидаемый файл:
```text
SHiNE-server/build/libs/shine-server.jar
```
Если Gradle wrapper не может скачать зависимости, сборку выполнять на машине/CI с доступом к Maven/Gradle или с уже заполненным cache.
---
# 4. Подготовить PostgreSQL backup
Перед первым стартом версии со schema v23 сделать backup тестовой БД. Например:
```bash
pg_dump -Fc -d '<DATABASE_URL_OR_NAME>' -f shine-before-archive-v22.dump
```
Точная команда зависит от текущей схемы доступа PostgreSQL.
При старте сервер последовательно применит `migration_v22.sql` и `migration_v23.sql`, если это требуется текущей версии БД. Вручную migrations выполнять обычно не нужно.
После старта проверить:
```sql
SELECT * FROM db_schema_version WHERE id=1;
```
Ожидается:
```text
schema_version = 23
```
И наличие:
```sql
SELECT to_regclass('public.archive_chain_cursor');
SELECT to_regclass('public.archive_publish_job');
SELECT to_regclass('public.archive_publish_job_chain');
```
---
# 5. Подготовить секреты на тестовом сервере
Пример:
```bash
sudo -u player mkdir -p /home/player/SHiNE/secrets
sudo chmod 700 /home/player/SHiNE/secrets
```
Положить:
```text
/home/player/SHiNE/secrets/archive-arweave-wallet.json
/home/player/SHiNE/secrets/server-root.key
/home/player/SHiNE/secrets/server-client.key
```
Права:
```bash
sudo chown player:player /home/player/SHiNE/secrets/*
sudo chmod 600 /home/player/SHiNE/secrets/*
```
### Форматы root/client key
Поддерживаются:
- raw seed 32 bytes;
- raw keypair 64 bytes;
- Solana JSON array на 32/64 байта;
- Base58 seed на 32 байта или Solana secret key на 64 байта;
- Base64 raw/PKCS8, где seed извлекается из последних 32 bytes.
### Arweave wallet
Ожидается RSA JWK с полями `n,e,d,p,q,dp,dq,qi`. Кошелёк должен иметь достаточно AR для размера первого полного архива.
---
# 6. Настроить внешний `application.properties`
Сервер читает внешний `application.properties` из **WorkingDirectory процесса** и накладывает его поверх встроенного конфига. Сохранять существующие DB/Solana/server параметры и добавить archive-секцию.
Минимум:
```properties
archive.publish.enabled=true
archive.publish.time=00:00
archive.publish.zoneId=Europe/Warsaw
archive.workDir=data/archive
archive.maxFileBytes=4000000000
archive.arweave.gateway=https://arweave.net
archive.arweave.walletJwkPath=/home/player/SHiNE/secrets/archive-arweave-wallet.json
archive.arweave.minConfirmations=1
archive.arweave.confirmPollSeconds=30
archive.arweave.confirmTimeoutMinutes=180
archive.solana.rootKeyPath=/home/player/SHiNE/secrets/server-root.key
archive.solana.clientKeyPath=/home/player/SHiNE/secrets/server-client.key
archive.solana.confirmPollSeconds=5
archive.solana.confirmTimeoutMinutes=30
archive.solana.commitment=finalized
```
`archive.publish.zoneId` выбрать осознанно. Если оставить пустым, используется timezone JVM/машины. Для ежедневного запуска ровно в нужную локальную полночь лучше задать ZoneId явно.
### Solana RPC
**Отдельного archive RPC нет.** Writer использует:
1. `solana.users.sync.rpcUrl`, если он задан;
2. иначе `solana.rpcUrl`.
Поэтому существующий рабочий RPC не дублировать в archive settings.
---
# 7. Убедиться, что server login соответствует ключам
`server.SHiNE.login` должен быть тем User PDA, чей archive head будет обновляться.
На startup `SolanaArchiveHeadWriter` проверяет:
```text
derive(root private) == UserPDA.root_key
derive(client private) == UserPDA.client_key
```
При несовпадении archive publisher не стартует.
---
# 8. Развернуть JAR
Можно использовать существующий `deploy/scripts/deploy_server.sh`. Он:
- собирает `shadowJar`;
- копирует JAR;
- создаёт/обновляет systemd unit;
- перезапускает сервис.
Типовой запуск задаётся уже существующими переменными проекта. Либо вручную заменить `shine-server.jar` в рабочей директории и перезапустить systemd service.
После deploy убедиться, что WorkingDirectory содержит внешний `application.properties`.
---
# 9. Первый startup
Смотреть лог:
```bash
sudo journalctl -u <SERVICE_NAME> -f
```
Ожидаемые события:
1. DB migration до v22;
2. обычный Solana users sync;
3. обычный server-to-server blockchain sync;
4. archive publisher preflight;
5. строка примерно:
```text
Archive publisher включён: login=... dir=data/archive dailyAt=00:00 zone=...
Следующая архивная публикация запланирована на ...
```
Если остался незавершённый job, он будет продолжен **сразу после старта**, не ожидая полуночи. Новый snapshot создаётся только по расписанию.
---
# 10. Что произойдёт в первую полночь
Если `archive_chain_cursor` пуст:
- сервер проходит все локально известные `blockchain_name`;
- для каждой берёт range `0..current_head`;
- создаёт первый big block `#1`;
- для каждой blockchain создаёт максимум один `UserBlockchainChunk`;
- внутри chunk лежат все её raw records из frozen range;
- backlink первого chunk пустой (`previous_big_block_ref = 0xFFFFFFFF`);
- создаёт локальный файл, например:
```text
data/archive/archive01.00001.12.09.26.tmp.SHiNE-archive
```
- загружает его в Arweave;
- после успешной загрузки переименовывает тот же файл, например:
```text
data/archive/archive01.00001.12.09.26.<REAL_TX_ID>.SHiNE-archive
```
- ждёт confirmations;
- обычным `update_user_pda` записывает block type `100`;
- ждёт Solana `finalized`;
- только затем commit-ит cursors.
Если новых данных нет, пустой big block не создаётся.
---
# 11. Проверка результата
## Локальные файлы
```bash
ls -lah data/archive/
```
После успешного upload `.tmp.SHiNE-archive` для завершённого job оставаться не должен; должен быть файл с реальным TX ID в имени.
## Job DB
```sql
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
arweave_confirmations, solana_signature, error_text
FROM archive_publish_job
ORDER BY id DESC
LIMIT 10;
```
Успех:
```text
status = CURSORS_COMMITTED
```
## Cursors
```sql
SELECT blockchain_name,
last_archived_source_block_number,
last_archive_big_block_number,
last_chunk_offset,
last_chunk_size
FROM archive_chain_cursor
ORDER BY blockchain_name;
```
## Локальная проекция User PDA
После очередной Solana sync:
```sql
SELECT login, record_number, archive_head_tx_id, archive_head_hash
FROM solana_user_pda_current
WHERE login = '<SERVER_LOGIN>';
```
`archive_head_tx_id` и `archive_head_hash` должны быть непустыми.
## Arweave
TX берётся прямо из имени финального файла. Проверить:
```bash
curl -sS 'https://arweave.net/tx/<TX_ID>/status'
```
---
# 12. Быстрый тест до полуночи
Если не хочется ждать 00:00, на тестовом сервере временно установить `archive.publish.time` на ближайшие 5–10 минут в будущем в выбранной `archive.publish.zoneId`, затем перезапустить сервис.
После проверки вернуть:
```properties
archive.publish.time=00:00
```
Не использовать интервал в минутах: scheduler специально работает по календарному локальному времени один раз в сутки.
---
# 13. Откат / выключение
Самый безопасный функциональный rollback:
```properties
archive.publish.enabled=false
```
и рестарт сервера. Тогда обычная серверная работа продолжается, archive scheduler ничего не публикует.
Не удалять `archive_chain_cursor`/job таблицы без причины: они нужны, чтобы после повторного включения publisher продолжил дельту, а не загрузил всю историю заново.
Уже опубликованные Arweave данные являются постоянными и обычным rollback сервера не удаляются.
---
# 14. Наиболее вероятные ошибки
### `Root key archive publisher-а не совпадает с User PDA`
Положен неправильный root key или неверный `server.SHiNE.login`.
### `Client key archive publisher-а не совпадает с User PDA`
Неверный client key.
### `Недостаточно AR`
Пополнить Arweave wallet. Первый архив может быть существенно больше ежедневных дельт.
### Arweave upload прошёл, Solana update не прошёл
Не удалять локальный файл/job. После исправления RPC/program/key причины restart продолжит незавершённый job.
### `archive cursor hash не совпадает`
Локальная blockchain изменилась относительно уже зафиксированного cursor. Не форсировать публикацию; сначала разобраться с resync/fork.
### Старый `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
```
В приложении: `Настройки → Архив блокчейна`.
+113
View File
@@ -0,0 +1,113 @@
# Проверка и эксплуатация Archive Publisher
## Перед ночным тестом
- [ ] Новый `shine_users` уже развёрнут на том Solana-кластере, который использует сервер.
- [ ] Серверный JAR собран из этого пакета.
- [ ] БД забэкаплена.
- [ ] `archive.publish.enabled=true`.
- [ ] `archive.publish.time=00:00`.
- [ ] `archive.publish.zoneId` соответствует желаемой локальной полуночи.
- [ ] `server.SHiNE.login` соответствует root/client keys.
- [ ] Arweave JWK читается пользователем процесса.
- [ ] На Arweave wallet достаточно AR.
- [ ] `data/archive` доступна на запись.
- [ ] В логе есть `Archive publisher включён` и точное время следующего запуска.
## Во время job
Нормальная последовательность логов/статусов:
```text
SNAPSHOT_CREATED
FILE_BUILT
ARWEAVE_UPLOADED
ARWEAVE_CONFIRMED
SOLANA_SUBMITTED
SOLANA_FINALIZED
CURSORS_COMMITTED
```
После `FILE_BUILT` существует `.tmp.SHiNE-archive`.
После полного Arweave upload имя уже содержит настоящий TX ID.
## После успешного первого job
Проверить:
```bash
find data/archive -maxdepth 1 -type f -name '*.SHiNE-archive' -ls
```
```sql
SELECT big_block_number, status, local_archive_path, arweave_confirmations
FROM archive_publish_job ORDER BY id DESC LIMIT 1;
```
```sql
SELECT count(*) AS archived_blockchains FROM archive_chain_cursor;
```
```sql
SELECT login, archive_head_tx_id, archive_head_hash
FROM solana_user_pda_current
WHERE login='<SERVER_LOGIN>';
```
## Проверка второй публикации
До следующей полуночи добавить несколько новых SHiNE records только в часть blockchain. После следующего job:
- в новый big block должны попасть только изменившиеся blockchain;
- одна blockchain в новом big block должна иметь один chunk независимо от числа новых records;
- cursor blockchain, которая не изменилась, должен остаться на старом big block/chunk;
- backlink изменившегося chunk должен указывать на предыдущий chunk этой же blockchain;
- FULL reference table нового big block должна содержать все предыдущие finalized big blocks.
## Crash/restart сценарии
### Restart после FILE_BUILT
Должен использоваться тот же frozen job и тот же локальный файл.
### Restart после ARWEAVE_UPLOADED
Не должно быть повторной оплаты/upload. Если TX сохранён, но rename не успел произойти, recovery переименует `.tmp` в имя с TX ID.
### Restart после SOLANA_FINALIZED
При совпадении PDA head с job сервер должен только commit cursors.
## Обычный сервер без публикации
Проверить отдельно:
```properties
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.
@@ -0,0 +1,38 @@
# Состав текущего patch-пакета
Этот этап рассчитан **поверх последнего рабочего ZIP**, присланного после успешного запуска 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.
## Удаления
В **этом** обновлении удалять файлы не требуется.
Старые test/free-avatar исходники, если они всё ещё физически присутствуют в рабочем дереве, этим patch-пакетом не затрагиваются. Они не относятся к trusted archive importer и не должны удаляться автоматически при наложении этого обновления.
## Как накладывать ZIP changed-files
ZIP содержит только новые/изменённые файлы с путями от корня репозитория.
Распаковать поверх той рабочей версии, из которой сделан пакет, с заменой совпадающих файлов.
После наложения:
```bash
./gradlew shadowJar
```
Если Gradle wrapper ещё не установлен локально, сначала обеспечить доступ к уже используемой версии Gradle/кэшу.
При старте сервер сам должен поднять schema с v22 до v23.
+50
View File
@@ -0,0 +1,50 @@
# Manifest changed/new files
Основа сравнения: последний присланный рабочий ZIP `3d14e34c-4249-4e11-8042-3f3349c8e9fd.zip`.
- Изменённых файлов: 19
- Новых файлов: 14
- Удаляемых файлов: 0
## Изменённые файлы
- `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-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`
- `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`
## Новые файлы
- `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`
## Удаления
На этом этапе файлов для удаления нет.
@@ -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;
```
+42
View File
@@ -0,0 +1,42 @@
# SHiNE Archive Publisher — документация
Эта папка — **актуальная точка входа** для механизма серверной архивации SHiNE в Arweave с фиксацией archive head в Solana User PDA.
Если задачу выполняет другая нейронка/агент, читать документы нужно в таком порядке:
1. `01_PROTOCOL_v1.0.md` — бинарный формат `SHINE-ARCHIVE`, big-block references, `UserBlockchainChunk`, подписи, PDA block `100`, crash-safety.
2. `02_IMPLEMENTATION_MAP.md` — как спецификация разложена по Java/Rust/JS/SQL файлам текущего проекта.
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. `07_ARCHIVE_IMPORT_AND_VIEWER.md` — whitelist доверенных publisher-ов, импорт archive chain, индекс последнего chunk, UI и `Blockchain-Viewer.html`.
7. `archive-publisher.example.properties` — пример конфигурации publisher + importer.
## Коротко
- Архиватор **по умолчанию выключен**: `archive.publish.enabled=false`.
- При включении создаёт новый snapshot **один раз в сутки в заданное локальное время**, по умолчанию `00:00`.
- При первом успешном запуске, когда архивных курсоров ещё нет, в первый big block попадает **всё локально известное состояние всех blockchain, начиная с source block 0**.
- Далее публикуется только дельта.
- Один `blockchain_name` в одном big block представлен максимум одним `UserBlockchainChunk`; внутри него лежат все новые raw SHiNE records этой цепочки.
- В конце chunk одна ссылка на предыдущий chunk этой же blockchain. Если blockchain в текущем big block отсутствует, её cursor/head не меняется.
- Готовый файл сначала существует локально как `<login>.<00001>.<dd.MM.yy>.tmp.SHiNE-archive`. После успешной загрузки в Arweave он переименовывается в `<login>.<00001>.<dd.MM.yy>.<REAL_ARWEAVE_TX_ID>.SHiNE-archive` и остаётся локально.
- После Arweave confirmations обычным `update_user_pda` обновляется PDA block type `100`: `archive_tx_id[32] + archive_hash[32]`.
- Cursor commit выполняется только после Solana `finalized`.
## Важно перед тестом
Изменён формат/парсер `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`.
@@ -0,0 +1,33 @@
# Минимальный пример для archive-capable сервера.
# Добавлять во внешний application.properties; существующие DB/Solana/server настройки не удалять.
archive.publish.enabled=true
archive.publish.time=00:00
archive.publish.zoneId=Europe/Warsaw
archive.workDir=data/archive
archive.maxFileBytes=4000000000
archive.arweave.gateway=https://arweave.net
archive.arweave.walletJwkPath=/home/player/SHiNE/secrets/archive-arweave-wallet.json
archive.arweave.minConfirmations=1
archive.arweave.confirmPollSeconds=30
archive.arweave.confirmTimeoutMinutes=180
# Отдельный archive Solana RPC НЕ задаётся.
# Используется solana.users.sync.rpcUrl, иначе solana.rpcUrl.
archive.solana.rootKeyPath=/home/player/SHiNE/secrets/server-root.key
archive.solana.clientKeyPath=/home/player/SHiNE/secrets/server-client.key
# Эти файлы могут содержать Base58 seed 32 bytes или Base58 Solana secret key 64 bytes.
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
+14
View File
@@ -9,6 +9,8 @@
- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` — доставка на
единственный access-сервер, retry-воркер и UI-статусы
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<S:...>` вставок внутри plaintext DM после расшифровки
- `docs/Personal_Messages/Файлы_DM_v2.md` — большие chunked-вложения, голосовые и BitTorrent v2 SHA-256/Merkle metadata
- `docs/API/18_DM_File_Storage_API.md` — HTTP-хранилище зашифрованных файлов DM на access-сервере отправителя
Исторический устаревший документ сохранён отдельно:
@@ -19,3 +21,15 @@
- код DM и оба документа `Протокол_DM_v1.md` + `Формат_DM_v1.md` всегда должны обновляться синхронно;
- если меняется поведение DM в коде, в том же наборе изменений обновляется и эта документация.
- локальная таблица пользователей для DM считается кэшем, а источником истины остаётся Solana PDA; если серверная логика DM меняет правила lazy-import пользователей из PDA, это тоже обязательно фиксируется в документации.
## Зашифрованные файлы DM
Официальный UI v1 умеет отправлять файл как внешний зашифрованный объект:
- файл шифруется в браузере `AES-256-GCM`;
- ciphertext хранится только на access-сервере отправителя под именем `Base58(SHA-256(ciphertext))`;
- AES-key, IV, исходное имя, MIME, размер и URL находятся в `<S:file...>` внутри plaintext DM, а значит сами защищены существующим E2EE DM;
- получатель скачивает ciphertext с сервера отправителя, проверяет SHA-256, расшифровывает локально и только затем отдаёт Blob браузеру для сохранения под исходным именем;
- сервер файлов ключ расшифрования не получает.
Формат бинарного контейнера `SHiNE_DM` при этом не меняется.
@@ -228,3 +228,38 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает
- при удалении чата с `friend`/`close_friend` UI должен отдельно предупредить, что одна очистка истории не уберёт строку чата, и при подтверждении снять социальную связь и очистить историю.
Это правило не меняет wire/API-формат DM и не меняет байтовый формат tombstone.
## 15. Файлы в личных сообщениях
Файл не встраивается байтами в `SHiNE_DM`. До отправки DM официальный браузерный клиент:
1. генерирует отдельные случайные `AES-256-GCM` key и IV;
2. шифрует исходный файл локально;
3. вычисляет `fileId = Base58(SHA-256(ciphertext))`;
4. подписанным HTTP `PUT` загружает ciphertext на свой текущий access-сервер;
5. отправляет обычный контентный DM `type=1/2`, plaintext которого начинается с `<S:file...>`.
Внутри `<S:file...>` находятся `fileId`, абсолютный URL сервера отправителя, AES-key/IV и исходная метаинформация файла. Так как весь plaintext контентного DM уже шифруется на ключ получателя/отправителя, сервер не получает ключ файла.
Получатель после E2EE-расшифровки DM скачивает ciphertext непосредственно с указанного access-сервера отправителя, сверяет `SHA-256`, расшифровывает файл в браузере и сохраняет исходный файл.
Отключение функции «Передача файлов» в локальных дополнительных настройках запрещает только отправку новых файлов; ранее полученные `<S:file...>` остаются скачиваемыми.
Хранение ciphertext и HTTP-контракт описаны в `docs/API/18_DM_File_Storage_API.md`.
## 16. Дедупликация файлов и пересылка сообщений (2026-09-11)
Для HTTP-хранилища DM-файлов действует усиленное правило content-addressed хранения:
- перед загрузкой каждого ciphertext-объекта официальный UI делает `HEAD /dm-files/{fileId}`;
- если сервер подтверждает объект с тем же `fileId` и размером, `PUT` с байтами повторно не выполняется;
- сервер перед ответом на `HEAD`, `GET` и перед `alreadyExists=true` сам пересчитывает `SHA-256` сохранённого объекта и сверяет его с `fileId`;
- повреждённый или подменённый объект не считается существующим и не выдаётся клиенту; при следующем корректном `PUT` он может быть записан заново.
Пересылка личного сообщения не вводит новый тип DM и не добавляет признак «переслано». UI создаёт обычное новое контентное сообщение `type=1/2` выбранному собеседнику:
- для обычного текста переносится отображаемый текст сообщения;
- исходный `<S:reply...>` не переносится, поэтому новое сообщение не остаётся ответом на сообщение из старого чата;
- для сообщения с файлами повторно используются существующие `<S:file...>`-описатели, поэтому ciphertext не шифруется и не загружается повторно;
- сервер и получатель видят пересланное сообщение как обычное новое сообщение без служебной пометки об источнике.
@@ -68,7 +68,7 @@
- `kind` — ASCII-идентификатор типа вставки;
- параметры отделяются `;`;
- ключ и значение отделяются `=`;
- значения не экранируются в v1;
- значения обычных v1-вставок не экранируются; для `file` поля `url`, `name`, `mime` кодируются через percent-encoding (`encodeURIComponent`), чтобы `;`, `=` и `>` внутри метаданных не ломали блок;
- канонический новый префикс: `<S:`;
- legacy-префикс `<SHiNE:` продолжает поддерживаться при чтении.
@@ -127,7 +127,32 @@ fromLogin|toLogin|timeMs|nonce
- UI может рисовать такие сообщения отдельным специальным стилем.
- официальный UI не отправляет call-summary, если от старта исходящего звонка до его завершения прошло меньше `5` секунд.
## 7. Поведение официального UI
## 7. Тип `file`
Формат v1:
```text
<S:file;v=1;id=BASE58_SHA256;url=ENCODED_URL;key=BASE64URL_AES_KEY;iv=BASE64URL_IV;name=ENCODED_NAME;mime=ENCODED_MIME;size=123;encsize=139>📎 example.pdf
```
Поля:
- `id``Base58(SHA-256(ciphertext))`;
- `url` — абсолютный URL ciphertext на access-сервере отправителя, percent-encoded;
- `key` — случайный 32-byte AES-256 key в Base64URL;
- `iv` — случайный 12-byte AES-GCM IV в Base64URL;
- `name` — исходное безопасно нормализованное имя файла, percent-encoded;
- `mime` — исходный MIME, percent-encoded;
- `size` — размер plaintext в байтах;
- `encsize` — размер ciphertext в байтах.
`key`, `iv` и метаданные безопасно находятся здесь только потому, что весь plaintext DM затем шифруется существующим E2EE-механизмом. Они не передаются файловому HTTP endpoint отдельно.
Текст `📎 example.pdf` после блока служит fallback для старого клиента. Новый официальный UI вместо него рисует карточку файла и кнопку «Скачать».
Перед локальной AES-GCM-расшифровкой клиент обязан повторно вычислить SHA-256 скачанного ciphertext и сравнить Base58 с `id`.
## 8. Поведение официального UI
Официальный UI SHiNE в v1:
@@ -137,12 +162,36 @@ fromLogin|toLogin|timeMs|nonce
- `Звонок: H:MM:SS`
- `Звонил, но недозвонился: ...`
- для `reply` скрывает сам блок и показывает только текст ответа;
- для `file` скрывает fallback-текст и показывает карточку вложения с локальным download/decrypt;
- если исходное reply-сообщение не найдено, reply-preview не показывается.
## 8. Совместимость
## 9. Совместимость
Так как это часть plaintext, а не часть серверного envelope:
- сервер не обязан понимать этот формат;
- будущие клиенты могут добавлять новые `kind`;
- клиенты, которые распознают SHiNE-вставки, должны скрывать неизвестные блоки целиком, если они стоят в начале и корректно закрыты.
## 10. Расширение `file` v2
Клиент обязан продолжать читать `v=1`. Новые chunked-вложения отправляются так:
```text
<S:file;v=2;id=ROOT_MANIFEST_ID;url=ENCODED_URL;key=BASE64URL_AES_KEY;ivp=BASE64URL_4BYTE_PREFIX;name=ENCODED_NAME;mime=ENCODED_MIME;size=123;encsize=456;chunk=1048576;chunks=7;th=TORRENT_V2_INFOHASH;pr=TORRENT_V2_PIECES_ROOT;kind=file;dur=0>
```
Дополнительные поля v2:
- `id` / `url` указывают не на весь файл, а на зашифрованный root manifest;
- `ivp` — случайный 4-byte IV prefix, из которого детерминированно строятся уникальные IV chunks/pages/root;
- `chunk` — plaintext chunk size, сейчас `1048576`;
- `chunks` — число частей;
- `th` — BitTorrent v2 SHA-256 infohash;
- `pr` — BitTorrent v2 pieces root;
- `kind=file|voice`;
- `dur` — длительность voice в миллисекундах, для обычного файла `0`.
В одном plaintext DM разрешено несколько последовательных `<S:file...>` блоков. Официальный UI собирает их в один список вложений и скрывает fallback-текст.
Подробный формат chunk encryption, manifest pages и BitTorrent v2 hashing: `Файлы_DM_v2.md`.
+124
View File
@@ -0,0 +1,124 @@
# Файлы и голосовые DM v2: chunked AES-GCM + BitTorrent v2 hashes
## Цели
DM file v2 убирает ограничение размера исходного файла, не требует держать файл целиком в RAM и сохраняет серверную модель «сервер видит только ciphertext».
Основные свойства:
- plaintext режется на куски по `1 MiB`;
- каждый кусок независимо шифруется `AES-256-GCM` одним случайным ключом файла, но с уникальным 96-bit IV;
- каждый ciphertext-кусок хранится как immutable объект `Base58(SHA-256(ciphertext))`;
- список кусков хранится в зашифрованных страницах манифеста по 256 записей;
- корневой манифест тоже зашифрован и content-addressed;
- E2EE DM содержит только ссылку на корневой манифест, AES-key, IV-prefix и пользовательские метаданные;
- один DM может содержать до 10 `<S:file;v=2...>` блоков;
- `kind=voice` использует тот же формат хранения и отдельный UI проигрывателя.
## IV и domain separation
На один файл создаётся случайный 4-byte `ivPrefix`.
12-byte IV строится как:
```text
ivPrefix[4] || uint64_be(token)
```
Диапазоны `token` разделены:
- chunks: `0 .. 2^63-1`;
- manifest pages: `2^63 + pageIndex`;
- root manifest: `0xffffffffffffffff`.
AAD также содержит домен (`chunk`, `page`, `root`), prefix и индекс. Поэтому перестановка ciphertext-кусков не проходит AES-GCM authentication.
## Manifest pages
Каждая страница после расшифровки содержит до 256 записей:
```json
{
"v": 2,
"page": 0,
"chunks": [
{
"i": 0,
"id": "Base58(SHA-256(ciphertext))",
"ps": 1048576,
"es": 1048592,
"ph": "BitTorrent-v2-piece-hash-base64url"
}
]
}
```
Страницы сами AES-GCM зашифрованы и загружаются в `/dm-files/{id}`.
## Root manifest
После расшифровки:
```json
{
"v": 2,
"scheme": "SHINE-DM-CHUNKED-AES-256-GCM",
"chunkSize": 1048576,
"fileSize": 123456789,
"chunkCount": 118,
"pages": [{"id":"...","count":118,"encryptedSize":12345}],
"torrent": {
"metaVersion": 2,
"blockLength": 16384,
"pieceLength": 1048576,
"piecesRoot": "...",
"infoHash": "..."
}
}
```
Имя файла и MIME в manifest не пишутся. Они остаются внутри E2EE DM.
## BitTorrent v2 совместимость
Хеширование соответствует BEP 52:
- базовый hash block: `16 KiB`;
- SHA-256;
- piece length: `1 MiB`;
- Merkle padding leaf = 32 zero bytes;
- `pieces root` вычисляется по правилам BitTorrent v2;
- `infoHash` = `SHA-256(bencode(info dictionary))`;
- piece-layer hash каждого 1-MiB куска хранится в зашифрованной manifest page.
Из `name`, `size`, `piecesRoot` и списка `ph` можно построить tracker-less `.torrent` v2 без повторного хеширования исходного файла.
Это совместимость метаданных и проверки контента. HTTP `/dm-files` пока не является BitTorrent peer transport: для настоящего P2P потребуется отдельный seeding/peer слой.
## Скачивание
Получатель:
1. получает и проверяет ciphertext root manifest по Base58(SHA-256);
2. расшифровывает root manifest;
3. по очереди получает manifest pages;
4. получает каждый chunk с сервера отправителя;
5. проверяет content address ciphertext;
6. расшифровывает chunk локально;
7. проверяет BitTorrent-v2 piece hash;
8. пишет plaintext на диск;
9. в конце проверяет итоговый `pieces root`.
В браузерах с File System Access API plaintext пишется на диск по частям и целиком в RAM не собирается. В остальных браузерах остаётся Blob fallback без искусственного лимита размера, но фактический предел зависит от памяти браузера.
## Голосовые
`MediaRecorder` пишет `Opus/WebM`, `Opus/Ogg` или поддерживаемый браузером audio MIME. После завершения запись проходит тот же DM file v2 pipeline.
Технический блок отличается полями:
```text
kind=voice;dur=<milliseconds>
```
Получатель видит player с Play/Pause, прогрессом и длительностью. Аудио расшифровывается только на клиенте.
@@ -371,3 +371,25 @@ UI-примечание (байтовый формат не меняет): ра
Контейнер `type=7/8` является служебным tombstone очистки истории. Его наличие без обычных сообщений `type=1/2` не должно само по себе означать, что у пары есть видимый пользовательский диалог.
Следствие для UI/агрегата диалогов: `hasDialog` определяется наличием пользовательского содержимого (или непрочитанных пользовательских сообщений), а не наличием служебной записи состояния/tombstone. Формат контейнера при этом не изменяется.
## 15. Вложения `<S:file>` и бинарный формат
Поддержка файлов не добавляет полей в `SHiNE_DM` и не меняет порядок существующих полей.
Для контентных `type=1/2` технический блок `<S:file...>` является частью обычного plaintext, который затем попадает в уже существующий зашифрованный `body`. Сам ciphertext внешнего файла в `SHiNE_DM` не включается.
Поэтому подписи контейнера, `baseKey`, `revisionTimeMs`, `reencryptedAtMs`, алгоритм E2EE DM и правила парности входящей/исходящей копий остаются прежними.
## 16. Примечание о пересылке сообщений (2026-09-11)
Пересылка не меняет байтовый формат `SHiNE_DM` и не добавляет отдельный `messageType` или флаг forward/repost.
Клиент формирует новый обычный plaintext для `type=1/2`:
- текстовая часть копируется как новое сообщение;
- `<S:reply...>` исходного сообщения удаляется;
- валидные `<S:file...>` могут быть скопированы без изменения, чтобы новое сообщение ссылалось на тот же уже загруженный зашифрованный объект;
- информация о том, из какого чата или сообщения выполнена пересылка, в контейнер не добавляется.
Таким образом подпись, `baseKey`, шифрование и структура контейнера остаются полностью прежними: меняется только содержимое нового plaintext перед стандартной отправкой.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27 -2
View File
@@ -94,16 +94,17 @@ UserPdaRecordV1
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. |
| `70` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `100` | `ArchiveHeadBlock` | Текущая голова серверного SHINE-ARCHIVE: Arweave TX ID + SHA-256 архива. |
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
Правила:
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`;
- каждый обязательный блок должен встречаться ровно один раз;
- порядок блоков в записи фиксируется для простоты проверки:
`RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`.
`RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`, `ArchiveHead`.
## 6. RecoveryKeyBlock
@@ -359,6 +360,28 @@ TrustedStateBlock
Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат.
## 15.1. ArchiveHeadBlock
Необязательный блок текущей головы серверного архива. Он используется archive-capable сервером и хранится в том же User PDA.
```text
ArchiveHeadBlock
- block_type: u8 = 100
- block_version: u8 = 0
- archive_tx_id: [u8; 32]
- archive_hash: [u8; 32]
```
Семантика:
- `archive_tx_id` — raw 32-byte Arweave transaction id последнего опубликованного большого `SHINE-ARCHIVE`; текстовая Base64URL-форма получается вне PDA;
- `archive_hash` — SHA-256 большого archive block по правилам `docs/Archive/01_PROTOCOL_v1.0.md`;
- отсутствие block `100` означает, что аккаунт ещё не объявлял archive head;
- обычный legacy `update_user_pda`, в instruction которого archive extension отсутствует, **обязан сохранить существующий ArchiveHeadBlock без изменений**;
- расширенный `update_user_pda` может заменить archive head или явно очистить его; отдельной Solana instruction для архива нет.
`ArchiveHeadBlock` входит в unsigned bytes User PDA и тем самым покрывается обычной root-подписью записи.
## 16. Подпись user_pda
Подписывается не вся PDA целиком, а unsigned-часть записи:
@@ -392,6 +415,7 @@ Solana-программа проверяет подпись через встр
- обязательные блоки присутствуют;
- создается минимум один `BlockchainRecord`;
- новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`;
- `ArchiveHeadBlock` при регистрации не обязателен; обычный пользователь/сервер может начать публиковать архив позже;
- стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит;
- `used_bytes <= paid_limit_bytes`;
- пользователь платит регистрационную комиссию;
@@ -408,6 +432,7 @@ Solana-программа проверяет подпись через встр
- `prev_record_hash` равен хэшу unsigned-части предыдущей записи;
- `updated_at_ms` обновляется;
- unsigned-часть новой записи подписана `root_key`;
- если archive extension в instruction отсутствует (legacy client), старый `ArchiveHeadBlock` сохраняется; если extension присутствует, применяется переданное `archive_head_update`;
- лимиты блокчейнов могут только увеличиваться;
- занятый размер и номер последнего блока не могут уменьшаться;
- при увеличении оплаченного лимита пользователь доплачивает комиссию;
@@ -135,3 +135,23 @@
- economy-настройки меняет DAO-authority;
- upgrade-authority программы после проверки передается DAO;
- пользовательские операции `create_user_pda` и `update_user_pda` остаются доступными обычным пользователям при корректных подписях и оплате.
## ArchiveHeadBlock и серверный SHINE-ARCHIVE
Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`):
```text
archive_tx_id [32]
archive_hash [32]
```
Он хранит текущую голову архива конкретного SHiNE-аккаунта: raw Arweave TX ID и SHA-256 соответствующего большого `SHINE-ARCHIVE`. Подробный бинарный формат и серверный workflow находятся в `docs/Archive/01_PROTOCOL_v1.0.md`.
Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим:
- legacy payload без archive extension сохраняет старый block `100`;
- новый payload может заменить/очистить archive head;
- итоговая полная User PDA запись, включая block `100`, покрывается обычной root-подписью.
Это позволяет обычным старым клиентским обновлениям профиля не стирать archive head серверного publisher-а.
+2
View File
@@ -9,6 +9,7 @@ include 'shine-server-db'
include 'shine-server-net-protocol'
include 'shine-server-net-server'
include 'shine-server-solana-users-sync'
include 'shine-server-archive'
project(':shine-server-log').projectDir = file('SHiNE-server/shine-server-log')
project(':shine-server-config').projectDir = file('SHiNE-server/shine-server-config')
@@ -19,3 +20,4 @@ project(':shine-server-db').projectDir = file('SHiNE-server/shine-server-db')
project(':shine-server-net-protocol').projectDir = file('SHiNE-server/shine-server-net-protocol')
project(':shine-server-net-server').projectDir = file('SHiNE-server/shine-server-net-server')
project(':shine-server-solana-users-sync').projectDir = file('SHiNE-server/shine-server-solana-users-sync')
project(':shine-server-archive').projectDir = file('SHiNE-server/shine-server-archive')
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -131,7 +131,7 @@ self.addEventListener('push', (event) => {
const notifyPromise = shouldNotify
? self.registration.showNotification(notificationTitle, {
body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'),
body: body || (fromLogin ? `Новое сообщение от ${fromLogin}` : 'Новое сообщение'),
tag: callId || (kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message'),
renotify: true,
requireInteraction: kind === 'incoming_call',
+5
View File
@@ -73,8 +73,10 @@ 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';
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
import * as arweaveUploadsView from './pages/arweave-uploads-view.js';
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
@@ -139,8 +141,10 @@ 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,
'server-settings-view': serverSettingsView,
'arweave-uploads-view': arweaveUploadsView,
'remote-addblock-session-view': remoteAddBlockSessionView,
@@ -226,6 +230,7 @@ const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
'server-settings-view',
'arweave-uploads-view',
'developer-settings-view',
'advanced-settings-view',
'trusted-device-login-settings-view',
'device-view',
'device-session-view',
@@ -0,0 +1,62 @@
import { createTopBar } from '../components/topbar.js';
import {
isDeveloperToolsEnabled,
isDmFileTransferEnabled,
setDeveloperToolsEnabled,
setDmFileTransferEnabled,
} from '../services/feature-settings.js';
export const pageMeta = { id: 'advanced-settings-view', title: 'Дополнительные настройки' };
function createToggleRow({ id, title, hint, checked }) {
const label = document.createElement('label');
label.className = 'card settings-feature-toggle-row';
label.htmlFor = id;
label.innerHTML = `
<span class="settings-feature-toggle-copy">
<strong class="settings-feature-toggle-title">${title}</strong>
<span class="settings-feature-toggle-hint">${hint}</span>
</span>
<input class="settings-feature-switch" id="${id}" type="checkbox" ${checked ? 'checked' : ''} />
`;
return label;
}
export function render({ navigate, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack advanced-settings-screen';
chrome?.setTopbar(createTopBar({
title: 'Дополнительные настройки',
back: { label: '←', onClick: () => navigate('settings-view') },
}));
const intro = document.createElement('div');
intro.className = 'card stack advanced-settings-intro';
intro.innerHTML = `
<p class="field-label">Скрытые возможности SHiNE</p>
<p class="meta-muted">Этот экран открывается пятью быстрыми нажатиями по логотипу в обычных настройках и имеет постоянный адрес <code>/settings/advanced</code>.</p>
`;
const developerRow = createToggleRow({
id: 'advanced-developer-tools',
title: 'Настройки разработчика',
hint: 'Показывать блок «Версии» и кнопку настроек разработчика в обычных настройках. По умолчанию выключено.',
checked: isDeveloperToolsEnabled(),
});
const filesRow = createToggleRow({
id: 'advanced-dm-files',
title: 'Передача файлов в личных сообщениях',
hint: 'Разрешить зашифрованные файлы любого размера, несколько файлов за сообщение и голосовые. Полученные ранее файлы останутся доступными.',
checked: isDmFileTransferEnabled(),
});
const developerInput = developerRow.querySelector('#advanced-developer-tools');
const filesInput = filesRow.querySelector('#advanced-dm-files');
developerInput?.addEventListener('change', () => setDeveloperToolsEnabled(developerInput.checked));
filesInput?.addEventListener('change', () => setDmFileTransferEnabled(filesInput.checked));
screen.append(intro, developerRow, filesRow);
return screen;
}
@@ -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;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+58 -7
View File
@@ -222,9 +222,10 @@ function hash01(str) {
* @param {Function} [opts.onCenterTap] - тап по центральному узлу (node) => void
* @param {Function} [opts.onNodeTap] - тап по периферийному узлу (node) => void (вызывается ДО центрирования)
* @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void
* @param {Function} [opts.onNodeMoveEnd] - ручное перемещение периферийного узла (node, {x,y}) => void
* @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
*/
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeHover, onDiveChange } = {}) {
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onNodeHover, onDiveChange } = {}) {
// Слои DOM
const edgesSvg = document.createElementNS(SVGNS, 'svg');
edgesSvg.setAttribute('class', 'fg-edges');
@@ -471,6 +472,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
edgeParents: Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [],
fixedLayout,
keepVisible: Boolean(src.keepVisible),
alwaysVisible: Boolean(src.alwaysVisible),
official: Boolean(src.official),
deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2,
track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко
@@ -530,7 +532,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// Слой 1 — фото круглой маской ~78% от бокса оверлея (сидит внутри кромки); слой 2 — glass_overlay.png
// на весь бокс (альфа уже в PNG). Кодовый glow не рисуем — у картинки своё свечение запечено (нет двойного).
const GLASS_OVERLAY_SRC = '/assets/glass_overlay_faithful.png';
const OFFICIAL_BADGE_SRC = '/assets/shine-official-badge.svg';
const OFFICIAL_BADGE_SRC = '/assets/shine-official-badge.svg?v=2';
function buildPngOrb(src, opts) {
const o = opts || {};
const wrap = document.createElement('div');
@@ -657,6 +659,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.shining = Boolean(src.shining);
node.official = Boolean(src.official);
node.keepVisible = Boolean(src.keepVisible);
node.alwaysVisible = Boolean(src.alwaysVisible);
node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [];
const layoutX = Number(src.layoutX);
const layoutY = Number(src.layoutY);
@@ -772,6 +775,20 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
for (const tier of [2, 3]) {
for (const n of nodes) {
if (n.tier !== tier) continue;
// Реальный X2 из network-view приходит уже с collision-aware фиксированной позицией и должен
// быть виден сразу, без клика/hover по родителю. Лабораторные deep-ветки без fixedLayout
// продолжают работать по старой схеме раскрытия expandP.
if (n.fixedLayout && n.alwaysVisible) {
n.x = n.tx;
n.y = n.ty;
const baseOp = tier === 2 ? DEEP2_OPACITY : DEEP3_OPACITY;
const baseSc = tier === 2 ? DEEP2_SCALE : (n.lod === 'full' ? 0.42 : 1);
n.opacity = n.hidden ? 0 : baseOp;
n.scale = baseSc;
n.targetOpacity = n.opacity;
n.targetScale = n.scale;
continue;
}
const p = nodeById.get(n.parentId);
if (!p) { n.opacity = 0; continue; }
const e = p.expandP || 0; // насколько раскрыт родитель
@@ -1032,7 +1049,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const L = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy) + Math.hypot(x2 - x1, y2 - y1)) / 2;
dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`;
}
const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми)
const pe = n.alwaysVisible ? 1 : (parent.expandP || 0); // X2 виден сразу; старые deep-ветки — по раскрытию
if (n.tier >= 3) {
// 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро).
if (pe > 0.02) {
@@ -1451,6 +1468,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let camStartY = 0;
let moved = false;
let downNodeEl = null;
let downNode = null;
let nodeDragActive = false;
let nodeDragStartX = 0;
let nodeDragStartY = 0;
let longTimer = 0;
let longFired = false;
const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами
@@ -1579,7 +1600,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
longFired = false;
downNodeEl = ev.target instanceof Element ? ev.target.closest('.fg-node') : null;
if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания
const downNode = nodeFromEvent(ev);
downNode = nodeFromEvent(ev);
nodeDragActive = false;
nodeDragStartX = Number(downNode?.x) || 0;
nodeDragStartY = Number(downNode?.y) || 0;
// касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out
if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true);
if (downNode && typeof onNodeLongPress === 'function') {
@@ -1630,13 +1654,29 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
if (!moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
moved = true;
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это свайп, а не нажатие
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это drag/pan, а не нажатие
// Не центральный аватар перетаскивается сам. Пустой фон или центральный узел продолжают панорамировать карту.
nodeDragActive = Boolean(downNode && !downNode.isFocus);
if (nodeDragActive && cssBloom) endCssBloom();
// палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста)
cancelTween(); // жест прерывает анимацию центрирования
dragging = true;
}
if (moved && nodeDragActive && downNode) {
// dx/dy приходят в экранных пикселях, координаты узла живут в world-space — делим на текущий zoom.
const nx = nodeDragStartX + dx / Math.max(0.001, zoom);
const ny = nodeDragStartY + dy / Math.max(0.001, zoom);
downNode.x = nx; downNode.y = ny;
downNode.tx = nx; downNode.ty = ny;
downNode.bfx = nx; downNode.bfy = ny;
downNode.vx = 0; downNode.vy = 0;
downNode.fixedLayout = true;
renderNodes();
renderEdges();
return;
}
if (moved) {
const newCamX = camStartX + dx;
const newCamY = camStartY + dy;
@@ -1667,14 +1707,23 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
try { stage.releasePointerCapture(ev.pointerId); } catch { /* не было захвата — ок */ }
const wasMoved = moved;
const wasLong = longFired;
const movedNode = nodeDragActive ? downNode : null;
pointerId = null;
dragging = false;
nodeDragActive = false;
// касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap)
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
if (wasMoved || wasLong) {
// после pan даём физике чуть устаканиться и уснуть
if (wasMoved) wake();
if (wasMoved && movedNode) {
// Передаём окончательную world-позицию наружу, чтобы ручная правка пережила следующий setModel/history render.
if (typeof onNodeMoveEnd === 'function') onNodeMoveEnd(movedNode, { x: movedNode.x, y: movedNode.y });
renderEdges();
} else if (wasMoved) {
// после pan даём физике чуть устаканиться и уснуть
wake();
}
downNode = null;
return;
}
// это был тап
@@ -1690,6 +1739,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
if (tapNode.isFocus) {
if (typeof onCenterTap === 'function') onCenterTap(tapNode);
downNode = null;
return;
}
if (typeof onNodeTap === 'function') {
@@ -1700,6 +1750,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// нет внешнего обработчика — внутреннее перецентрирование (фолбэк)
startRecenterTween(tapNode.id);
}
downNode = null;
}
function onResize() {
+71 -42
View File
@@ -1,5 +1,6 @@
import { createTopBar } from '../components/topbar.js';
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
import { isDeveloperToolsEnabled } from '../services/feature-settings.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -35,6 +36,24 @@ export function render({navigate, chrome}) {
back: { label: '←', onClick: () => navigate('profile-view') },
}));
const logoButton = document.createElement('button');
logoButton.type = 'button';
logoButton.className = 'settings-shine-logo-button';
logoButton.setAttribute('aria-label', 'Логотип SHiNE');
logoButton.title = 'SHiNE';
logoButton.innerHTML = '<img class="settings-shine-logo" src="/img/shine-logo-transparent-final_big.png" alt="SHiNE" />';
let logoTapCount = 0;
let lastLogoTapAt = 0;
logoButton.addEventListener('click', () => {
const now = Date.now();
logoTapCount = now - lastLogoTapAt <= 900 ? logoTapCount + 1 : 1;
lastLogoTapAt = now;
if (logoTapCount < 5) return;
logoTapCount = 0;
navigate('advanced-settings-view');
});
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
@@ -52,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>
@@ -66,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');
@@ -93,61 +119,64 @@ export function render({navigate, chrome}) {
}
});
const versionCard = document.createElement('div');
versionCard.className = 'card stack';
screen.append(logoButton, card);
const title = document.createElement('p');
title.className = 'field-label';
title.textContent = 'Версии';
if (isDeveloperToolsEnabled()) {
const versionCard = document.createElement('div');
versionCard.className = 'card stack';
const clientVersion = document.createElement('p');
clientVersion.className = 'meta-muted';
clientVersion.textContent = `Клиент: ${formatVersionForUi(window.__SHINE_CLIENT_VERSION__)}`;
const title = document.createElement('p');
title.className = 'field-label';
title.textContent = 'Версии';
const uiBuild = document.createElement('p');
uiBuild.className = 'meta-muted';
uiBuild.textContent = `Сборка UI: ${formatVersionForUi(window.__SHINE_BUILD_HASH__)}`;
const clientVersion = document.createElement('p');
clientVersion.className = 'meta-muted';
clientVersion.textContent = `Клиент: ${formatVersionForUi(window.__SHINE_CLIENT_VERSION__)}`;
const serverVersion = document.createElement('p');
serverVersion.className = 'meta-muted';
serverVersion.textContent = 'Сервер: загружается...';
const uiBuild = document.createElement('p');
uiBuild.className = 'meta-muted';
uiBuild.textContent = `Сборка UI: ${formatVersionForUi(window.__SHINE_BUILD_HASH__)}`;
versionCard.append(title, clientVersion, uiBuild, serverVersion);
const serverVersion = document.createElement('p');
serverVersion.className = 'meta-muted';
serverVersion.textContent = 'Сервер: загружается...';
const developerCard = document.createElement('div');
developerCard.className = 'card stack';
developerCard.innerHTML = `
<button class="shine-btn shine-btn--settings" type="button" id="settings-developer">Настройки разработчика</button>
`;
developerCard.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view'));
versionCard.append(title, clientVersion, uiBuild, serverVersion);
void (async () => {
try {
let value = '';
const developerCard = document.createElement('div');
developerCard.className = 'card stack';
developerCard.innerHTML = `
<button class="shine-btn shine-btn--settings" type="button" id="settings-developer">Настройки разработчика</button>
`;
developerCard.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view'));
void (async () => {
try {
const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000);
value = String(pingResp?.payload?.serverVersion || pingResp?.serverVersion || '').trim();
} catch {
// fallback below
}
let value = '';
try {
const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000);
value = String(pingResp?.payload?.serverVersion || pingResp?.serverVersion || '').trim();
} catch {
// fallback below
}
if (!value) {
const infoResp = await authService.ws.request('GetServerInfo', {});
value = String(infoResp?.payload?.version || '').trim();
if (!value) {
const infoResp = await authService.ws.request('GetServerInfo', {});
value = String(infoResp?.payload?.version || '').trim();
}
if (!isDisposed) serverVersion.textContent = `Сервер: ${formatVersionForUi(value)}`;
} catch {
if (!isDisposed) {
serverVersion.textContent = 'Сервер: недоступно';
}
}
if (!isDisposed) serverVersion.textContent = `Сервер: ${formatVersionForUi(value)}`;
} catch {
if (!isDisposed) {
serverVersion.textContent = 'Сервер: недоступно';
}
}
})();
})();
screen.append(versionCard, developerCard);
}
screen.cleanup = () => {
isDisposed = true;
};
screen.append(card);
screen.append(versionCard);
screen.append(developerCard);
return screen;
}
+3
View File
@@ -36,6 +36,7 @@ const PRETTY_PATHS = new Map([
['server-settings-view', 'settings/servers'],
['arweave-uploads-view', 'settings/arweave-uploads'],
['developer-settings-view', 'settings/developer'],
['advanced-settings-view', 'settings/advanced'],
['trusted-device-login-settings-view', 'settings/device-login'],
['language-view', 'settings/language'],
['app-log-view', 'settings/app-log'],
@@ -322,6 +323,7 @@ export function parseRouteFromPath(pathname = '') {
if (sub === 'servers') return { pageId: 'server-settings-view', params: {} };
if (sub === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} };
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
if (sub === 'advanced') return { pageId: 'advanced-settings-view', params: {} };
if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} };
if (sub === 'language') return { pageId: 'language-view', params: {} };
if (sub === 'app-log') return { pageId: 'app-log-view', params: {} };
@@ -447,6 +449,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'settings-view' ||
pageId === 'access-servers-view' ||
pageId === 'developer-settings-view' ||
pageId === 'advanced-settings-view' ||
pageId === 'server-settings-view' ||
pageId === 'arweave-uploads-view' ||
pageId === 'remote-addblock-session-view' ||
+8 -16
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('Введите логин');
@@ -3157,22 +3165,6 @@ export class AuthService {
return response.payload || {};
}
async getTestFreeAvatarQuota() {
const response = await this.ws.request('TestGetFreeAvatarQuota', {});
if (response.status !== 200) throw opError('TestGetFreeAvatarQuota', response);
return response.payload || {};
}
async uploadTestFreeAvatar({ contentType, fileBytesBase64, sha256Hex }) {
const response = await this.ws.request('TestUploadFreeAvatar', {
contentType,
fileBytesBase64,
sha256Hex,
}, 60000);
if (response.status !== 200) throw opError('TestUploadFreeAvatar', response);
return response.payload || {};
}
async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) {
const cleanKind = String(kind || '').trim().toLowerCase();
const kinds = CONNECTION_SUBTYPES[cleanKind];
+8 -4
View File
@@ -289,15 +289,19 @@ export async function importAesKeyRaw(keyBytes, usages = ['encrypt', 'decrypt'])
return getSubtleApi().importKey('raw', keyBytes, { name: 'AES-GCM' }, false, usages);
}
export async function encryptBytesAesGcm(plainBytes, keyBytes, ivBytes) {
export async function encryptBytesAesGcm(plainBytes, keyBytes, ivBytes, additionalData = null) {
const key = await importAesKeyRaw(keyBytes, ['encrypt']);
const cipher = await getSubtleApi().encrypt({ name: 'AES-GCM', iv: ivBytes }, key, plainBytes);
const algorithm = { name: 'AES-GCM', iv: ivBytes };
if (additionalData) algorithm.additionalData = additionalData;
const cipher = await getSubtleApi().encrypt(algorithm, key, plainBytes);
return new Uint8Array(cipher);
}
export async function decryptBytesAesGcm(cipherBytes, keyBytes, ivBytes) {
export async function decryptBytesAesGcm(cipherBytes, keyBytes, ivBytes, additionalData = null) {
const key = await importAesKeyRaw(keyBytes, ['decrypt']);
const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv: ivBytes }, key, cipherBytes);
const algorithm = { name: 'AES-GCM', iv: ivBytes };
if (additionalData) algorithm.additionalData = additionalData;
const plain = await getSubtleApi().decrypt(algorithm, key, cipherBytes);
return new Uint8Array(plain);
}
+722
View File
@@ -0,0 +1,722 @@
import {
base64UrlToBytes,
bytesToBase58,
bytesToBase64Url,
decryptBytesAesGcm,
encryptBytesAesGcm,
importPkcs8Ed25519,
randomBytes,
sha256Bytes,
signBase64,
utf8Bytes,
} from './crypto-utils.js';
import { loadSessionMaterial } from './key-vault.js';
import {
TORRENT_V2_BLOCK_BYTES,
TORRENT_V2_PIECE_BYTES,
TorrentV2PieceAccumulator,
buildTorrentV2MetainfoBytes,
computeTorrentV2InfoHash,
computeTorrentV2PieceRoot,
} from './torrent-v2-service.js';
export const DM_FILE_CHUNK_BYTES = TORRENT_V2_PIECE_BYTES;
export const DM_FILE_MANIFEST_PAGE_CHUNKS = 256;
export const DM_MAX_ATTACHMENTS_PER_MESSAGE = 10;
const MANIFEST_VERSION = 2;
const MANIFEST_SCHEME = 'SHINE-DM-CHUNKED-AES-256-GCM';
const ROOT_IV_TOKEN = 0xffffffffffffffffn;
const PAGE_IV_BASE = 0x8000000000000000n;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function normalizeFileName(value = '') {
const cleaned = String(value || 'file')
.replace(/[\\/\u0000-\u001f\u007f]/g, '_')
.trim();
return (cleaned || 'file').slice(0, 180);
}
function wsUrlToHttpBase(wsUrl = '') {
const parsed = new URL(String(wsUrl || ''), window.location.href);
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
else if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Не удалось определить HTTP-адрес сервера SHiNE');
}
parsed.pathname = '/';
parsed.search = '';
parsed.hash = '';
return parsed.origin;
}
function uploadPreimage({ sessionId, fileId, encryptedSize, timeMs }) {
return `DM_FILE_UPLOAD_V1:${sessionId}:${fileId}:${Number(encryptedSize)}:${Number(timeMs)}`;
}
async function readErrorMessage(response) {
try {
const raw = String(await response.text()).trim();
if (!raw) return '';
try {
const payload = JSON.parse(raw);
return String(payload?.message || payload?.error || raw).trim();
} catch {
return raw;
}
} catch {
return '';
}
}
function objectUrl(serverBase, id) {
return `${String(serverBase || '').replace(/\/$/, '')}/dm-files/${encodeURIComponent(String(id || ''))}`;
}
function objectBaseFromUrl(url = '') {
const parsed = new URL(String(url || ''), window.location.href);
return parsed.origin;
}
function makeIv(prefix4, token) {
if (!(prefix4 instanceof Uint8Array) || prefix4.byteLength !== 4) {
throw new Error('Некорректный IV prefix файла');
}
const iv = new Uint8Array(12);
iv.set(prefix4, 0);
new DataView(iv.buffer).setBigUint64(4, BigInt(token), false);
return iv;
}
function chunkIv(prefix4, index) {
return makeIv(prefix4, BigInt(index));
}
function pageIv(prefix4, index) {
return makeIv(prefix4, PAGE_IV_BASE + BigInt(index));
}
function rootIv(prefix4) {
return makeIv(prefix4, ROOT_IV_TOKEN);
}
function makeAad(prefix4, domain, index = 0) {
return utf8Bytes(`SHINE-DM-FILE-V2:${bytesToBase64Url(prefix4)}:${domain}:${index}`);
}
function chunkAad(prefix4, index) {
return makeAad(prefix4, 'chunk', index);
}
function pageAad(prefix4, index) {
return makeAad(prefix4, 'page', index);
}
function rootAad(prefix4) {
return makeAad(prefix4, 'root', 0);
}
function assertSafeIndex(index, label = 'index') {
const n = Number(index);
if (!Number.isSafeInteger(n) || n < 0) throw new Error(`Некорректный ${label}`);
return n;
}
async function createUploadContext({ login, sessionId, wsUrl }) {
const cleanLogin = String(login || '').trim();
const cleanSessionId = String(sessionId || '').trim();
if (!cleanLogin || !cleanSessionId) throw new Error('Нет активной пользовательской сессии');
const sessionMaterial = await loadSessionMaterial(cleanLogin);
if (!sessionMaterial?.sessionPrivPkcs8) {
throw new Error('На устройстве нет сохранённого session key для загрузки файла');
}
if (sessionMaterial.sessionId && String(sessionMaterial.sessionId) !== cleanSessionId) {
throw new Error('Сохранённый session key относится к другой сессии');
}
return {
sessionId: cleanSessionId,
serverBase: wsUrlToHttpBase(wsUrl),
privateKey: await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8),
};
}
async function uploadEncryptedObject(bytes, context) {
const payload = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || 0);
const fileId = bytesToBase58(await sha256Bytes(payload));
const url = objectUrl(context.serverBase, fileId);
// Быстрая дедупликация: если сервер уже подтверждает content-addressed объект,
// не отправляем его байты повторно. HEAD на сервере сам перепроверяет SHA-256 файла.
try {
const existing = await fetch(url, { method: 'HEAD', cache: 'no-store' });
if (existing.ok) {
const storedSize = Number(existing.headers.get('Content-Length') || -1);
const etag = String(existing.headers.get('ETag') || '').replace(/^"|"$/g, '');
if (storedSize === payload.byteLength && (!etag || etag === fileId)) {
return { id: fileId, url, encryptedSize: payload.byteLength, alreadyExists: true };
}
}
} catch {
// Старый сервер или временная ошибка HEAD не должны ломать загрузку: PUT остаётся источником истины.
}
const timeMs = Date.now();
const signatureB64 = await signBase64(context.privateKey, uploadPreimage({
sessionId: context.sessionId,
fileId,
encryptedSize: payload.byteLength,
timeMs,
}));
let response;
try {
response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'X-Shine-Session-Id': context.sessionId,
'X-Shine-Time-Ms': String(timeMs),
'X-Shine-Content-Length': String(payload.byteLength),
'X-Shine-Signature': signatureB64,
},
body: payload,
cache: 'no-store',
});
} catch (error) {
throw new Error(`Не удалось загрузить часть файла на сервер отправителя: ${error?.message || 'network error'}`);
}
if (!response.ok) {
const detail = await readErrorMessage(response);
throw new Error(detail || `Сервер отклонил часть файла (HTTP ${response.status})`);
}
let alreadyExists = false;
try {
const payloadJson = await response.clone().json();
alreadyExists = Boolean(payloadJson?.alreadyExists);
} catch {
// Ответ старого сервера может не содержать JSON-флаг; успешного HTTP достаточно.
}
return { id: fileId, url, encryptedSize: payload.byteLength, alreadyExists };
}
async function fetchVerifiedObject(url, expectedId) {
let response;
try {
response = await fetch(url, { method: 'GET', cache: 'no-store' });
} catch (error) {
throw new Error(`Не удалось скачать зашифрованные данные: ${error?.message || 'network error'}`);
}
if (!response.ok) {
throw new Error(response.status === 404 ? 'Часть файла больше не найдена на сервере отправителя' : `Ошибка скачивания файла (HTTP ${response.status})`);
}
const bytes = new Uint8Array(await response.arrayBuffer());
const actualId = bytesToBase58(await sha256Bytes(bytes));
if (actualId !== String(expectedId || '')) {
bytes.fill(0);
throw new Error('SHA-256 зашифрованной части не совпал: данные повреждены или подменены');
}
return bytes;
}
function encodeJson(value) {
return encoder.encode(JSON.stringify(value));
}
function decodeJson(bytes) {
return JSON.parse(decoder.decode(bytes));
}
async function encryptAndUploadManifestPage({ descriptors, pageIndex, fileKey, ivPrefix, uploadContext }) {
const plain = encodeJson({ v: MANIFEST_VERSION, page: pageIndex, chunks: descriptors });
const iv = pageIv(ivPrefix, pageIndex);
const aad = pageAad(ivPrefix, pageIndex);
let encrypted;
try {
encrypted = await encryptBytesAesGcm(plain, fileKey, iv, aad);
const stored = await uploadEncryptedObject(encrypted, uploadContext);
return {
id: stored.id,
count: descriptors.length,
encryptedSize: stored.encryptedSize,
};
} finally {
plain.fill(0);
iv.fill(0);
aad.fill(0);
encrypted?.fill(0);
}
}
export function formatDmFileSize(bytes = 0) {
const value = Math.max(0, Number(bytes || 0));
if (value < 1024) return `${value} Б`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} КБ`;
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 ? 0 : 1)} МБ`;
return `${(value / (1024 * 1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 * 1024 ? 1 : 2)} ГБ`;
}
/**
* V2 uploader: no whole-file size limit. Only one 1 MiB plaintext piece is held in
* memory at a time. Every piece is independently AES-256-GCM encrypted and stored
* as Base58(SHA-256(ciphertext)).
*/
export async function encryptAndUploadDmFile({
file,
login,
sessionId,
wsUrl,
kind = 'file',
durationMs = 0,
onProgress = null,
} = {}) {
if (!file || typeof file.slice !== 'function') throw new Error('Файл не выбран');
const uploadContext = await createUploadContext({ login, sessionId, wsUrl });
const fileKey = randomBytes(32);
const ivPrefix = randomBytes(4);
const fileSize = Math.max(0, Number(file.size || 0));
const chunkCount = Math.ceil(fileSize / DM_FILE_CHUNK_BYTES);
const pageRefs = [];
let pageDescriptors = [];
let pageIndex = 0;
const pieceAccumulator = new TorrentV2PieceAccumulator();
let singlePieceRoot = null;
let completedBytes = 0;
try {
for (let index = 0; index < chunkCount; index += 1) {
const start = index * DM_FILE_CHUNK_BYTES;
const end = Math.min(fileSize, start + DM_FILE_CHUNK_BYTES);
const plain = new Uint8Array(await file.slice(start, end).arrayBuffer());
const iv = chunkIv(ivPrefix, index);
const aad = chunkAad(ivPrefix, index);
let encrypted = null;
try {
const pieceRoot = await computeTorrentV2PieceRoot(plain, {
padToPieceLength: fileSize > TORRENT_V2_PIECE_BYTES,
});
if (!pieceRoot) throw new Error('Не удалось вычислить torrent-v2 hash части');
if (chunkCount <= 1) singlePieceRoot = pieceRoot.slice();
else await pieceAccumulator.addPieceRoot(pieceRoot);
encrypted = await encryptBytesAesGcm(plain, fileKey, iv, aad);
const stored = await uploadEncryptedObject(encrypted, uploadContext);
pageDescriptors.push({
i: index,
id: stored.id,
ps: plain.byteLength,
es: encrypted.byteLength,
ph: bytesToBase64Url(pieceRoot),
});
completedBytes += plain.byteLength;
onProgress?.({
phase: 'chunks',
chunkIndex: index,
chunkCount,
processedBytes: completedBytes,
totalBytes: fileSize,
});
} finally {
plain.fill(0);
iv.fill(0);
aad.fill(0);
encrypted?.fill(0);
}
if (pageDescriptors.length >= DM_FILE_MANIFEST_PAGE_CHUNKS || index === chunkCount - 1) {
const pageRef = await encryptAndUploadManifestPage({
descriptors: pageDescriptors,
pageIndex,
fileKey,
ivPrefix,
uploadContext,
});
pageRefs.push(pageRef);
pageDescriptors = [];
pageIndex += 1;
}
}
const piecesRoot = fileSize <= 0
? null
: (chunkCount <= 1 ? singlePieceRoot : await pieceAccumulator.finalize());
const torrentInfo = await computeTorrentV2InfoHash({
name: normalizeFileName(file.name),
size: fileSize,
piecesRoot,
});
const rootManifest = {
v: MANIFEST_VERSION,
scheme: MANIFEST_SCHEME,
chunkSize: DM_FILE_CHUNK_BYTES,
fileSize,
chunkCount,
pages: pageRefs,
torrent: {
metaVersion: 2,
blockLength: TORRENT_V2_BLOCK_BYTES,
pieceLength: TORRENT_V2_PIECE_BYTES,
piecesRoot: piecesRoot ? bytesToBase64Url(piecesRoot) : '',
infoHash: torrentInfo.infoHashB64Url,
},
};
const rootPlain = encodeJson(rootManifest);
const iv = rootIv(ivPrefix);
const aad = rootAad(ivPrefix);
let rootEncrypted = null;
try {
rootEncrypted = await encryptBytesAesGcm(rootPlain, fileKey, iv, aad);
const storedRoot = await uploadEncryptedObject(rootEncrypted, uploadContext);
onProgress?.({ phase: 'manifest', processedBytes: fileSize, totalBytes: fileSize, chunkCount });
return {
version: 2,
id: storedRoot.id,
url: storedRoot.url,
keyB64Url: bytesToBase64Url(fileKey),
ivPrefixB64Url: bytesToBase64Url(ivPrefix),
name: normalizeFileName(file.name),
mime: String(file.type || 'application/octet-stream').trim().slice(0, 160) || 'application/octet-stream',
size: fileSize,
encryptedSize: pageRefs.reduce((sum, item) => sum + Number(item.encryptedSize || 0), 0) + storedRoot.encryptedSize,
chunkSize: DM_FILE_CHUNK_BYTES,
chunkCount,
torrentV2InfoHashB64Url: torrentInfo.infoHashB64Url,
torrentV2PiecesRootB64Url: piecesRoot ? bytesToBase64Url(piecesRoot) : '',
kind: String(kind || 'file') === 'voice' ? 'voice' : 'file',
durationMs: Math.max(0, Math.floor(Number(durationMs || 0))),
};
} finally {
rootPlain.fill(0);
iv.fill(0);
aad.fill(0);
rootEncrypted?.fill(0);
}
} finally {
fileKey.fill(0);
ivPrefix.fill(0);
singlePieceRoot?.fill(0);
}
}
async function loadV2RootManifest(attachment) {
const id = String(attachment?.id || '').trim();
const url = String(attachment?.url || '').trim();
const key = base64UrlToBytes(String(attachment?.keyB64Url || ''));
const ivPrefix = base64UrlToBytes(String(attachment?.ivPrefixB64Url || ''));
if (!id || !url || key.byteLength !== 32 || ivPrefix.byteLength !== 4) {
key.fill(0);
ivPrefix.fill(0);
throw new Error('В сообщении не хватает данных для расшифровки chunked-файла');
}
const encrypted = await fetchVerifiedObject(url, id);
const iv = rootIv(ivPrefix);
const aad = rootAad(ivPrefix);
let plain;
try {
plain = await decryptBytesAesGcm(encrypted, key, iv, aad);
const manifest = decodeJson(plain);
if (Number(manifest?.v) !== MANIFEST_VERSION || manifest?.scheme !== MANIFEST_SCHEME) {
throw new Error('Неизвестная версия манифеста файла');
}
if (Number(manifest.chunkSize) !== DM_FILE_CHUNK_BYTES) throw new Error('Неожиданный размер chunk в манифесте');
if (Number(manifest.fileSize) !== Number(attachment?.size || 0)) throw new Error('Размер файла не совпал с манифестом');
if (Number(manifest.chunkCount) !== Math.ceil(Number(manifest.fileSize || 0) / DM_FILE_CHUNK_BYTES)) {
throw new Error('Некорректное число частей в манифесте');
}
const piecesRoot = manifest?.torrent?.piecesRoot ? base64UrlToBytes(manifest.torrent.piecesRoot) : null;
const recomputedInfo = await computeTorrentV2InfoHash({
name: normalizeFileName(attachment?.name || 'file'),
size: Number(manifest.fileSize || 0),
piecesRoot,
});
if (recomputedInfo.infoHashB64Url !== String(manifest?.torrent?.infoHash || '')) {
piecesRoot?.fill(0);
throw new Error('BitTorrent v2 infohash манифеста не совпал');
}
piecesRoot?.fill(0);
return { manifest, key, ivPrefix, serverBase: objectBaseFromUrl(url) };
} catch (error) {
key.fill(0);
ivPrefix.fill(0);
throw error;
} finally {
encrypted.fill(0);
iv.fill(0);
aad.fill(0);
plain?.fill(0);
}
}
async function loadV2ManifestPage({ pageRef, pageIndex, key, ivPrefix, serverBase }) {
const id = String(pageRef?.id || '').trim();
if (!id) throw new Error('В манифесте отсутствует идентификатор страницы');
const encrypted = await fetchVerifiedObject(objectUrl(serverBase, id), id);
const iv = pageIv(ivPrefix, pageIndex);
const aad = pageAad(ivPrefix, pageIndex);
let plain;
try {
plain = await decryptBytesAesGcm(encrypted, key, iv, aad);
const page = decodeJson(plain);
if (Number(page?.v) !== MANIFEST_VERSION || Number(page?.page) !== pageIndex || !Array.isArray(page?.chunks)) {
throw new Error('Повреждена страница манифеста');
}
if (Number(pageRef?.count || 0) !== page.chunks.length) throw new Error('Размер страницы манифеста не совпал');
return page.chunks;
} finally {
encrypted.fill(0);
iv.fill(0);
aad.fill(0);
plain?.fill(0);
}
}
async function *iterateV2Chunks(context) {
let expectedIndex = 0;
for (let pageIndex = 0; pageIndex < context.manifest.pages.length; pageIndex += 1) {
const descriptors = await loadV2ManifestPage({
pageRef: context.manifest.pages[pageIndex],
pageIndex,
key: context.key,
ivPrefix: context.ivPrefix,
serverBase: context.serverBase,
});
for (const descriptor of descriptors) {
if (assertSafeIndex(descriptor?.i, 'индекс chunk') !== expectedIndex) {
throw new Error('Нарушен порядок частей файла');
}
expectedIndex += 1;
yield descriptor;
}
}
if (expectedIndex !== Number(context.manifest.chunkCount || 0)) {
throw new Error('Манифест содержит неполный список частей');
}
}
async function decryptV2Chunk(context, descriptor) {
const index = assertSafeIndex(descriptor?.i, 'индекс chunk');
const id = String(descriptor?.id || '').trim();
if (!id) throw new Error('У части файла отсутствует hash');
const encrypted = await fetchVerifiedObject(objectUrl(context.serverBase, id), id);
const iv = chunkIv(context.ivPrefix, index);
const aad = chunkAad(context.ivPrefix, index);
let plain;
try {
plain = await decryptBytesAesGcm(encrypted, context.key, iv, aad);
} catch {
throw new Error(`Не удалось расшифровать часть ${index + 1}`);
} finally {
encrypted.fill(0);
iv.fill(0);
aad.fill(0);
}
if (plain.byteLength !== Number(descriptor?.ps || 0)) {
plain.fill(0);
throw new Error(`Размер расшифрованной части ${index + 1} не совпал`);
}
const expectedPieceHash = String(descriptor?.ph || '').trim();
const pieceRoot = await computeTorrentV2PieceRoot(plain, {
padToPieceLength: Number(context.manifest.fileSize || 0) > TORRENT_V2_PIECE_BYTES,
});
if (!pieceRoot || bytesToBase64Url(pieceRoot) !== expectedPieceHash) {
plain.fill(0);
pieceRoot?.fill(0);
throw new Error(`BitTorrent v2 SHA-256 части ${index + 1} не совпал`);
}
return { plain, pieceRoot };
}
async function verifyCompletedTorrentRoot(context, accumulator, singlePieceRoot) {
const expectedRoot = String(context.manifest?.torrent?.piecesRoot || '');
if (!expectedRoot && Number(context.manifest.fileSize || 0) === 0) return;
const actualRoot = Number(context.manifest.chunkCount || 0) <= 1
? singlePieceRoot
: await accumulator.finalize();
if (!actualRoot || bytesToBase64Url(actualRoot) !== expectedRoot) {
actualRoot?.fill(0);
throw new Error('Итоговый BitTorrent v2 pieces root не совпал');
}
actualRoot.fill(0);
}
async function decryptV2ToSink(attachment, sink, { onProgress = null } = {}) {
const context = await loadV2RootManifest(attachment);
const accumulator = new TorrentV2PieceAccumulator();
let singlePieceRoot = null;
let processedBytes = 0;
try {
for await (const descriptor of iterateV2Chunks(context)) {
const { plain, pieceRoot } = await decryptV2Chunk(context, descriptor);
try {
if (Number(context.manifest.chunkCount || 0) <= 1) singlePieceRoot = pieceRoot.slice();
else await accumulator.addPieceRoot(pieceRoot);
await sink(plain, descriptor);
processedBytes += plain.byteLength;
onProgress?.({
processedBytes,
totalBytes: Number(context.manifest.fileSize || 0),
chunkIndex: Number(descriptor.i),
chunkCount: Number(context.manifest.chunkCount || 0),
});
} finally {
plain.fill(0);
pieceRoot.fill(0);
}
}
if (processedBytes !== Number(context.manifest.fileSize || 0)) {
throw new Error('Итоговый размер расшифрованного файла не совпал');
}
await verifyCompletedTorrentRoot(context, accumulator, singlePieceRoot);
return context.manifest;
} finally {
context.key.fill(0);
context.ivPrefix.fill(0);
singlePieceRoot?.fill(0);
}
}
async function downloadV1(attachment = {}) {
const fileId = String(attachment?.id || '').trim();
const fileUrl = String(attachment?.url || '').trim();
const keyB64Url = String(attachment?.keyB64Url || '').trim();
const ivB64Url = String(attachment?.ivB64Url || '').trim();
if (!fileId || !fileUrl || !keyB64Url || !ivB64Url) throw new Error('В сообщении не хватает данных для расшифровки файла');
const encryptedBytes = await fetchVerifiedObject(fileUrl, fileId);
const keyBytes = base64UrlToBytes(keyB64Url);
const ivBytes = base64UrlToBytes(ivB64Url);
let plainBytes;
try {
plainBytes = await decryptBytesAesGcm(encryptedBytes, keyBytes, ivBytes);
} catch {
throw new Error('Не удалось расшифровать файл: ключ или содержимое повреждены');
} finally {
encryptedBytes.fill(0);
keyBytes.fill(0);
ivBytes.fill(0);
}
if (plainBytes.byteLength !== Number(attachment?.size || 0)) {
plainBytes.fill(0);
throw new Error('Размер расшифрованного файла не совпал с сообщением');
}
return plainBytes;
}
function suggestedPickerTypes(attachment) {
const mime = String(attachment?.mime || '').trim();
if (!mime || mime === 'application/octet-stream') return undefined;
const name = normalizeFileName(attachment?.name || 'file');
const dot = name.lastIndexOf('.');
const extension = dot >= 0 ? name.slice(dot) : '';
return [{
description: 'Файл SHiNE',
accept: { [mime]: extension ? [extension] : [] },
}];
}
function saveBlob(blob, name) {
const objectUrlValue = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrlValue;
anchor.download = normalizeFileName(name || 'file');
anchor.rel = 'noopener';
anchor.style.display = 'none';
document.body.append(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrlValue), 30_000);
}
export async function decryptDmFileToBlob(attachment = {}, options = {}) {
if (Number(attachment?.version || 1) < 2) {
const plain = await downloadV1(attachment);
const blob = new Blob([plain], { type: String(attachment?.mime || 'application/octet-stream') });
plain.fill(0);
return blob;
}
const parts = [];
await decryptV2ToSink(attachment, async (plain) => {
parts.push(plain.slice().buffer);
}, options);
return new Blob(parts, { type: String(attachment?.mime || 'application/octet-stream') || 'application/octet-stream' });
}
export async function downloadAndDecryptDmFile(attachment = {}, { onProgress = null } = {}) {
if (Number(attachment?.version || 1) < 2) {
const plain = await downloadV1(attachment);
const blob = new Blob([plain], { type: String(attachment?.mime || 'application/octet-stream') });
plain.fill(0);
saveBlob(blob, attachment?.name);
return { streamed: false, size: blob.size };
}
let writable = null;
if (typeof window.showSaveFilePicker === 'function') {
try {
const handle = await window.showSaveFilePicker({
suggestedName: normalizeFileName(attachment?.name || 'file'),
types: suggestedPickerTypes(attachment),
});
writable = await handle.createWritable();
} catch (error) {
if (error?.name === 'AbortError') return { cancelled: true };
// Some browsers expose the API but reject certain MIME/type descriptors.
try {
const handle = await window.showSaveFilePicker({ suggestedName: normalizeFileName(attachment?.name || 'file') });
writable = await handle.createWritable();
} catch (secondError) {
if (secondError?.name === 'AbortError') return { cancelled: true };
}
}
}
if (writable) {
try {
await decryptV2ToSink(attachment, async (plain) => {
await writable.write(plain);
}, { onProgress });
await writable.close();
writable = null;
return { streamed: true, size: Number(attachment?.size || 0) };
} catch (error) {
try { await writable?.abort?.(); } catch { /* ignore */ }
throw error;
}
}
// Cross-browser fallback: still chunk-download/decrypt, but the final Blob is held
// in memory because Firefox/Safari do not yet expose a writable download stream.
const blob = await decryptDmFileToBlob(attachment, { onProgress });
saveBlob(blob, attachment?.name);
return { streamed: false, size: blob.size };
}
export async function buildDmFileTorrentV2(attachment = {}) {
if (Number(attachment?.version || 1) < 2) throw new Error('Torrent-v2 metadata есть только у файлов SHiNE v2');
const context = await loadV2RootManifest(attachment);
const pieceLayer = [];
try {
for await (const descriptor of iterateV2Chunks(context)) {
if (Number(context.manifest.fileSize || 0) > TORRENT_V2_PIECE_BYTES) {
const hash = base64UrlToBytes(String(descriptor?.ph || ''));
if (hash.byteLength !== 32) throw new Error('Повреждён torrent piece layer');
pieceLayer.push(hash);
}
}
const piecesRoot = context.manifest?.torrent?.piecesRoot
? base64UrlToBytes(context.manifest.torrent.piecesRoot)
: null;
const bytes = buildTorrentV2MetainfoBytes({
name: normalizeFileName(attachment?.name || 'file'),
size: Number(attachment?.size || 0),
piecesRoot,
pieceLayer,
});
piecesRoot?.fill(0);
pieceLayer.forEach((hash) => hash.fill(0));
return new Blob([bytes], { type: 'application/x-bittorrent' });
} finally {
context.key.fill(0);
context.ivPrefix.fill(0);
}
}
+103
View File
@@ -8,6 +8,8 @@ function defaultParsed(rawText = '') {
blocks: [],
replyRef: null,
callSummary: null,
fileAttachment: null,
fileAttachments: [],
};
}
@@ -87,6 +89,97 @@ export function buildDmReplyTechBlock({ baseKey = '' } = {}) {
return `<S:reply;v=1;id=${cleanBaseKey}>`;
}
function decodeFileField(value = '') {
try {
return decodeURIComponent(String(value || ''));
} catch {
return String(value || '');
}
}
function normalizeFileAttachment(fields = {}) {
const version = Number(fields.v || 0);
const id = String(fields.id || '').trim();
const url = decodeFileField(fields.url || '').trim();
const keyB64Url = String(fields.key || '').trim();
const name = decodeFileField(fields.name || '').trim() || 'file';
const mime = decodeFileField(fields.mime || '').trim() || 'application/octet-stream';
const size = Number(fields.size || 0);
const encryptedSize = Number(fields.encsize || 0);
if (!id || !url || !keyB64Url || !Number.isFinite(size) || size < 0) return null;
if (version >= 2) {
const ivPrefixB64Url = String(fields.ivp || '').trim();
if (!ivPrefixB64Url) return null;
const chunkSize = Number(fields.chunk || 0);
const chunkCount = Number(fields.chunks || 0);
const kind = String(fields.kind || 'file').trim().toLowerCase() === 'voice' ? 'voice' : 'file';
const durationMs = Math.max(0, Math.floor(Number(fields.dur || 0)));
return {
version,
id,
url,
keyB64Url,
ivPrefixB64Url,
name,
mime,
size,
encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0,
chunkSize: Number.isFinite(chunkSize) && chunkSize > 0 ? chunkSize : 0,
chunkCount: Number.isFinite(chunkCount) && chunkCount >= 0 ? Math.floor(chunkCount) : 0,
torrentV2InfoHashB64Url: String(fields.th || '').trim(),
torrentV2PiecesRootB64Url: String(fields.pr || '').trim(),
kind,
durationMs,
};
}
const ivB64Url = String(fields.iv || '').trim();
if (!ivB64Url) return null;
return {
version: version || 1,
id,
url,
keyB64Url,
ivB64Url,
name,
mime,
size,
encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0,
kind: 'file',
durationMs: 0,
};
}
export function buildDmFileTechBlock(attachment = {}) {
const version = Math.max(1, Math.floor(Number(attachment?.version || 1)));
const id = String(attachment?.id || '').trim();
const url = String(attachment?.url || '').trim();
const keyB64Url = String(attachment?.keyB64Url || '').trim();
const size = Math.max(0, Math.floor(Number(attachment?.size || 0)));
const encryptedSize = Math.max(0, Math.floor(Number(attachment?.encryptedSize || 0)));
if (!id || !url || !keyB64Url) throw new Error('Не хватает данных для технического блока файла');
const name = encodeURIComponent(String(attachment?.name || 'file'));
const mime = encodeURIComponent(String(attachment?.mime || 'application/octet-stream'));
const encodedUrl = encodeURIComponent(url);
if (version >= 2) {
const ivPrefix = String(attachment?.ivPrefixB64Url || '').trim();
if (!ivPrefix) throw new Error('Не хватает IV prefix для chunked-файла');
const chunkSize = Math.max(0, Math.floor(Number(attachment?.chunkSize || 0)));
const chunkCount = Math.max(0, Math.floor(Number(attachment?.chunkCount || 0)));
const torrentHash = String(attachment?.torrentV2InfoHashB64Url || '').trim();
const piecesRoot = String(attachment?.torrentV2PiecesRootB64Url || '').trim();
const kind = String(attachment?.kind || 'file') === 'voice' ? 'voice' : 'file';
const durationMs = Math.max(0, Math.floor(Number(attachment?.durationMs || 0)));
return `<S:file;v=2;id=${id};url=${encodedUrl};key=${keyB64Url};ivp=${ivPrefix};name=${name};mime=${mime};size=${size};encsize=${encryptedSize};chunk=${chunkSize};chunks=${chunkCount};th=${torrentHash};pr=${piecesRoot};kind=${kind};dur=${durationMs}>`;
}
const ivB64Url = String(attachment?.ivB64Url || '').trim();
if (!ivB64Url) throw new Error('Не хватает IV для файла v1');
return `<S:file;v=1;id=${id};url=${encodedUrl};key=${keyB64Url};iv=${ivB64Url};name=${name};mime=${mime};size=${size};encsize=${encryptedSize}>`;
}
export function parseDmTechBlocks(rawText = '') {
const text = String(rawText || '');
if (!hasTechPrefix(text)) return defaultParsed(text);
@@ -95,6 +188,8 @@ export function parseDmTechBlocks(rawText = '') {
let cursor = 0;
let replyRef = null;
let callSummary = null;
let fileAttachment = null;
const fileAttachments = [];
while (hasTechPrefix(text, cursor)) {
const end = text.indexOf('>', cursor);
@@ -142,6 +237,12 @@ export function parseDmTechBlocks(rawText = '') {
reason: String(fields.reason || '').trim().toLowerCase(),
};
}
} else if (kind === 'file') {
const attachment = normalizeFileAttachment(fields);
if (attachment) {
fileAttachments.push(attachment);
if (!fileAttachment) fileAttachment = attachment;
}
}
cursor = end + 1;
@@ -157,5 +258,7 @@ export function parseDmTechBlocks(rawText = '') {
blocks,
replyRef,
callSummary,
fileAttachment,
fileAttachments,
};
}
+43
View File
@@ -0,0 +1,43 @@
const STORAGE_KEYS = Object.freeze({
developerTools: 'shine-feature-developer-tools-v1',
dmFileTransfer: 'shine-feature-dm-file-transfer-v1',
});
function readBoolean(key, defaultValue = true) {
try {
const raw = localStorage.getItem(key);
if (raw == null) return Boolean(defaultValue);
return raw !== '0' && raw !== 'false';
} catch {
return Boolean(defaultValue);
}
}
function writeBoolean(key, enabled) {
const value = Boolean(enabled);
try {
localStorage.setItem(key, value ? '1' : '0');
} catch {
// В приватном режиме/при запрете storage оставляем значение только на уровне default.
}
window.dispatchEvent(new CustomEvent('shine-feature-settings-updated', {
detail: { key, enabled: value },
}));
return value;
}
export function isDeveloperToolsEnabled() {
return readBoolean(STORAGE_KEYS.developerTools, false);
}
export function setDeveloperToolsEnabled(enabled) {
return writeBoolean(STORAGE_KEYS.developerTools, enabled);
}
export function isDmFileTransferEnabled() {
return readBoolean(STORAGE_KEYS.dmFileTransfer, true);
}
export function setDmFileTransferEnabled(enabled) {
return writeBoolean(STORAGE_KEYS.dmFileTransfer, enabled);
}
+33 -2
View File
@@ -1,4 +1,4 @@
import { base58ToBytes, base64ToBytes, bytesToBase58, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js';
import { base58ToBytes, base64ToBytes, base64UrlToBytes, bytesToBase58, bytesToBase64Url, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from './client-key-utils.js';
import {
SHINE_LOGIN_GUARD_PROGRAM_ID,
@@ -27,6 +27,7 @@ const BLOCK_TYPE_SERVER_PROFILE = 30;
const BLOCK_TYPE_ACCESS_SERVERS = 40;
const BLOCK_TYPE_SESSIONS = 50;
const BLOCK_TYPE_TRUSTED_STATE = 70;
const BLOCK_TYPE_ARCHIVE_HEAD = 100;
const SESSIONS_MODE_MIXED = 1;
const SESSION_TYPE_USER = 1;
const SESSION_TYPE_HOMESERVER = 100;
@@ -326,6 +327,8 @@ function createPdaState({
sessionsMode,
sessions,
trustedCount,
archiveHeadTxId = '',
archiveHeadHash = null,
}) {
const serverProfile = isServer ? {
addressFormatType: Number(addressFormatType || 0),
@@ -360,6 +363,8 @@ function createPdaState({
sessionPubKey32: x?.sessionPubKey32 instanceof Uint8Array ? x.sessionPubKey32 : new Uint8Array(x?.sessionPubKey32 || 32),
})) : [],
trustedCount: Number(trustedCount || 0) & 0xff,
archiveHeadTxId: String(archiveHeadTxId || '').trim(),
archiveHeadHash: archiveHeadHash instanceof Uint8Array ? archiveHeadHash : new Uint8Array(archiveHeadHash || 32),
};
}
@@ -445,6 +450,8 @@ export function parseShineUserPda(dataBytes) {
let sessionsMode = SESSIONS_MODE_MIXED;
let sessions = [];
let trustedCount = 0;
let archiveHeadTxId = '';
let archiveHeadHash = new Uint8Array(32);
for (let i = 0; i < blocksCount; i += 1) {
const blockType = reader.readU8();
@@ -528,6 +535,11 @@ export function parseShineUserPda(dataBytes) {
trustedCount = reader.readU8();
continue;
}
if (blockType === BLOCK_TYPE_ARCHIVE_HEAD) {
archiveHeadTxId = bytesToBase64Url(reader.readBytes(32));
archiveHeadHash = reader.readBytes(32);
continue;
}
throw new Error(`Неизвестный блок PDA: ${blockType}`);
}
@@ -556,6 +568,8 @@ export function parseShineUserPda(dataBytes) {
sessionsMode,
sessions,
trustedCount,
archiveHeadTxId,
archiveHeadHash,
});
return {
@@ -586,6 +600,8 @@ export function serializeUnsignedRecordFromState(stateLike) {
sessionsMode: stateLike.sessionsMode,
sessions: stateLike.sessions,
trustedCount: stateLike.trustedCount,
archiveHeadTxId: stateLike.archiveHeadTxId,
archiveHeadHash: stateLike.archiveHeadHash,
});
const buf = [0x53, 0x48, 0x69, 0x4e, 0x45, 1, 0, 0, 0];
@@ -594,7 +610,8 @@ export function serializeUnsignedRecordFromState(stateLike) {
pushU32LE(buf, state.recordNumber);
for (const x of state.prevRecordHash) buf.push(x);
pushStrU8(buf, state.login);
buf.push(state.isServer ? 8 : 7);
const hasArchiveHead = Boolean(state.archiveHeadTxId);
buf.push((state.isServer ? 8 : 7) + (hasArchiveHead ? 1 : 0));
buf.push(BLOCK_TYPE_RECOVERY_KEY, 0);
for (const x of state.recoveryKey) buf.push(x);
@@ -636,6 +653,16 @@ export function serializeUnsignedRecordFromState(stateLike) {
buf.push(BLOCK_TYPE_TRUSTED_STATE, 0, state.trustedCount & 0xff);
if (hasArchiveHead) {
const txId32 = base64UrlToBytes(state.archiveHeadTxId);
if (txId32.length !== 32 || state.archiveHeadHash.length !== 32) {
throw new Error('Archive head должен содержать TX ID/hash по 32 байта');
}
buf.push(BLOCK_TYPE_ARCHIVE_HEAD, 0);
for (const x of txId32) buf.push(x);
for (const x of state.archiveHeadHash) buf.push(x);
}
const recordLen = buf.length + 64;
buf[7] = recordLen & 0xff;
buf[8] = (recordLen >>> 8) & 0xff;
@@ -1037,6 +1064,8 @@ export async function updateShineUserPdaOnSolana({
serverProfile,
accessServers,
trustedCount,
archiveHeadTxId = '',
archiveHeadHash = null,
}) {
const current = await readShineUserPda({ login, solanaEndpoint });
const cleanLogin = current.login;
@@ -1134,6 +1163,8 @@ export async function updateShineUserPdaOnSolana({
sessionsMode: current.sessionsMode,
sessions: current.sessions,
trustedCount: trustedCount == null ? current.trustedCount : trustedCount,
archiveHeadTxId: current.archiveHeadTxId,
archiveHeadHash: current.archiveHeadHash,
});
const unsignedNext = serializeUnsignedRecordFromState(nextState);
+234
View File
@@ -0,0 +1,234 @@
import { bytesToBase64Url, sha256Bytes } from './crypto-utils.js';
export const TORRENT_V2_BLOCK_BYTES = 16 * 1024;
export const TORRENT_V2_PIECE_BYTES = 1024 * 1024;
const ZERO_HASH = new Uint8Array(32);
const encoder = new TextEncoder();
function concatBytes(parts = []) {
const arrays = parts.map((part) => part instanceof Uint8Array ? part : new Uint8Array(part || 0));
const total = arrays.reduce((sum, part) => sum + part.byteLength, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of arrays) {
out.set(part, offset);
offset += part.byteLength;
}
return out;
}
async function hashPair(left, right) {
return sha256Bytes(concatBytes([left, right]));
}
function nextPowerOfTwo(value) {
let n = 1;
while (n < value) n *= 2;
return n;
}
async function reduceMerkleLevel(nodes) {
if (nodes.length <= 1) return nodes;
const next = [];
for (let i = 0; i < nodes.length; i += 2) {
next.push(hashPair(nodes[i], nodes[i + 1]));
}
return Promise.all(next);
}
/**
* BEP 52 pieces root / piece-layer hash for one piece.
* Leaves are SHA-256 hashes of 16 KiB blocks. Padding leaves are 32 zero bytes,
* exactly as required by BitTorrent v2.
*/
export async function computeTorrentV2PieceRoot(pieceBytes, { padToPieceLength = false } = {}) {
const bytes = pieceBytes instanceof Uint8Array ? pieceBytes : new Uint8Array(pieceBytes || 0);
if (!bytes.byteLength) return null;
const blockCount = Math.ceil(bytes.byteLength / TORRENT_V2_BLOCK_BYTES);
const leafPromises = [];
for (let offset = 0; offset < bytes.byteLength; offset += TORRENT_V2_BLOCK_BYTES) {
leafPromises.push(sha256Bytes(bytes.subarray(offset, Math.min(bytes.byteLength, offset + TORRENT_V2_BLOCK_BYTES))));
}
let level = await Promise.all(leafPromises);
const targetLeaves = padToPieceLength
? (TORRENT_V2_PIECE_BYTES / TORRENT_V2_BLOCK_BYTES)
: nextPowerOfTwo(Math.max(1, blockCount));
while (level.length < targetLeaves) level.push(ZERO_HASH.slice());
while (level.length > 1) level = await reduceMerkleLevel(level);
return level[0];
}
let zeroPieceRootPromise = null;
export function getTorrentV2ZeroPieceRoot() {
if (!zeroPieceRootPromise) {
zeroPieceRootPromise = (async () => {
let level = Array.from(
{ length: TORRENT_V2_PIECE_BYTES / TORRENT_V2_BLOCK_BYTES },
() => ZERO_HASH.slice(),
);
while (level.length > 1) level = await reduceMerkleLevel(level);
return level[0];
})();
}
return zeroPieceRootPromise;
}
/**
* Streaming Merkle accumulator for the BEP 52 piece layer. It keeps only O(log n)
* hashes in memory and pads the right side with the standard zero-piece subtree.
*/
export class TorrentV2PieceAccumulator {
constructor() {
this.stack = [];
this.count = 0;
}
async #addSubtree(hash, level) {
let current = hash;
let currentLevel = level;
while (this.stack[currentLevel]) {
current = await hashPair(this.stack[currentLevel], current);
this.stack[currentLevel] = null;
currentLevel += 1;
}
this.stack[currentLevel] = current;
}
async addPieceRoot(hash) {
if (!(hash instanceof Uint8Array) || hash.byteLength !== 32) {
throw new Error('Некорректный torrent-v2 piece hash');
}
await this.#addSubtree(hash, 0);
this.count += 1;
}
async finalize() {
if (this.count <= 0) return null;
let target = 1;
while (target < this.count) target *= 2;
let remaining = target - this.count;
let count = this.count;
const zeroRoots = [await getTorrentV2ZeroPieceRoot()];
const ensureZeroLevel = async (level) => {
while (zeroRoots.length <= level) {
const previous = zeroRoots[zeroRoots.length - 1];
zeroRoots.push(await hashPair(previous, previous));
}
return zeroRoots[level];
};
while (remaining > 0) {
let maxByRemaining = Math.floor(Math.log2(remaining));
let alignmentLevel = 0;
let aligned = count;
while (aligned > 0 && aligned % 2 === 0) {
alignmentLevel += 1;
aligned /= 2;
}
const level = Math.min(maxByRemaining, alignmentLevel);
const blockLeaves = 2 ** level;
await this.#addSubtree(await ensureZeroLevel(level), level);
count += blockLeaves;
remaining -= blockLeaves;
}
const root = this.stack.findLast?.((value) => value) || [...this.stack].reverse().find((value) => value) || null;
return root ? root.slice() : null;
}
}
function encodeBString(bytes) {
const data = bytes instanceof Uint8Array ? bytes : encoder.encode(String(bytes ?? ''));
return concatBytes([encoder.encode(`${data.byteLength}:`), data]);
}
function compareByteArrays(left, right) {
const limit = Math.min(left.byteLength, right.byteLength);
for (let i = 0; i < limit; i += 1) {
if (left[i] !== right[i]) return left[i] - right[i];
}
return left.byteLength - right.byteLength;
}
function encodeBValue(value) {
if (value instanceof Uint8Array) return encodeBString(value);
if (typeof value === 'string') return encodeBString(encoder.encode(value));
if (typeof value === 'number' || typeof value === 'bigint') {
const integer = typeof value === 'bigint' ? value : BigInt(Math.trunc(value));
return encoder.encode(`i${integer.toString()}e`);
}
if (Array.isArray(value)) {
return concatBytes([encoder.encode('l'), ...value.map(encodeBValue), encoder.encode('e')]);
}
if (value && typeof value === 'object') {
const entries = Object.entries(value).map(([key, item]) => ({
keyBytes: encoder.encode(key),
value: item,
})).sort((a, b) => compareByteArrays(a.keyBytes, b.keyBytes));
const parts = [encoder.encode('d')];
for (const entry of entries) {
parts.push(encodeBString(entry.keyBytes), encodeBValue(entry.value));
}
parts.push(encoder.encode('e'));
return concatBytes(parts);
}
throw new Error('Неподдерживаемое значение bencode');
}
export function buildTorrentV2InfoBytes({ name, size, piecesRoot, pieceLength = TORRENT_V2_PIECE_BYTES } = {}) {
const cleanName = String(name || 'file');
const fileData = { length: Math.max(0, Math.trunc(Number(size || 0))) };
if (fileData.length > 0) {
if (!(piecesRoot instanceof Uint8Array) || piecesRoot.byteLength !== 32) {
throw new Error('Для непустого файла нужен 32-байтный pieces root');
}
fileData['pieces root'] = piecesRoot;
}
return encodeBValue({
'file tree': {
[cleanName]: {
'': fileData,
},
},
'meta version': 2,
name: cleanName,
'piece length': Math.trunc(pieceLength),
});
}
export async function computeTorrentV2InfoHash({ name, size, piecesRoot } = {}) {
const infoBytes = buildTorrentV2InfoBytes({ name, size, piecesRoot });
const hash = await sha256Bytes(infoBytes);
return {
infoBytes,
infoHash: hash,
infoHashB64Url: bytesToBase64Url(hash),
};
}
/** Builds a tracker-less BitTorrent v2 metainfo file. */
export function buildTorrentV2MetainfoBytes({ name, size, piecesRoot, pieceLayer = [] } = {}) {
const infoBytes = buildTorrentV2InfoBytes({ name, size, piecesRoot });
const outer = [encoder.encode('d'), encodeBString(encoder.encode('info')), infoBytes];
if (Number(size || 0) > TORRENT_V2_PIECE_BYTES) {
if (!(piecesRoot instanceof Uint8Array) || piecesRoot.byteLength !== 32) {
throw new Error('Некорректный pieces root');
}
const hashes = (Array.isArray(pieceLayer) ? pieceLayer : []).map((hash) => {
if (!(hash instanceof Uint8Array) || hash.byteLength !== 32) throw new Error('Некорректный piece layer');
return hash;
});
const layerBytes = concatBytes(hashes);
outer.push(
encodeBString(encoder.encode('piece layers')),
encoder.encode('d'),
encodeBString(piecesRoot),
encodeBString(layerBytes),
encoder.encode('e'),
);
}
outer.push(encoder.encode('e'));
return concatBytes(outer);
}
+398
View File
@@ -1275,3 +1275,401 @@
text-shadow: 0 0 5px rgba(92, 190, 255, 0.72), 0 0 12px rgba(72, 145, 255, 0.34);
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.46));
}
/* ===== Зашифрованные вложения в личных сообщениях ===== */
.dm-file-btn {
position: absolute;
right: 45px;
bottom: 52px;
z-index: 6;
min-width: 43px;
width: 43px;
height: 43px;
padding: 0;
border-radius: 14px;
font-size: 21px;
line-height: 1;
background: rgba(13, 28, 54, 0.94);
border: 1px solid rgba(183, 207, 242, 0.28);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255,255,255,.08);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.dm-file-btn[hidden] {
display: none;
}
.dm-file-btn:disabled {
opacity: 0.7;
cursor: progress;
}
.dm-file-card {
min-width: min(285px, 72vw);
max-width: min(330px, 76vw);
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
}
.dm-file-card__icon {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
font-size: 19px;
}
.dm-file-card__copy {
min-width: 0;
display: grid;
gap: 3px;
}
.dm-file-card__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 680;
}
.dm-file-card__meta {
font-size: 11px;
color: rgba(205, 219, 242, 0.67);
}
.dm-file-card__download {
min-height: 34px;
padding: 0 10px;
border-radius: 11px;
font-size: 12px;
font-weight: 650;
white-space: nowrap;
}
.dm-file-card__download:disabled {
opacity: 0.65;
cursor: progress;
}
@media (max-width: 390px) {
.dm-screen .dm-file-card {
min-width: min(264px, 76vw);
grid-template-columns: 32px minmax(0, 1fr);
}
.dm-screen .dm-file-card__download {
grid-column: 2;
justify-self: start;
}
}
.dm-chat-input--multiline .dm-file-btn {
right: 0;
bottom: 104px;
}
/* ===== DM file transfer v2 + voice notes ===== */
.dm-attachment-stack {
display: grid;
gap: 8px;
min-width: 0;
}
.dm-send-icon-btn svg {
width: 22px;
height: 22px;
display: block;
margin: auto;
}
.dm-send-icon-btn.is-recording {
transform: scale(1.04);
box-shadow: 0 0 0 5px rgba(255, 104, 126, 0.08), 0 0 24px rgba(255, 104, 126, 0.18);
}
.dm-send-icon-btn.is-recording:not(.is-locked) {
color: rgba(255, 190, 202, 0.98);
}
.dm-send-icon-btn.is-locked {
color: rgba(255, 218, 226, 0.98);
background: rgba(164, 45, 70, 0.28);
border-color: rgba(255, 150, 171, 0.32);
}
.dm-send-busy-dot {
display: inline-grid;
place-items: center;
width: 100%;
height: 100%;
font-size: 20px;
line-height: 1;
}
.dm-voice-recording-status {
grid-column: 1;
min-height: 42px;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 14px;
background: rgba(255, 255, 255, 0.035);
overflow: hidden;
}
.dm-voice-recording-status[hidden] {
display: none;
}
.dm-voice-recording-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 104, 126, 0.96);
box-shadow: 0 0 12px rgba(255, 104, 126, 0.55);
animation: dmVoicePulse 1s ease-in-out infinite;
}
@keyframes dmVoicePulse {
0%, 100% { opacity: 0.55; transform: scale(0.82); }
50% { opacity: 1; transform: scale(1); }
}
.dm-voice-recording-time {
font-variant-numeric: tabular-nums;
font-size: 13px;
font-weight: 720;
color: rgba(255, 230, 235, 0.96);
}
.dm-voice-recording-hint {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
font-size: 11px;
color: rgba(210, 222, 241, 0.66);
}
.dm-chat-input--recording .dm-actions-col {
align-self: center;
}
.dm-voice-card {
min-width: min(280px, 72vw);
max-width: min(330px, 76vw);
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.dm-voice-card__play {
width: 42px;
height: 42px;
min-width: 42px;
padding: 0;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 15px;
line-height: 1;
background: rgba(92, 190, 255, 0.12);
border: 1px solid rgba(159, 211, 255, 0.22);
}
.dm-voice-card__play:disabled {
opacity: 0.65;
cursor: progress;
}
.dm-voice-card__body {
min-width: 0;
display: grid;
gap: 6px;
}
.dm-voice-card__track {
position: relative;
width: 100%;
height: 18px;
min-height: 18px;
padding: 0;
border: 0;
background: transparent;
overflow: hidden;
}
.dm-voice-card__track::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 8px;
height: 2px;
border-radius: 999px;
background: rgba(220, 234, 255, 0.20);
}
.dm-voice-card__fill {
position: absolute;
left: 0;
top: 7px;
width: 0;
height: 4px;
border-radius: 999px;
background: currentColor;
box-shadow: 0 0 8px rgba(92, 190, 255, 0.35);
pointer-events: none;
}
.dm-voice-card__meta {
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 11px;
color: rgba(205, 219, 242, 0.68);
font-variant-numeric: tabular-nums;
}
@media (max-width: 390px) {
.dm-voice-card {
min-width: min(252px, 76vw);
}
.dm-voice-recording-hint {
max-width: 150px;
}
}
/* Пересылка DM: выбор чата и подтверждение остаются внутри одного feature-owned modal. */
.dm-forward-modal {
align-items: center;
padding: 18px;
}
.dm-forward-card {
width: min(92vw, 430px);
max-height: min(78vh, 680px);
overflow: hidden;
gap: 12px;
border: 1px solid rgba(104, 193, 255, 0.25);
border-radius: 24px;
background: linear-gradient(155deg, rgba(20, 31, 49, 0.98), rgba(7, 13, 24, 0.98));
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.52), 0 0 34px rgba(56, 165, 255, 0.12);
backdrop-filter: blur(24px) saturate(130%);
-webkit-backdrop-filter: blur(24px) saturate(130%);
}
.dm-forward-head,
.dm-forward-confirm-peer {
display: flex;
align-items: center;
gap: 12px;
}
.dm-forward-head {
justify-content: space-between;
}
.dm-forward-close {
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 25px;
line-height: 1;
color: rgba(255, 255, 255, 0.78);
background: rgba(255, 255, 255, 0.05);
}
.dm-forward-preview {
padding: 11px 13px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
color: rgba(242, 247, 255, 0.76);
background: rgba(255, 255, 255, 0.035);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dm-forward-preview--confirm {
white-space: normal;
max-height: 128px;
overflow: auto;
}
.dm-forward-list {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 120px;
max-height: min(54vh, 460px);
overflow-y: auto;
padding-right: 2px;
}
.dm-forward-chat-row {
width: 100%;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 11px;
min-height: 62px;
padding: 8px 10px;
border: 0;
border-radius: 15px;
text-align: left;
color: var(--text);
background: transparent;
}
.dm-forward-chat-row:hover,
.dm-forward-chat-row:focus-visible {
outline: none;
background: rgba(67, 166, 255, 0.11);
}
.dm-forward-chat-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.dm-forward-chat-copy strong,
.dm-forward-chat-copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dm-forward-chat-arrow {
font-size: 28px;
color: rgba(130, 205, 255, 0.7);
}
.dm-forward-loading {
padding: 20px 8px;
text-align: center;
}
.dm-forward-confirm-peer {
padding: 12px;
border-radius: 16px;
background: rgba(67, 166, 255, 0.08);
}
.dm-forward-confirm-actions {
margin-top: 2px;
}
+1
View File
@@ -404,6 +404,7 @@ button.dm-via-node:hover { border-color: rgba(25, 229, 138, 0.5); }
.dm-actions-col {
position: relative;
display: grid;
grid-template-columns: repeat(2, 46px);
grid-template-rows: 46px;
+110
View File
@@ -270,3 +270,113 @@
}
.language-choice-actions .shine-btn { height: 50px; }
/* ===== Скрытые расширенные настройки ===== */
.settings-shine-logo-button {
width: min(72vw, 280px);
min-height: 118px;
margin: 2px auto 10px;
padding: 6px 12px;
display: grid;
place-items: center;
border: 0;
background: transparent;
box-shadow: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.settings-shine-logo-button:hover,
.settings-shine-logo-button:focus,
.settings-shine-logo-button:active {
border: 0;
background: transparent;
box-shadow: none;
transform: none;
}
.settings-shine-logo-button:focus-visible {
outline: 2px solid rgba(167, 205, 255, 0.7);
outline-offset: 4px;
border-radius: 22px;
}
.settings-shine-logo {
display: block;
width: 100%;
max-height: 126px;
object-fit: contain;
pointer-events: none;
filter: drop-shadow(0 12px 30px rgba(73, 126, 216, 0.16));
}
.settings-feature-toggle-row {
min-height: 76px;
padding: 15px 16px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
cursor: pointer;
}
.settings-feature-toggle-copy {
min-width: 0;
display: grid;
gap: 5px;
}
.settings-feature-toggle-title {
font-size: 15px;
font-weight: 700;
color: rgba(246, 250, 255, 0.96);
}
.settings-feature-toggle-hint {
font-size: 12px;
line-height: 1.4;
color: rgba(194, 210, 236, 0.68);
}
.settings-feature-switch {
appearance: none;
-webkit-appearance: none;
width: 48px;
height: 28px;
margin: 0;
padding: 2px;
border: 1px solid rgba(189, 207, 238, 0.26);
border-radius: 999px;
background: rgba(8, 17, 34, 0.72);
box-shadow: inset 0 2px 7px rgba(0, 0, 0, 0.28);
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease;
}
.settings-feature-switch::after {
content: '';
display: block;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(235, 243, 255, 0.9);
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.28);
transition: transform 150ms ease;
}
.settings-feature-switch:checked {
border-color: rgba(86, 174, 255, 0.6);
background: rgba(38, 132, 237, 0.48);
box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 0 16px rgba(58, 148, 255, 0.12);
}
.settings-feature-switch:checked::after {
transform: translateX(20px);
background: #ffffff;
}
.settings-feature-switch:focus-visible {
outline: 2px solid rgba(190, 220, 255, 0.82);
outline-offset: 3px;
}
+28 -7
View File
@@ -203,17 +203,24 @@
/* Маленький знак официального пользователя. Он находится внутри .node-dot, поэтому автоматически
масштабируется вместе с аватаркой при focus/zoom/анимациях графа. */
.fg-official-badge {
/* ВАЖНО: селектор намеренно специфичнее глобального `.node-dot img` из features/network.css,
где обычные фото стартуют с opacity:0 и width/height:100%. Иначе badge тоже наследует эти
правила и становится полностью невидимым. */
.fg-node .node-dot .fg-official-badge {
position: absolute;
left: -2%;
bottom: 0;
width: 15%;
height: 15%;
min-width: 8px;
min-height: 8px;
bottom: -1%;
width: 16%;
height: 16%;
min-width: 9px;
min-height: 9px;
object-fit: contain;
object-position: center;
display: block;
z-index: 5;
opacity: 1;
border-radius: 0;
transition: none;
z-index: 20;
pointer-events: none;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42));
}
@@ -452,6 +459,20 @@
box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12), 0 0 14px rgba(110, 210, 255, 0.28);
}
/* Служебные переключатели карты: история и X2 намеренно чуть компактнее основных фильтров. */
.fg-history-chip {
padding-left: 11px;
padding-right: 11px;
}
.fg-x2-chip {
min-width: 36px;
padding-left: 9px;
padding-right: 9px;
font-weight: 750;
letter-spacing: 0.02em;
}
/* Контекстное меню узла (долгое нажатие) — в #modal-root, поверх всего, не масштабируется */
.fg-menu-overlay {
position: fixed;
@@ -94,16 +94,17 @@ UserPdaRecordV1
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. |
| `70` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `100` | `ArchiveHeadBlock` | Текущая голова серверного SHINE-ARCHIVE: Arweave TX ID + SHA-256 архива. |
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
Правила:
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`;
- каждый обязательный блок должен встречаться ровно один раз;
- порядок блоков в записи фиксируется для простоты проверки:
`RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`.
`RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`, `ArchiveHead`.
## 6. RecoveryKeyBlock
@@ -359,6 +360,28 @@ TrustedStateBlock
Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат.
## 15.1. ArchiveHeadBlock
Необязательный блок текущей головы серверного архива. Он используется archive-capable сервером и хранится в том же User PDA.
```text
ArchiveHeadBlock
- block_type: u8 = 100
- block_version: u8 = 0
- archive_tx_id: [u8; 32]
- archive_hash: [u8; 32]
```
Семантика:
- `archive_tx_id` — raw 32-byte Arweave transaction id последнего опубликованного большого `SHINE-ARCHIVE`; текстовая Base64URL-форма получается вне PDA;
- `archive_hash` — SHA-256 большого archive block по правилам `docs/Archive/01_PROTOCOL_v1.0.md`;
- отсутствие block `100` означает, что аккаунт ещё не объявлял archive head;
- обычный legacy `update_user_pda`, в instruction которого archive extension отсутствует, **обязан сохранить существующий ArchiveHeadBlock без изменений**;
- расширенный `update_user_pda` может заменить archive head или явно очистить его; отдельной Solana instruction для архива нет.
`ArchiveHeadBlock` входит в unsigned bytes User PDA и тем самым покрывается обычной root-подписью записи.
## 16. Подпись user_pda
Подписывается не вся PDA целиком, а unsigned-часть записи:
@@ -392,6 +415,7 @@ Solana-программа проверяет подпись через встр
- обязательные блоки присутствуют;
- создается минимум один `BlockchainRecord`;
- новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`;
- `ArchiveHeadBlock` при регистрации не обязателен; обычный пользователь/сервер может начать публиковать архив позже;
- стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит;
- `used_bytes <= paid_limit_bytes`;
- пользователь платит регистрационную комиссию;
@@ -408,6 +432,7 @@ Solana-программа проверяет подпись через встр
- `prev_record_hash` равен хэшу unsigned-части предыдущей записи;
- `updated_at_ms` обновляется;
- unsigned-часть новой записи подписана `root_key`;
- если archive extension в instruction отсутствует (legacy client), старый `ArchiveHeadBlock` сохраняется; если extension присутствует, применяется переданное `archive_head_update`;
- лимиты блокчейнов могут только увеличиваться;
- занятый размер и номер последнего блока не могут уменьшаться;
- при увеличении оплаченного лимита пользователь доплачивает комиссию;
@@ -753,3 +753,23 @@ signature = Ed25519(blockchain_private_key, message_hash)
- Anchor discriminator'ы и Anchor-ABI инструкций;
- старые seed'ы, которые конфликтовали с уже существующим Anchor-состоянием в devnet;
- внутренние helper-функции старой реализации.
## ArchiveHeadBlock и серверный SHINE-ARCHIVE
Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`):
```text
archive_tx_id [32]
archive_hash [32]
```
Он хранит текущую голову архива конкретного SHiNE-аккаунта: raw Arweave TX ID и SHA-256 соответствующего большого `SHINE-ARCHIVE`. Подробный бинарный формат и серверный workflow находятся в `docs/Archive/01_PROTOCOL_v1.0.md`.
Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим:
- legacy payload без archive extension сохраняет старый block `100`;
- новый payload может заменить/очистить archive head;
- итоговая полная User PDA запись, включая block `100`, покрывается обычной root-подписью.
Это позволяет обычным старым клиентским обновлениям профиля не стирать archive head серверного publisher-а.
@@ -36,6 +36,7 @@ const BLOCK_TYPE_SERVER_PROFILE: u8 = 30;
const BLOCK_TYPE_ACCESS_SERVERS: u8 = 40;
const BLOCK_TYPE_SESSIONS: u8 = 50;
const BLOCK_TYPE_TRUSTED_STATE: u8 = 70;
const BLOCK_TYPE_ARCHIVE_HEAD: u8 = 100;
const BLOCK_VERSION_0: u8 = 0;
const BLOCKCHAIN_TYPE_MAIN_USER: u8 = 1;
const SESSIONS_MODE_MIXED: u8 = 1;
@@ -163,6 +164,8 @@ pub struct UpdateUserPdaArgs {
pub prev_hash: [u8; 32],
pub additional_limit: u64,
pub fields: UserMutableFields,
/// None = legacy instruction, archive head сохранить; Some(None) = очистить; Some(Some) = заменить.
pub archive_head_update: Option<Option<ArchiveHeadRecord>>,
pub signature: [u8; 64],
}
@@ -210,6 +213,12 @@ pub struct BlockchainRecord {
pub arweave_tx_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArchiveHeadRecord {
pub arweave_tx_id: [u8; 32],
pub archive_hash: [u8; 32],
}
#[derive(Clone, Debug)]
pub struct UserRecord {
pub created_at_ms: u64,
@@ -230,6 +239,7 @@ pub struct UserRecord {
pub sessions_mode: u8,
pub sessions: Vec<SessionRecord>,
pub trusted_count: u8,
pub archive_head: Option<ArchiveHeadRecord>,
pub signature: [u8; 64],
}
@@ -349,17 +359,34 @@ fn parse_create_args(r: &mut Reader<'_>) -> Result<CreateUserPdaArgs, ProgramErr
}
fn parse_update_args(r: &mut Reader<'_>) -> Result<UpdateUserPdaArgs, ProgramError> {
let login = r.read_string_u8()?;
let recovery_key = r.read_pubkey()?;
let root_key = r.read_pubkey()?;
let created_at_ms = r.read_u64()?;
let updated_at_ms = r.read_u64()?;
let version = r.read_u32()?;
let prev_hash = r.read_fixed_32()?;
let additional_limit = r.read_u64()?;
let fields = parse_fields(r)?;
// Backward compatibility: legacy update после trusted_count содержит ровно 64-byte root signature.
let archive_head_update = if r.remaining() > 64 {
let present = r.read_u8()?;
match present {
0 => Some(None),
1 => Some(Some(ArchiveHeadRecord {
arweave_tx_id: r.read_fixed_32()?,
archive_hash: r.read_fixed_32()?,
})),
_ => return Err(ProgramError::from(ShineUsersError::InvalidInstruction)),
}
} else {
None
};
Ok(UpdateUserPdaArgs {
login: r.read_string_u8()?,
recovery_key: r.read_pubkey()?,
root_key: r.read_pubkey()?,
created_at_ms: r.read_u64()?,
updated_at_ms: r.read_u64()?,
version: r.read_u32()?,
prev_hash: r.read_fixed_32()?,
additional_limit: r.read_u64()?,
fields: parse_fields(r)?,
signature: r.read_fixed_64()?,
login, recovery_key, root_key, created_at_ms, updated_at_ms, version, prev_hash,
additional_limit, fields, archive_head_update, signature: r.read_fixed_64()?,
})
}
@@ -590,6 +617,7 @@ fn process_create_user_pda<'a>(program_id: &Pubkey, accounts: &'a [AccountInfo<'
sessions_mode: args.fields.sessions_mode,
sessions: args.fields.sessions,
trusted_count: args.fields.trusted_count,
archive_head: None,
signature: [0; 64],
};
validate_blockchain_limits(&record.blockchain, 0, 0, true)?;
@@ -713,6 +741,10 @@ fn build_update_record(old_record: &UserRecord, args: &UpdateUserPdaArgs, new_ba
sessions_mode: args.fields.sessions_mode,
sessions: args.fields.sessions.clone(),
trusted_count: args.fields.trusted_count,
archive_head: match &args.archive_head_update {
None => old_record.archive_head.clone(),
Some(value) => value.clone(),
},
signature: [0; 64],
})
}
@@ -857,6 +889,7 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result<UserRecord, ProgramError> {
let mut sessions_mode = SESSIONS_MODE_MIXED;
let mut sessions = Vec::new();
let mut trusted_count = 0u8;
let mut archive_head: Option<ArchiveHeadRecord> = None;
for _ in 0..blocks_count {
let block_type = read_u8_from(useful, &mut cursor)?;
@@ -907,6 +940,13 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result<UserRecord, ProgramError> {
BLOCK_TYPE_TRUSTED_STATE => {
trusted_count = read_u8_from(useful, &mut cursor)?;
}
BLOCK_TYPE_ARCHIVE_HEAD => {
require!(archive_head.is_none(), ShineUsersError::InvalidRecordData);
archive_head = Some(ArchiveHeadRecord {
arweave_tx_id: read_fixed_32_from(useful, &mut cursor)?,
archive_hash: read_fixed_32_from(useful, &mut cursor)?,
});
}
_ => return Err(ProgramError::from(ShineUsersError::InvalidRecordFormat)),
}
}
@@ -933,6 +973,7 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result<UserRecord, ProgramError> {
sessions_mode,
sessions,
trusted_count,
archive_head,
signature,
})
}
@@ -986,7 +1027,7 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result<Vec<u8>, ProgramErro
out.push(login_bytes.len() as u8);
out.extend_from_slice(login_bytes);
let blocks_count = if record.is_server { 8 } else { 7 };
let blocks_count = (if record.is_server { 8 } else { 7 }) + if record.archive_head.is_some() { 1 } else { 0 };
out.push(blocks_count);
write_recovery_key_block(&mut out, record);
write_root_key_block(&mut out, record);
@@ -996,6 +1037,7 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result<Vec<u8>, ProgramErro
write_access_servers_block(&mut out, record)?;
write_sessions_block(&mut out, record)?;
write_trusted_state_block(&mut out, record);
if let Some(archive_head) = &record.archive_head { write_archive_head_block(&mut out, archive_head); }
let record_len = out.len().checked_add(64).ok_or(ProgramError::from(ShineUsersError::MathOverflow))?;
require!(record_len <= u16::MAX as usize, ShineUsersError::RecordTooLarge);
@@ -1045,6 +1087,7 @@ fn write_sessions_block(out: &mut Vec<u8>, record: &UserRecord) -> Result<(), Pr
out.push(BLOCK_TYPE_SESSIONS); out.push(BLOCK_VERSION_0); out.push(record.sessions_mode); require!(record.sessions.len() <= MAX_SESSIONS, ShineUsersError::InvalidRecordData); out.push(record.sessions.len() as u8); for session in &record.sessions { out.push(session.session_type); out.push(session.session_version); write_len_prefixed_string(out, &session.session_name)?; out.extend_from_slice(session.session_pub_key.as_ref()); } Ok(())
}
fn write_trusted_state_block(out: &mut Vec<u8>, record: &UserRecord) { out.push(BLOCK_TYPE_TRUSTED_STATE); out.push(BLOCK_VERSION_0); out.push(record.trusted_count); }
fn write_archive_head_block(out: &mut Vec<u8>, archive: &ArchiveHeadRecord) { out.push(BLOCK_TYPE_ARCHIVE_HEAD); out.push(BLOCK_VERSION_0); out.extend_from_slice(&archive.arweave_tx_id); out.extend_from_slice(&archive.archive_hash); }
fn write_len_prefixed_string(out: &mut Vec<u8>, value: &str) -> Result<(), ProgramError> { let bytes = value.as_bytes(); require!(bytes.len() <= u8::MAX as usize, ShineUsersError::InvalidRecordData); out.push(bytes.len() as u8); out.extend_from_slice(bytes); Ok(()) }
fn verify_record_signature_hash(instructions_sysvar: &AccountInfo, root_key: &Pubkey, signature: &[u8; 64], message_hash: &[u8]) -> Result<[u8; 64], ProgramError> {