SHA256
Compare commits
19
Commits
7d27bfdcaf
..
svyazi
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
1be1d56599 | ||
|
|
2df0a78eb2 | ||
|
|
c3253dceac | ||
|
|
ef068eca81 | ||
|
|
f9ae811702 | ||
|
|
ea0098d705 | ||
|
|
4991a146d5 | ||
|
|
849250bfa8 | ||
|
|
d83f1d4cce | ||
|
|
4c7e71f21f | ||
|
|
107f85b818 | ||
|
|
3a466b4c38 | ||
|
|
08663fa339 | ||
|
|
8e86872aa7 | ||
|
|
2d059e9ff5 | ||
|
|
9f77c54955 | ||
|
|
f827c3e493 | ||
|
|
ae2f2fac41 | ||
|
|
1f70d36e74 |
@@ -105,6 +105,7 @@ ESP32/**/*.a
|
||||
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
||||
deploy/backup/archive/**
|
||||
!deploy/backup/archive/.gitkeep
|
||||
SHiNE-bundle-*.zip
|
||||
|
||||
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
||||
.agents/
|
||||
|
||||
@@ -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._-]", "_");
|
||||
}
|
||||
}
|
||||
+37
@@ -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(); }
|
||||
}
|
||||
+54
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+229
@@ -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;
|
||||
}
|
||||
}
|
||||
+98
@@ -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; }
|
||||
}
|
||||
+90
@@ -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();
|
||||
}
|
||||
}
|
||||
+341
@@ -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();}
|
||||
}
|
||||
+259
@@ -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;
|
||||
}
|
||||
}
|
||||
+101
@@ -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); }
|
||||
}
|
||||
+310
@@ -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); }
|
||||
}
|
||||
}
|
||||
+373
@@ -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) {}
|
||||
}
|
||||
+34
@@ -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));
|
||||
}
|
||||
}
|
||||
+83
@@ -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) {
|
||||
|
||||
+3
@@ -17,6 +17,9 @@ public final class ShineSignatureConstants {
|
||||
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
|
||||
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
|
||||
|
||||
/** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */
|
||||
public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:";
|
||||
|
||||
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
||||
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_18 = 18;
|
||||
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";
|
||||
@@ -57,6 +60,9 @@ public final class DatabaseInitializer {
|
||||
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
|
||||
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() {}
|
||||
|
||||
@@ -206,6 +212,18 @@ public final class DatabaseInitializer {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_20;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_21) {
|
||||
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
|
||||
) {}
|
||||
+14
@@ -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
|
||||
) {}
|
||||
+17
@@ -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,298 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Server projection for per-user channel read/unread state.
|
||||
*
|
||||
* Important rules:
|
||||
* - rows are created only by a signed client SetChannelReadState;
|
||||
* - channel publications increment unread_count only for rows that already exist;
|
||||
* - read_count is monotonic and unread_count is reduced by the accepted read delta;
|
||||
* - unread_count is never part of the client signature and is server-owned.
|
||||
*/
|
||||
public final class ChannelReadStateDAO {
|
||||
private static volatile ChannelReadStateDAO instance;
|
||||
|
||||
private ChannelReadStateDAO() {}
|
||||
|
||||
public static ChannelReadStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ChannelReadStateDAO.class) {
|
||||
if (instance == null) instance = new ChannelReadStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public record ReadState(long readCount, long unreadCount, long readUpdatedAtMs,
|
||||
String clientKey, String readSignature) {}
|
||||
|
||||
public record UpsertResult(boolean applied, long readCount, long unreadCount) {}
|
||||
|
||||
public ReadState get(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT read_count, unread_count, read_updated_at_ms, client_key, read_signature
|
||||
FROM channel_read_state
|
||||
WHERE LOWER(viewer_login)=LOWER(?) AND owner_bch_name=? AND channel_name=?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setString(2, ownerBchName);
|
||||
ps.setString(3, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return new ReadState(
|
||||
Math.max(0L, rs.getLong("read_count")),
|
||||
Math.max(0L, rs.getLong("unread_count")),
|
||||
rs.getLong("read_updated_at_ms"),
|
||||
rs.getString("client_key"),
|
||||
rs.getString("read_signature"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long getUnreadCount(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
|
||||
ReadState state = get(c, viewerLogin, ownerBchName, channelName);
|
||||
return state == null ? 0L : state.unreadCount();
|
||||
}
|
||||
|
||||
public long sumUnreadCount(Connection c, String viewerLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT COALESCE(SUM(crs.unread_count),0)
|
||||
FROM channel_read_state crs
|
||||
WHERE LOWER(crs.viewer_login)=LOWER(?)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_names_state cns
|
||||
JOIN connections_state cs
|
||||
ON LOWER(cs.login)=LOWER(crs.viewer_login)
|
||||
AND cs.rel_type=30
|
||||
AND cs.to_bch_name=cns.owner_bch_name
|
||||
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||
WHERE cns.owner_bch_name=crs.owner_bch_name
|
||||
AND cns.slug=crs.channel_name
|
||||
AND cns.channel_type_code=1
|
||||
)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the requested public channel exists and that viewer currently follows it.
|
||||
*/
|
||||
public boolean hasActiveSubscription(Connection c, String viewerLogin, String ownerBchName, String channelName,
|
||||
int followRelType) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM channel_names_state cns
|
||||
JOIN connections_state cs
|
||||
ON LOWER(cs.login)=LOWER(?)
|
||||
AND cs.rel_type=?
|
||||
AND cs.to_bch_name=cns.owner_bch_name
|
||||
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||
WHERE cns.owner_bch_name=?
|
||||
AND cns.slug=?
|
||||
AND cns.channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setInt(2, followRelType);
|
||||
ps.setString(3, ownerBchName);
|
||||
ps.setString(4, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String resolvePublicChannelNameByRoot(Connection c, String ownerBchName, int rootBlockNumber) throws SQLException {
|
||||
String sql = """
|
||||
SELECT slug
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name=? AND channel_root_block_number=? AND channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setInt(2, rootBlockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString("slug") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public Integer resolvePublicChannelRootByName(Connection c, String ownerBchName, String channelName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT channel_root_block_number
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setString(2, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getInt("channel_root_block_number") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String resolveOwnerLogin(Connection c, String ownerBchName, String channelName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setString(2, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString("owner_login") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the first client baseline with unread=0, or advances an existing read watermark.
|
||||
* Older timestamps and backwards read_count values are ignored.
|
||||
*/
|
||||
public UpsertResult upsertSignedReadIfNewer(Connection c,
|
||||
String viewerLogin,
|
||||
String ownerLogin,
|
||||
String ownerBchName,
|
||||
String channelName,
|
||||
long readCount,
|
||||
long readUpdatedAtMs,
|
||||
String clientKey,
|
||||
String readSignature,
|
||||
long nowMs) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO channel_read_state (
|
||||
viewer_login, owner_login, owner_bch_name, channel_name,
|
||||
read_count, unread_count,
|
||||
read_updated_at_ms, client_key, read_signature, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
|
||||
ON CONFLICT (viewer_login, owner_bch_name, channel_name)
|
||||
DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
unread_count = GREATEST(
|
||||
0,
|
||||
channel_read_state.unread_count -
|
||||
GREATEST(0, EXCLUDED.read_count - channel_read_state.read_count)
|
||||
),
|
||||
read_count = GREATEST(channel_read_state.read_count, EXCLUDED.read_count),
|
||||
read_updated_at_ms = EXCLUDED.read_updated_at_ms,
|
||||
client_key = EXCLUDED.client_key,
|
||||
read_signature = EXCLUDED.read_signature,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
WHERE channel_read_state.read_updated_at_ms < EXCLUDED.read_updated_at_ms
|
||||
AND EXCLUDED.read_count >= channel_read_state.read_count
|
||||
RETURNING read_count, unread_count
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setString(2, ownerLogin);
|
||||
ps.setString(3, ownerBchName);
|
||||
ps.setString(4, channelName);
|
||||
ps.setLong(5, Math.max(0L, readCount));
|
||||
ps.setLong(6, readUpdatedAtMs);
|
||||
ps.setString(7, clientKey);
|
||||
ps.setString(8, readSignature);
|
||||
ps.setLong(9, nowMs);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return new UpsertResult(true,
|
||||
Math.max(0L, rs.getLong("read_count")),
|
||||
Math.max(0L, rs.getLong("unread_count")));
|
||||
}
|
||||
}
|
||||
}
|
||||
ReadState current = get(c, viewerLogin, ownerBchName, channelName);
|
||||
return new UpsertResult(false,
|
||||
current == null ? 0L : current.readCount(),
|
||||
current == null ? 0L : current.unreadCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments existing rows for current subscribers of one public channel.
|
||||
* Missing channel_read_state rows are deliberately NOT created.
|
||||
* Returns affected viewer logins for post-commit counter pushes.
|
||||
*/
|
||||
public List<String> incrementUnreadForSubscribers(Connection c,
|
||||
String ownerBchName,
|
||||
String channelName,
|
||||
int followRelType,
|
||||
long nowMs) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE channel_read_state crs
|
||||
SET unread_count = crs.unread_count + 1,
|
||||
updated_at_ms = ?
|
||||
WHERE crs.owner_bch_name = ?
|
||||
AND crs.channel_name = ?
|
||||
AND LOWER(crs.viewer_login) <> LOWER(crs.owner_login)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_names_state cns
|
||||
JOIN connections_state cs
|
||||
ON LOWER(cs.login)=LOWER(crs.viewer_login)
|
||||
AND cs.rel_type=?
|
||||
AND cs.to_bch_name=cns.owner_bch_name
|
||||
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||
WHERE cns.owner_bch_name=crs.owner_bch_name
|
||||
AND cns.slug=crs.channel_name
|
||||
AND cns.channel_type_code=1
|
||||
)
|
||||
RETURNING viewer_login
|
||||
""";
|
||||
List<String> affected = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, nowMs);
|
||||
ps.setString(2, ownerBchName);
|
||||
ps.setString(3, channelName);
|
||||
ps.setInt(4, followRelType);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) affected.add(rs.getString("viewer_login"));
|
||||
}
|
||||
}
|
||||
return affected;
|
||||
}
|
||||
|
||||
/** Delete the projection row when a channel FOLLOW is explicitly removed. */
|
||||
public boolean deleteForUnfollowTarget(Connection c, String viewerLogin, String ownerBchName,
|
||||
int rootBlockNumber) throws SQLException {
|
||||
String sql = """
|
||||
DELETE FROM channel_read_state crs
|
||||
USING channel_names_state cns
|
||||
WHERE LOWER(crs.viewer_login)=LOWER(?)
|
||||
AND crs.owner_bch_name=?
|
||||
AND cns.owner_bch_name=crs.owner_bch_name
|
||||
AND cns.slug=crs.channel_name
|
||||
AND cns.channel_root_block_number=?
|
||||
AND cns.channel_type_code=1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setString(2, ownerBchName);
|
||||
ps.setInt(3, rootBlockNumber);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Channel unread/read projection.
|
||||
-- The row is created only by a signed SetChannelReadState from the client.
|
||||
-- New channel publications only increment existing rows.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_read_state (
|
||||
viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0),
|
||||
unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0),
|
||||
read_updated_at_ms BIGINT NOT NULL,
|
||||
client_key TEXT NOT NULL,
|
||||
read_signature TEXT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (viewer_login, owner_bch_name, channel_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer
|
||||
ON channel_read_state(viewer_login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel
|
||||
ON channel_read_state(owner_bch_name, channel_name);
|
||||
|
||||
UPDATE db_schema_version SET schema_version = 21 WHERE id = 1;
|
||||
@@ -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;
|
||||
@@ -425,6 +425,26 @@ CREATE INDEX IF NOT EXISTS idx_user_settings_login
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
|
||||
ON user_settings(setting_type, setting_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_read_state (
|
||||
viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0),
|
||||
unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0),
|
||||
read_updated_at_ms BIGINT NOT NULL,
|
||||
client_key TEXT NOT NULL,
|
||||
read_signature TEXT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (viewer_login, owner_bch_name, channel_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer
|
||||
ON channel_read_state(viewer_login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel
|
||||
ON channel_read_state(owner_bch_name, channel_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ip_geo_cache (
|
||||
ip TEXT PRIMARY KEY,
|
||||
geo TEXT,
|
||||
@@ -2008,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,20,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;
|
||||
|
||||
+16
-8
@@ -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;
|
||||
@@ -75,18 +73,24 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageLikes_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetUserCounters_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_SetChannelReadState_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler;
|
||||
@@ -159,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()),
|
||||
@@ -191,6 +193,7 @@ public final class JsonHandlerRegistry {
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
||||
Map.entry("GetArchiveBlockchainLocation", new Net_GetArchiveBlockchainLocation_Handler()),
|
||||
|
||||
// --- userParams ---
|
||||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||||
@@ -208,9 +211,12 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
|
||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||
Map.entry("GetMessageLikes", new Net_GetMessageLikes_Handler()),
|
||||
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
||||
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
|
||||
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
|
||||
Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()),
|
||||
Map.entry("SetChannelReadState", new Net_SetChannelReadState_Handler()),
|
||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()),
|
||||
@@ -250,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),
|
||||
@@ -282,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),
|
||||
@@ -299,9 +304,12 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
|
||||
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
||||
Map.entry("GetMessageLikes", Net_GetMessageLikes_Request.class),
|
||||
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
|
||||
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
|
||||
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
|
||||
Map.entry("GetUserCounters", Net_GetUserCounters_Request.class),
|
||||
Map.entry("SetChannelReadState", Net_SetChannelReadState_Request.class),
|
||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
|
||||
|
||||
+49
-4
@@ -26,6 +26,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import server.sync.AddBlockSyncService;
|
||||
import shine.db.channels.ChannelNameRules;
|
||||
@@ -42,6 +43,7 @@ import shine.db.entities.UserParamEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
@@ -100,7 +102,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
blockchainName,
|
||||
req.getBlockNumber(), // старое поле, пока оставляем
|
||||
req.getPrevBlockHash(), // старое поле, пока оставляем
|
||||
req.getBlockBytesB64()
|
||||
req.getBlockBytesB64(),
|
||||
true
|
||||
);
|
||||
|
||||
// ✅ УСПЕХ: как раньше
|
||||
@@ -186,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);
|
||||
@@ -586,9 +590,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
|
||||
dbWriter.appendBlockAndState(
|
||||
List<String> counterChangedLogins = dbWriter.appendBlockAndState(
|
||||
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
|
||||
|
||||
for (String changedLogin : counterChangedLogins) {
|
||||
UserCountersSupport.pushChanged(changedLogin);
|
||||
}
|
||||
|
||||
if (chat200CreateSeed != null) {
|
||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||
}
|
||||
@@ -607,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 ====================================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
+41
-1
@@ -4,6 +4,8 @@ import blockchain.BchBlockEntry;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
@@ -19,6 +21,8 @@ import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* BlockchainWriter — запись блока в БД и формирование файловой версии цепочки.
|
||||
@@ -44,6 +48,7 @@ public final class BlockchainWriter {
|
||||
private final ChannelNameStateDAO channelNameStateDAO;
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
@@ -58,7 +63,7 @@ public final class BlockchainWriter {
|
||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||
}
|
||||
|
||||
public void appendBlockAndState(String blockchainName,
|
||||
public List<String> appendBlockAndState(String blockchainName,
|
||||
BchBlockEntry block,
|
||||
BlockchainStateEntry st,
|
||||
BlockEntry be,
|
||||
@@ -75,6 +80,7 @@ public final class BlockchainWriter {
|
||||
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
|
||||
|
||||
boolean committed = false;
|
||||
List<String> counterChangedLogins = new ArrayList<>();
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
@@ -108,6 +114,29 @@ public final class BlockchainWriter {
|
||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
}
|
||||
|
||||
// Channel read/unread projection is updated in the same SQL transaction as the block.
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
Integer lineCode = be.getLineCode();
|
||||
if (isPublicChannelPublication(msgType, msgSubType) && lineCode != null && lineCode > 0) {
|
||||
String channelName = channelReadStateDAO.resolvePublicChannelNameByRoot(c, blockchainName, lineCode);
|
||||
if (channelName != null && !channelName.isBlank()) {
|
||||
counterChangedLogins.addAll(channelReadStateDAO.incrementUnreadForSubscribers(
|
||||
c, blockchainName, channelName, MsgSubType.CONNECTION_FOLLOW, nowMs));
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit UNFOLLOW removes the projection row. FOLLOW never creates it.
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|
||||
&& be.getToBchName() != null && !be.getToBchName().isBlank()
|
||||
&& be.getToBlockNumber() != null && be.getToBlockNumber() > 0) {
|
||||
if (channelReadStateDAO.deleteForUnfollowTarget(
|
||||
c, be.getLogin(), be.getToBchName(), be.getToBlockNumber())) {
|
||||
counterChangedLogins.add(be.getLogin());
|
||||
}
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
@@ -142,6 +171,17 @@ public final class BlockchainWriter {
|
||||
|
||||
// 4) После успешной подмены — чистим временные артефакты.
|
||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||
return counterChangedLogins;
|
||||
}
|
||||
|
||||
private static boolean isPublicChannelPublication(int msgType, int msgSubType) {
|
||||
if (msgType != 1) return false;
|
||||
return msgSubType == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||
}
|
||||
|
||||
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
||||
|
||||
+52
@@ -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();}
|
||||
}
|
||||
+9
@@ -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;}
|
||||
}
|
||||
+16
@@ -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;}
|
||||
}
|
||||
+8
-33
@@ -144,38 +144,12 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
}
|
||||
|
||||
static String userSettingsChannelKey(String ownerBch, String channelName) {
|
||||
String bch = ownerBch == null ? "" : ownerBch.trim();
|
||||
String name = channelName == null ? "" : channelName.trim();
|
||||
return bch + "/" + name;
|
||||
}
|
||||
|
||||
static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException {
|
||||
if (viewerLogin == null || viewerLogin.isBlank()) return 0;
|
||||
String key = userSettingsChannelKey(ownerBch, channelName);
|
||||
String sql = """
|
||||
SELECT value_num
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND setting_type = ?
|
||||
AND setting_key = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
long lastSeen = messagesCount;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setInt(2, 1);
|
||||
ps.setString(3, key);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long value = rs.getLong("value_num");
|
||||
if (!rs.wasNull()) lastSeen = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastSeen < 0) lastSeen = 0;
|
||||
if (lastSeen > messagesCount) return 0;
|
||||
return Math.max(0, messagesCount - (int) lastSeen);
|
||||
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank()
|
||||
|| channelName == null || channelName.isBlank()) return 0;
|
||||
long unread = shine.db.dao.ChannelReadStateDAO.getInstance()
|
||||
.getUnreadCount(c, viewerLogin, ownerBch, channelName);
|
||||
return unread > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, unread);
|
||||
}
|
||||
|
||||
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
@@ -262,12 +236,13 @@ final class ChannelsReadSupport {
|
||||
|
||||
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
|
||||
String order = asc ? "ASC" : "DESC";
|
||||
boolean bounded = limit > 0;
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
||||
ORDER BY block_number
|
||||
""" + order + " LIMIT ?";
|
||||
""" + order + (bounded ? " LIMIT ?" : "");
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
@@ -278,7 +253,7 @@ final class ChannelsReadSupport {
|
||||
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
||||
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
||||
ps.setInt(9, lineCode);
|
||||
ps.setInt(10, limit);
|
||||
if (bounded) ps.setInt(10, limit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostBlock> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
|
||||
+6
-4
@@ -34,9 +34,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 30 : req.getLimit();
|
||||
if (limit <= 0 || limit > 1000) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
|
||||
// limit is optional. null/0 means "return the whole channel".
|
||||
// Positive values are kept for backwards-compatible callers that still want a bounded response.
|
||||
int limit = req.getLimit() == null ? 0 : req.getLimit();
|
||||
if (limit < 0) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_limit", "Некорректный limit");
|
||||
}
|
||||
|
||||
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
||||
@@ -113,7 +115,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
if (!asc) {
|
||||
java.util.Collections.reverse(posts);
|
||||
}
|
||||
if (posts.size() > limit) {
|
||||
if (limit > 0 && posts.size() > limit) {
|
||||
posts = new ArrayList<>(posts.subList(0, limit));
|
||||
}
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetMessageLikes_Request req = (Net_GetMessageLikes_Request) baseRequest;
|
||||
Net_GetMessageLikes_Request.MessageSelector message = req.getMessage();
|
||||
if (message == null || message.getBlockchainName() == null || message.getBlockchainName().isBlank()
|
||||
|| message.getBlockNumber() == null || message.getBlockNumber() < 0
|
||||
|| message.getBlockHash() == null || message.getBlockHash().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message");
|
||||
}
|
||||
|
||||
final byte[] blockHash;
|
||||
try {
|
||||
blockHash = ChannelsReadSupport.hexToBytes(message.getBlockHash());
|
||||
} catch (Exception e) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
|
||||
}
|
||||
if (blockHash == null || blockHash.length == 0) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
|
||||
}
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
String sql = """
|
||||
SELECT rs.from_login,
|
||||
COALESCE(ups.first_name, '') AS first_name,
|
||||
COALESCE(ups.last_name, '') AS last_name,
|
||||
COALESCE(ups.ava_ar, '') AS avatar_ar,
|
||||
COALESCE(ups.account_role, 'unknown') AS account_role,
|
||||
COALESCE(ups.shine_status, 'unknown') AS shine_status
|
||||
FROM reactions_state rs
|
||||
LEFT JOIN user_profile_state ups ON LOWER(ups.login) = LOWER(rs.from_login)
|
||||
WHERE rs.reaction_type = 1
|
||||
AND rs.last_sub_type = 1
|
||||
AND rs.to_bch_name = ?
|
||||
AND rs.to_block_number = ?
|
||||
AND rs.to_block_hash = ?
|
||||
ORDER BY LOWER(rs.from_login)
|
||||
""";
|
||||
|
||||
List<Net_GetMessageLikes_Response.UserItem> shining = new ArrayList<>();
|
||||
List<Net_GetMessageLikes_Response.UserItem> official = new ArrayList<>();
|
||||
List<Net_GetMessageLikes_Response.UserItem> others = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, message.getBlockchainName().trim());
|
||||
ps.setInt(2, message.getBlockNumber());
|
||||
ps.setBytes(3, blockHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem();
|
||||
user.setLogin(rs.getString("from_login"));
|
||||
user.setFirstName(rs.getString("first_name"));
|
||||
user.setLastName(rs.getString("last_name"));
|
||||
user.setAvatarAr(rs.getString("avatar_ar"));
|
||||
|
||||
boolean isOfficial = "primary".equalsIgnoreCase(rs.getString("account_role"));
|
||||
boolean isShining = "shining".equalsIgnoreCase(rs.getString("shine_status"));
|
||||
if (isOfficial && isShining) shining.add(user);
|
||||
else if (isOfficial) official.add(user);
|
||||
else others.add(user);
|
||||
}
|
||||
|
||||
Net_GetMessageLikes_Response resp = new Net_GetMessageLikes_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setShining(shining);
|
||||
resp.setOfficial(official);
|
||||
resp.setOthers(others);
|
||||
resp.setTotal(shining.size() + official.size() + others.size());
|
||||
resp.setTruncated(false);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("GetMessageLikes failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
|
||||
public class Net_GetUserCounters_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetUserCounters_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetUserCounters_Request req = (Net_GetUserCounters_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
String login = ctx.getLogin().trim();
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
UserCountersSupport.Snapshot s = UserCountersSupport.calculate(c, login);
|
||||
Net_GetUserCounters_Response r = new Net_GetUserCounters_Response();
|
||||
r.setOp(req.getOp());
|
||||
r.setRequestId(req.getRequestId());
|
||||
r.setStatus(WireCodes.Status.OK);
|
||||
r.setLogin(login);
|
||||
r.setDmUnreadCount(s.dmUnreadCount());
|
||||
r.setChannelsUnreadCount(s.channelsUnreadCount());
|
||||
r.setNotificationsUnreadCount(s.notificationsUnreadCount());
|
||||
r.setNotificationRepliesUnreadCount(s.repliesUnreadCount());
|
||||
r.setNotificationConnectionsUnreadCount(s.connectionsUnreadCount());
|
||||
r.setNotificationEventsUnreadCount(s.eventsUnreadCount());
|
||||
return r;
|
||||
} catch (Exception e) {
|
||||
log.error("GetUserCounters failed for {}", login, e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Не удалось получить счётчики");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-4
@@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
@@ -81,11 +82,15 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
||||
channelRef.setChannelRoot(rootRef);
|
||||
|
||||
row.setChannel(channelRef);
|
||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
||||
int messagesCount = ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber);
|
||||
row.setMessagesCount(messagesCount);
|
||||
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
|
||||
row.setUnreadCount(ownChannel
|
||||
? 0
|
||||
: ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
|
||||
ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null
|
||||
? null
|
||||
: ChannelReadStateDAO.getInstance().get(c, viewerLogin, key.ownerBch, meta.channelName);
|
||||
row.setReadStateInitialized(readState != null);
|
||||
row.setReadCount(readState == null ? 0L : Math.min(messagesCount, Math.max(0L, readState.readCount())));
|
||||
row.setUnreadCount(readState == null ? 0 : (int) Math.min(Integer.MAX_VALUE, readState.unreadCount()));
|
||||
|
||||
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||
if (lastPost != null) {
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.Base64Ws;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
import utils.config.ShineSignatureConstants;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
|
||||
/** Accepts the signed client read watermark for one followed public channel. */
|
||||
public final class Net_SetChannelReadState_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_SetChannelReadState_Handler.class);
|
||||
private static final long MAX_FUTURE_SKEW_MS = 5 * 60_000L;
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_SetChannelReadState_Request req = (Net_SetChannelReadState_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||
"NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
if (req.getLogin() == null || req.getLogin().isBlank()
|
||||
|| req.getOwner_bch_name() == null || req.getOwner_bch_name().isBlank()
|
||||
|| req.getChannel_name() == null || req.getChannel_name().isBlank()
|
||||
|| req.getRead_count() == null || req.getRead_count() < 0
|
||||
|| req.getTime_ms() == null || req.getTime_ms() <= 0
|
||||
|| req.getClient_key() == null || req.getClient_key().isBlank()
|
||||
|| req.getSignature() == null || req.getSignature().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST,
|
||||
"BAD_FIELDS", "Некорректные поля ChannelReadState");
|
||||
}
|
||||
|
||||
String authenticatedLogin = String.valueOf(ctx.getCurrentUser().getLogin()).trim();
|
||||
String login = req.getLogin().trim();
|
||||
String ownerBch = req.getOwner_bch_name().trim();
|
||||
String channelName = req.getChannel_name().trim();
|
||||
long readCount = req.getRead_count();
|
||||
long timeMs = req.getTime_ms();
|
||||
String clientKeyB64 = req.getClient_key().trim();
|
||||
String signatureB64 = req.getSignature().trim();
|
||||
|
||||
if (!authenticatedLogin.equalsIgnoreCase(login)) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||
"LOGIN_MISMATCH", "Состояние чтения принадлежит другому пользователю");
|
||||
}
|
||||
long nowMs = System.currentTimeMillis();
|
||||
if (timeMs > nowMs + MAX_FUTURE_SKEW_MS) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||
"BAD_TIME", "Некорректное время подписи");
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] pubKey32;
|
||||
byte[] sig64;
|
||||
try {
|
||||
pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key");
|
||||
sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature");
|
||||
} catch (IllegalArgumentException e) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST,
|
||||
"BAD_BASE64", "client_key/signature должны быть Base64");
|
||||
}
|
||||
|
||||
String userClientKey = String.valueOf(ctx.getCurrentUser().getClientKey() == null
|
||||
? "" : ctx.getCurrentUser().getClientKey()).trim();
|
||||
if (userClientKey.isBlank() || !userClientKey.equals(clientKeyB64)) {
|
||||
return NetExceptionResponseFactory.error(req, 403,
|
||||
"DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю");
|
||||
}
|
||||
|
||||
String signText = ShineSignatureConstants.CHANNEL_READ_STATE_PREFIX
|
||||
+ escapePart(login) + '|'
|
||||
+ escapePart(ownerBch) + '|'
|
||||
+ escapePart(channelName) + '|'
|
||||
+ timeMs + '|'
|
||||
+ readCount;
|
||||
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||
"INVALID_SIGNATURE", "Подпись состояния чтения канала не прошла проверку");
|
||||
}
|
||||
|
||||
ChannelReadStateDAO dao = ChannelReadStateDAO.getInstance();
|
||||
ChannelReadStateDAO.UpsertResult result;
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
if (!dao.hasActiveSubscription(c, authenticatedLogin, ownerBch, channelName,
|
||||
MsgSubType.CONNECTION_FOLLOW)) {
|
||||
return NetExceptionResponseFactory.error(req, 409,
|
||||
"CHANNEL_NOT_FOLLOWED", "Пользователь не подписан на этот канал");
|
||||
}
|
||||
String ownerLogin = dao.resolveOwnerLogin(c, ownerBch, channelName);
|
||||
Integer channelRoot = dao.resolvePublicChannelRootByName(c, ownerBch, channelName);
|
||||
if (ownerLogin == null || ownerLogin.isBlank() || channelRoot == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404,
|
||||
"CHANNEL_NOT_FOUND", "Канал не найден");
|
||||
}
|
||||
|
||||
// Never persist a watermark beyond the number of messages that really exists.
|
||||
// Without this guard, one buggy/malicious oversized read_count would be monotonic
|
||||
// and could make all future unread counters impossible to clear correctly.
|
||||
long actualMessagesCount = ChannelsReadSupport.countPosts(c, ownerBch, channelRoot);
|
||||
long effectiveReadCount = Math.min(readCount, actualMessagesCount);
|
||||
|
||||
result = dao.upsertSignedReadIfNewer(c,
|
||||
authenticatedLogin, ownerLogin, ownerBch, channelName,
|
||||
effectiveReadCount, timeMs, clientKeyB64, signatureB64, nowMs);
|
||||
}
|
||||
|
||||
Net_SetChannelReadState_Response resp = new Net_SetChannelReadState_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(authenticatedLogin);
|
||||
resp.setOwner_bch_name(ownerBch);
|
||||
resp.setChannel_name(channelName);
|
||||
resp.setRead_count(result.readCount());
|
||||
resp.setUnread_count(result.unreadCount());
|
||||
resp.setTime_ms(timeMs);
|
||||
resp.setApplied(result.applied());
|
||||
if (result.applied()) UserCountersSupport.pushChanged(authenticatedLogin);
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("SetChannelReadState failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_ERROR", NetExceptionResponseFactory.detailedMessage(
|
||||
"Внутренняя ошибка SetChannelReadState", e));
|
||||
}
|
||||
}
|
||||
|
||||
private static String escapePart(String value) {
|
||||
String s = String.valueOf(value == null ? "" : value);
|
||||
return s.replace("\\", "\\\\").replace("|", "\\|");
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.UserNotificationSeenStateDAO;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
|
||||
/**
|
||||
* Temporary calculator behind the stable GetUserCounters/UserCountersChanged contract.
|
||||
* It intentionally uses current DB state today; later it can read one materialized row/cache
|
||||
* without changing API or UI.
|
||||
*/
|
||||
public final class UserCountersSupport {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private UserCountersSupport() {}
|
||||
|
||||
public record Snapshot(long dmUnreadCount, long channelsUnreadCount, long repliesUnreadCount,
|
||||
long connectionsUnreadCount, long eventsUnreadCount) {
|
||||
public long notificationsUnreadCount() {
|
||||
return repliesUnreadCount + connectionsUnreadCount + eventsUnreadCount;
|
||||
}
|
||||
}
|
||||
|
||||
public static Snapshot calculate(Connection c, String login) throws Exception {
|
||||
long dm = countDmUnread(c, login);
|
||||
long channels = countChannelsUnread(c, login);
|
||||
UserNotificationSeenStateDAO seen = UserNotificationSeenStateDAO.getInstance();
|
||||
UserNotificationsStateDAO notifications = UserNotificationsStateDAO.getInstance();
|
||||
long repliesSeen = seen.getSeenAt(c, login, "replies");
|
||||
long connectionsSeen = seen.getSeenAt(c, login, "connections");
|
||||
long eventsSeen = seen.getSeenAt(c, login, "events");
|
||||
long replies = notifications.countUnseen(c, login, "reply", repliesSeen);
|
||||
long connections = notifications.countUnseen(c, login, "connection", connectionsSeen);
|
||||
long events = notifications.countUnseen(c, login, "event", eventsSeen);
|
||||
return new Snapshot(dm, channels, replies, connections, events);
|
||||
}
|
||||
|
||||
public static Snapshot calculate(String login) throws Exception {
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
return calculate(c, login);
|
||||
}
|
||||
}
|
||||
|
||||
public static void pushChanged(String login) {
|
||||
if (login == null || login.isBlank()) return;
|
||||
try {
|
||||
Snapshot s = calculate(login);
|
||||
ObjectNode payload = toPayload(login, s);
|
||||
String eventId = "user-counters-" + System.currentTimeMillis();
|
||||
for (ConnectionContext ctx : ActiveConnectionsRegistry.getInstance().getByLogin(login)) {
|
||||
WsEventSender.sendEvent(ctx, "UserCountersChanged", eventId, payload);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Counter push is best-effort. The client can always recover with GetUserCounters.
|
||||
}
|
||||
}
|
||||
|
||||
public static ObjectNode toPayload(String login, Snapshot s) {
|
||||
ObjectNode p = MAPPER.createObjectNode();
|
||||
p.put("login", login);
|
||||
p.put("dmUnreadCount", s.dmUnreadCount());
|
||||
p.put("channelsUnreadCount", s.channelsUnreadCount());
|
||||
p.put("notificationsUnreadCount", s.notificationsUnreadCount());
|
||||
ObjectNode n = p.putObject("notifications");
|
||||
n.put("replies", s.repliesUnreadCount());
|
||||
n.put("connections", s.connectionsUnreadCount());
|
||||
n.put("events", s.eventsUnreadCount());
|
||||
return p;
|
||||
}
|
||||
|
||||
private static long countDmUnread(Connection c, String login) throws Exception {
|
||||
String sql = "SELECT COALESCE(SUM(unread_count),0) FROM dm_dialog_state WHERE LOWER(owner_login)=LOWER(?)";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long countChannelsUnread(Connection c, String login) throws Exception {
|
||||
return ChannelReadStateDAO.getInstance().sumUnreadCount(c, login);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetMessageLikes_Request extends Net_Request {
|
||||
private MessageSelector message;
|
||||
private Integer limit;
|
||||
|
||||
public MessageSelector getMessage() { return message; }
|
||||
public void setMessage(MessageSelector message) { this.message = message; }
|
||||
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
|
||||
public static class MessageSelector {
|
||||
private String blockchainName;
|
||||
private Integer blockNumber;
|
||||
private String blockHash;
|
||||
|
||||
public String getBlockchainName() { return blockchainName; }
|
||||
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||
|
||||
public Integer getBlockNumber() { return blockNumber; }
|
||||
public void setBlockNumber(Integer blockNumber) { this.blockNumber = blockNumber; }
|
||||
|
||||
public String getBlockHash() { return blockHash; }
|
||||
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetMessageLikes_Response extends Net_Response {
|
||||
private List<UserItem> shining = new ArrayList<>();
|
||||
private List<UserItem> official = new ArrayList<>();
|
||||
private List<UserItem> others = new ArrayList<>();
|
||||
private int total;
|
||||
private boolean truncated;
|
||||
|
||||
public List<UserItem> getShining() { return shining; }
|
||||
public void setShining(List<UserItem> shining) { this.shining = shining; }
|
||||
public List<UserItem> getOfficial() { return official; }
|
||||
public void setOfficial(List<UserItem> official) { this.official = official; }
|
||||
public List<UserItem> getOthers() { return others; }
|
||||
public void setOthers(List<UserItem> others) { this.others = others; }
|
||||
public int getTotal() { return total; }
|
||||
public void setTotal(int total) { this.total = total; }
|
||||
public boolean isTruncated() { return truncated; }
|
||||
public void setTruncated(boolean truncated) { this.truncated = truncated; }
|
||||
|
||||
public static class UserItem {
|
||||
private String login;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String avatarAr;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public String getFirstName() { return firstName; }
|
||||
public void setFirstName(String firstName) { this.firstName = firstName; }
|
||||
public String getLastName() { return lastName; }
|
||||
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||
public String getAvatarAr() { return avatarAr; }
|
||||
public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; }
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
/** Lightweight authenticated request for bottom-toolbar counters. */
|
||||
public class Net_GetUserCounters_Request extends Net_Request {
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
/** Stable UI contract. Server-side calculation may be replaced by materialized counters later. */
|
||||
public class Net_GetUserCounters_Response extends Net_Response {
|
||||
private String login;
|
||||
private long dmUnreadCount;
|
||||
private long channelsUnreadCount;
|
||||
private long notificationsUnreadCount;
|
||||
private long notificationRepliesUnreadCount;
|
||||
private long notificationConnectionsUnreadCount;
|
||||
private long notificationEventsUnreadCount;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public long getDmUnreadCount() { return dmUnreadCount; }
|
||||
public void setDmUnreadCount(long value) { this.dmUnreadCount = value; }
|
||||
public long getChannelsUnreadCount() { return channelsUnreadCount; }
|
||||
public void setChannelsUnreadCount(long value) { this.channelsUnreadCount = value; }
|
||||
public long getNotificationsUnreadCount() { return notificationsUnreadCount; }
|
||||
public void setNotificationsUnreadCount(long value) { this.notificationsUnreadCount = value; }
|
||||
public long getNotificationRepliesUnreadCount() { return notificationRepliesUnreadCount; }
|
||||
public void setNotificationRepliesUnreadCount(long value) { this.notificationRepliesUnreadCount = value; }
|
||||
public long getNotificationConnectionsUnreadCount() { return notificationConnectionsUnreadCount; }
|
||||
public void setNotificationConnectionsUnreadCount(long value) { this.notificationConnectionsUnreadCount = value; }
|
||||
public long getNotificationEventsUnreadCount() { return notificationEventsUnreadCount; }
|
||||
public void setNotificationEventsUnreadCount(long value) { this.notificationEventsUnreadCount = value; }
|
||||
}
|
||||
+8
@@ -26,7 +26,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
public static class ChannelSummary {
|
||||
private ChannelRef channel;
|
||||
private int messagesCount;
|
||||
private long readCount;
|
||||
private int unreadCount;
|
||||
private boolean readStateInitialized;
|
||||
private LastMessage lastMessage;
|
||||
|
||||
public ChannelRef getChannel() { return channel; }
|
||||
@@ -35,9 +37,15 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
public int getMessagesCount() { return messagesCount; }
|
||||
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
|
||||
|
||||
public long getReadCount() { return readCount; }
|
||||
public void setReadCount(long readCount) { this.readCount = readCount; }
|
||||
|
||||
public int getUnreadCount() { return unreadCount; }
|
||||
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
||||
|
||||
public boolean isReadStateInitialized() { return readStateInitialized; }
|
||||
public void setReadStateInitialized(boolean readStateInitialized) { this.readStateInitialized = readStateInitialized; }
|
||||
|
||||
public LastMessage getLastMessage() { return lastMessage; }
|
||||
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_SetChannelReadState_Request extends Net_Request {
|
||||
private String login;
|
||||
private String owner_bch_name;
|
||||
private String channel_name;
|
||||
private Long read_count;
|
||||
private Long time_ms;
|
||||
private String client_key;
|
||||
private String signature;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getOwner_bch_name() { return owner_bch_name; }
|
||||
public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; }
|
||||
|
||||
public String getChannel_name() { return channel_name; }
|
||||
public void setChannel_name(String channel_name) { this.channel_name = channel_name; }
|
||||
|
||||
public Long getRead_count() { return read_count; }
|
||||
public void setRead_count(Long read_count) { this.read_count = read_count; }
|
||||
|
||||
public Long getTime_ms() { return time_ms; }
|
||||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||
|
||||
public String getClient_key() { return client_key; }
|
||||
public void setClient_key(String client_key) { this.client_key = client_key; }
|
||||
|
||||
public String getSignature() { return signature; }
|
||||
public void setSignature(String signature) { this.signature = signature; }
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public class Net_SetChannelReadState_Response extends Net_Response {
|
||||
private String login;
|
||||
private String owner_bch_name;
|
||||
private String channel_name;
|
||||
private Long read_count;
|
||||
private Long unread_count;
|
||||
private Long time_ms;
|
||||
private Boolean applied;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getOwner_bch_name() { return owner_bch_name; }
|
||||
public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; }
|
||||
|
||||
public String getChannel_name() { return channel_name; }
|
||||
public void setChannel_name(String channel_name) { this.channel_name = channel_name; }
|
||||
|
||||
public Long getRead_count() { return read_count; }
|
||||
public void setRead_count(Long read_count) { this.read_count = read_count; }
|
||||
|
||||
public Long getUnread_count() { return unread_count; }
|
||||
public void setUnread_count(Long unread_count) { this.unread_count = unread_count; }
|
||||
|
||||
public Long getTime_ms() { return time_ms; }
|
||||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||
|
||||
public Boolean getApplied() { return applied; }
|
||||
public void setApplied(Boolean applied) { this.applied = applied; }
|
||||
}
|
||||
+2
-1
@@ -5,6 +5,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.*;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
@@ -23,7 +24,7 @@ public final class Net_SetNotificationState_Handler implements JsonMessageHandle
|
||||
byte[] pub=Ed25519Util.keyFromBase64(ctx.getCurrentUser().getClientKey()); if(!Ed25519Util.verify(p.signedBody,p.signature64,pub)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_SIGNATURE","Некорректная подпись clientKey");
|
||||
long now=System.currentTimeMillis(); if(p.timeMs>now+5*60_000L) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_TIME","Некорректное время подписи");
|
||||
long actual; try(Connection c=DbController.getInstance().getConnection()){ actual=UserNotificationSeenStateDAO.getInstance().advance(c,login,p.categoryName(),p.seenAtMs,p.timeMs,raw); }
|
||||
Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); return r;
|
||||
Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); UserCountersSupport.pushChanged(login); return r;
|
||||
}catch(Exception e){ return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_NOTIFICATION_STATE",e.getMessage()==null?"Некорректное состояние уведомлений":e.getMessage()); }
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
@@ -59,6 +60,8 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
||||
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||
if (status.applied()) {
|
||||
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
||||
UserCountersSupport.pushChanged(incoming.toLogin);
|
||||
UserCountersSupport.pushChanged(incoming.fromLogin);
|
||||
}
|
||||
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
|
||||
|
||||
+9
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
@@ -78,6 +79,14 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||
if (pairStatus.applied()) {
|
||||
outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId);
|
||||
|
||||
// SendMessagePair is also the local-server delivery path. Without this push,
|
||||
// a recipient on the same access server receives the DM but the toolbar
|
||||
// counter stays stale until the next explicit GetUserCounters refresh.
|
||||
UserCountersSupport.pushChanged(incoming.toLogin);
|
||||
if (!incoming.fromLogin.equalsIgnoreCase(incoming.toLogin)) {
|
||||
UserCountersSupport.pushChanged(incoming.fromLogin);
|
||||
}
|
||||
}
|
||||
|
||||
if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+97
-8
@@ -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,
|
||||
|
||||
+1
-1
@@ -629,7 +629,7 @@ public final class SolanaUsersSyncService
|
||||
account.address(),
|
||||
account.slot(),
|
||||
account.dataBase64(),
|
||||
state.lastSeenSignature()
|
||||
""
|
||||
)
|
||||
);
|
||||
} catch (Exception ignored) {
|
||||
|
||||
+98
-180
@@ -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)"
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ public final class SolanaPdaUtil {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Ed25519.validatePublicKeyFull(
|
||||
return Ed25519.validatePublicKeyPartial(
|
||||
publicKey,
|
||||
0
|
||||
);
|
||||
|
||||
+27
@@ -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
@@ -1,2 +1,2 @@
|
||||
client.version=1.12.0
|
||||
server.version=1.10.0
|
||||
client.version=1.12.15
|
||||
server.version=1.10.6
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
|
||||
+18
-1
@@ -123,6 +123,23 @@ find_offline_gradle_zip() {
|
||||
fi
|
||||
done
|
||||
|
||||
local extracted_dir=""
|
||||
if [[ -d "$HOME/.gradle/wrapper/dists/gradle-8.14-bin" ]]; then
|
||||
extracted_dir="$(find "$HOME/.gradle/wrapper/dists/gradle-8.14-bin" -type d -name 'gradle-8.14' | sort | head -n 1 || true)"
|
||||
fi
|
||||
if [[ -n "$extracted_dir" && -d "$extracted_dir" ]]; then
|
||||
local generated_zip="$TMP/gradle-offline.zip"
|
||||
(
|
||||
cd "$(dirname -- "$extracted_dir")"
|
||||
rm -f -- "$generated_zip"
|
||||
zip -qr "$generated_zip" "gradle-8.14"
|
||||
)
|
||||
if [[ -f "$generated_zip" ]]; then
|
||||
printf '%s\n' "$generated_zip"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -269,7 +286,7 @@ if OFFLINE_ZIP_SRC="$(find_offline_gradle_zip)"; then
|
||||
OFFLINE_ZIP_NAME="gradle-offline.zip"
|
||||
else
|
||||
echo "ERROR: offline Gradle zip not found." >&2
|
||||
echo "Place it at ./offline/gradle-offline.zip or set BUNDLE_OFFLINE_GRADLE_ZIP." >&2
|
||||
echo "Place it at ./offline/gradle-offline.zip, set BUNDLE_OFFLINE_GRADLE_ZIP, or install Gradle 8.14 locally." >&2
|
||||
echo "The bundle is not created because this variant is meant to be offline-ready." >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -14,19 +14,24 @@
|
||||
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
|
||||
предки, фокус-сообщение, потомки.
|
||||
|
||||
4. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя.
|
||||
4. `GetMessageLikes` — отдает списки пользователей, поставивших лайк сообщению,
|
||||
сгруппированные для UI по статусу профиля.
|
||||
|
||||
5. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
|
||||
5. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя.
|
||||
|
||||
6. `ListGroupChats200` — отдает список групповых чатов типа `200`.
|
||||
6. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
|
||||
|
||||
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||
7. `SetChannelReadState` — сохраняет подписанный watermark чтения канала и возвращает новый unread-счетчик.
|
||||
|
||||
8. `ListGroupChats200` — отдает список групповых чатов типа `200`.
|
||||
|
||||
9. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
|
||||
|
||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||
>
|
||||
> `unreadCount` для канала считается по `user_settings`.
|
||||
> `unreadCount` для канала считается по подписанному состоянию чтения канала.
|
||||
> Для собственных каналов владельцу всегда возвращается `unreadCount = 0`, чтобы его собственные публикации не становились «новыми» для него самого.
|
||||
> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным; UI при загрузке списка каналов создаёт baseline на текущем `messagesCount`. После этого новые публикации увеличивают `unreadCount` до продвижения курсора чтения.
|
||||
> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным. После появления записи новые публикации увеличивают `unreadCount` до продвижения курсора чтения.
|
||||
|
||||
---
|
||||
|
||||
@@ -107,6 +112,9 @@
|
||||
"channelRoot": { "blockNumber": 456, "blockHash": "..." }
|
||||
},
|
||||
"messagesCount": 90,
|
||||
"readCount": 0,
|
||||
"unreadCount": 0,
|
||||
"readStateInitialized": false,
|
||||
"lastMessage": {
|
||||
"messageRef": { "blockNumber": 1002, "blockHash": "..." },
|
||||
"text": "актуальный текст",
|
||||
@@ -141,6 +149,8 @@
|
||||
}
|
||||
```
|
||||
|
||||
`limit` необязателен. Если поле отсутствует или равно `0`, сервер возвращает всю ленту канала. Положительное значение ограничивает количество сообщений для совместимых клиентов.
|
||||
|
||||
### Response (success)
|
||||
```json
|
||||
{
|
||||
@@ -266,7 +276,63 @@
|
||||
|
||||
---
|
||||
|
||||
## 4) GetPersonalDiary
|
||||
## 4) GetMessageLikes
|
||||
|
||||
Возвращает пользователей, которые поставили лайк конкретному сообщению канала.
|
||||
|
||||
- `message.blockchainName`, `message.blockNumber`, `message.blockHash` должны указывать на исходное сообщение.
|
||||
- `limit` в текущей реализации не требуется: сервер возвращает полный найденный список лайков.
|
||||
- Пользователи группируются по состоянию профиля:
|
||||
- `shining` — `account_role=primary` и `shine_status=shining`;
|
||||
- `official` — `account_role=primary`, но без `shine_status=shining`;
|
||||
- `others` — остальные пользователи.
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"op": "GetMessageLikes",
|
||||
"requestId": "req-4",
|
||||
"payload": {
|
||||
"message": {
|
||||
"blockchainName": "bob-001",
|
||||
"blockNumber": 140,
|
||||
"blockHash": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (success)
|
||||
```json
|
||||
{
|
||||
"op": "GetMessageLikes",
|
||||
"requestId": "req-4",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"shining": [
|
||||
{ "login": "Alice", "firstName": "Alice", "lastName": "", "avatarAr": "ArweaveTxId..." }
|
||||
],
|
||||
"official": [],
|
||||
"others": [
|
||||
{ "login": "Carl", "firstName": "", "lastName": "", "avatarAr": "" }
|
||||
],
|
||||
"total": 2,
|
||||
"truncated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ошибки
|
||||
- `bad_fields` — не передан `message` или обязательные поля ссылки на сообщение.
|
||||
- `bad_hash` — `message.blockHash` не является корректным hex-хэшем блока.
|
||||
- `internal_error` — внутренняя ошибка чтения.
|
||||
|
||||
`truncated` сейчас всегда `false`; поле оставлено в ответе для совместимости с UI и возможной будущей пагинацией.
|
||||
|
||||
---
|
||||
|
||||
## 5) GetPersonalDiary
|
||||
|
||||
Возвращает виртуальный канал `Личный дневник` для самого пользователя.
|
||||
|
||||
@@ -289,7 +355,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 4) GetChannelsCounters
|
||||
## 6) GetChannelsCounters
|
||||
|
||||
### Request
|
||||
```json
|
||||
@@ -321,7 +387,69 @@
|
||||
|
||||
---
|
||||
|
||||
## 5) ListGroupChats200
|
||||
## 7) SetChannelReadState
|
||||
|
||||
Сохраняет подписанную позицию чтения для одного канала, на который пользователь подписан.
|
||||
|
||||
- Требует авторизованное WebSocket-соединение.
|
||||
- `login` должен совпадать с текущим авторизованным пользователем.
|
||||
- Подпись строится client key пользователя по строке:
|
||||
|
||||
```text
|
||||
SHiNe/ChannelReadState:<login>|<owner_bch_name>|<channel_name>|<time_ms>|<read_count>
|
||||
```
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"op": "SetChannelReadState",
|
||||
"requestId": "req-7",
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"owner_bch_name": "bob-001",
|
||||
"channel_name": "news",
|
||||
"read_count": 90,
|
||||
"time_ms": 1760000000000,
|
||||
"client_key": "<base64-client-public-key>",
|
||||
"signature": "<base64-ed25519-signature>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (success)
|
||||
```json
|
||||
{
|
||||
"op": "SetChannelReadState",
|
||||
"requestId": "req-7",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"login": "Alice",
|
||||
"owner_bch_name": "bob-001",
|
||||
"channel_name": "news",
|
||||
"read_count": 90,
|
||||
"unread_count": 0,
|
||||
"time_ms": 1760000000000,
|
||||
"applied": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ошибки
|
||||
- `NOT_AUTHENTICATED` — нет авторизованной сессии.
|
||||
- `BAD_FIELDS` — не переданы обязательные поля.
|
||||
- `LOGIN_MISMATCH` — `login` не совпадает с текущей сессией.
|
||||
- `BAD_TIME` — время подписи слишком далеко в будущем.
|
||||
- `BAD_BASE64` — `client_key` или `signature` не являются корректным Base64.
|
||||
- `DEVICE_KEY_MISMATCH` — `client_key` не совпадает с текущим client key пользователя.
|
||||
- `INVALID_SIGNATURE` — подпись watermark не прошла проверку.
|
||||
- `CHANNEL_NOT_FOLLOWED` — пользователь не подписан на канал.
|
||||
- `CHANNEL_NOT_FOUND` — канал не найден.
|
||||
- `INTERNAL_ERROR` — внутренняя ошибка записи.
|
||||
|
||||
---
|
||||
|
||||
## 8) ListGroupChats200
|
||||
|
||||
### Request
|
||||
```json
|
||||
@@ -361,7 +489,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 6) GetGroupDialog
|
||||
## 9) GetGroupDialog
|
||||
|
||||
### Request
|
||||
```json
|
||||
@@ -414,8 +542,10 @@
|
||||
- `user_not_found`
|
||||
- `channel_not_found`
|
||||
- `message_not_found`
|
||||
- `limit_too_large`
|
||||
- `bad_limit`
|
||||
- `channel_name_already_exists`
|
||||
- `CHANNEL_NOT_FOLLOWED`
|
||||
- `CHANNEL_NOT_FOUND`
|
||||
- `internal_error`
|
||||
|
||||
---
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
- `ListSubscriptionsFeed` — экран списка каналов.
|
||||
- `GetChannelMessages` — сообщения конкретного канала.
|
||||
- `GetMessageThread` — дерево обсуждения для сообщения.
|
||||
- `GetMessageLikes` — списки пользователей, поставивших лайк сообщению.
|
||||
- `SetChannelReadState` — подписанный watermark чтения канала.
|
||||
|
||||
2. **UI вкладки Каналы**:
|
||||
- при открытии пытается загрузить реальный feed с сервера;
|
||||
@@ -33,12 +35,16 @@
|
||||
1. Вызвать `ListSubscriptionsFeed`.
|
||||
2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`.
|
||||
3. Для первого `messages[0]` вызвать `GetMessageThread`.
|
||||
4. Для первого `messages[0]` вызвать `GetMessageLikes`.
|
||||
5. Для подписанного канала вызвать `SetChannelReadState` с текущим `messagesCount`.
|
||||
|
||||
### Ошибки
|
||||
1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`.
|
||||
2. `GetChannelMessages` с битым channel payload -> `bad_fields`.
|
||||
3. `GetMessageThread` с несуществующим block -> `message_not_found`.
|
||||
4. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`.
|
||||
4. `GetMessageLikes` с битым `message.blockHash` -> `bad_hash`.
|
||||
5. `SetChannelReadState` без активной подписки -> `CHANNEL_NOT_FOLLOWED`.
|
||||
6. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`.
|
||||
|
||||
---
|
||||
|
||||
@@ -91,6 +97,38 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 3.4 GetMessageLikes
|
||||
```json
|
||||
{
|
||||
"op": "GetMessageLikes",
|
||||
"requestId": "debug-likes-1",
|
||||
"payload": {
|
||||
"message": {
|
||||
"blockchainName": "TestUser1-001",
|
||||
"blockNumber": 123,
|
||||
"blockHash": "<hash-from-GetChannelMessages>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3.5 SetChannelReadState
|
||||
```json
|
||||
{
|
||||
"op": "SetChannelReadState",
|
||||
"requestId": "debug-read-state-1",
|
||||
"payload": {
|
||||
"login": "TestUser1",
|
||||
"owner_bch_name": "TestUser2-001",
|
||||
"channel_name": "news",
|
||||
"read_count": 25,
|
||||
"time_ms": 1760000000000,
|
||||
"client_key": "<base64-client-public-key>",
|
||||
"signature": "<base64-ed25519-signature>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) Что смотреть в ответах
|
||||
@@ -101,6 +139,8 @@
|
||||
- у каждой записи есть:
|
||||
- `channel.channelRoot.blockNumber`,
|
||||
- `messagesCount`,
|
||||
- `unreadCount`,
|
||||
- `readStateInitialized`,
|
||||
- `lastMessage` (может быть null, если сообщений нет).
|
||||
|
||||
### GetChannelMessages
|
||||
@@ -115,6 +155,16 @@
|
||||
- у узлов должны быть версии и счетчики.
|
||||
- у каждого узла дополнительно может приходить `rawBlockB64` (Base64 сырого `block_bytes`).
|
||||
|
||||
### GetMessageLikes
|
||||
- `payload.shining[]`, `payload.official[]`, `payload.others[]` — группы пользователей.
|
||||
- у каждого пользователя есть `login`, `firstName`, `lastName`, `avatarAr`.
|
||||
- `payload.truncated` сейчас всегда `false`; поле оставлено для совместимости и будущей пагинации.
|
||||
|
||||
### SetChannelReadState
|
||||
- `payload.read_count` — сохраненная позиция чтения.
|
||||
- `payload.unread_count` — пересчитанный unread для канала.
|
||||
- `payload.applied=false` означает, что на сервере уже была более новая signed-позиция.
|
||||
|
||||
### Важно по совместимости
|
||||
- `rawBlockB64` добавлен только в `GetMessageThread`.
|
||||
- `GetChannelMessages` не содержит `rawBlockB64` (без изменений формата ленты).
|
||||
|
||||
@@ -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` | создание новой авторизованной сессии |
|
||||
@@ -46,8 +44,10 @@
|
||||
| `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок |
|
||||
| `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала |
|
||||
| `GetMessageThread` | `06_Channels_Read_API.md` | тред сообщения |
|
||||
| `GetMessageLikes` | `06_Channels_Read_API.md` | списки пользователей, поставивших лайк сообщению |
|
||||
| `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION |
|
||||
| `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов |
|
||||
| `SetChannelReadState` | `06_Channels_Read_API.md` | подписанный watermark чтения канала |
|
||||
| `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` |
|
||||
| `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` |
|
||||
| `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя |
|
||||
@@ -80,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`.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# User counters API
|
||||
|
||||
## GetUserCounters
|
||||
Authenticated lightweight WebSocket request used by the bottom toolbar.
|
||||
|
||||
Request:
|
||||
```json
|
||||
{"op":"GetUserCounters","requestId":"...","payload":{}}
|
||||
```
|
||||
|
||||
Success payload:
|
||||
```json
|
||||
{
|
||||
"login": "alice",
|
||||
"dmUnreadCount": 7,
|
||||
"channelsUnreadCount": 12,
|
||||
"notificationsUnreadCount": 5,
|
||||
"notificationRepliesUnreadCount": 2,
|
||||
"notificationConnectionsUnreadCount": 1,
|
||||
"notificationEventsUnreadCount": 2
|
||||
}
|
||||
```
|
||||
|
||||
The fields form a stable UI contract. Their server-side calculation is intentionally replaceable by materialized counters/cache later.
|
||||
|
||||
## UserCountersChanged
|
||||
Server push event for every active session of the user. It lets the toolbar update without polling.
|
||||
|
||||
```json
|
||||
{
|
||||
"op":"UserCountersChanged",
|
||||
"event":true,
|
||||
"payload":{
|
||||
"login":"alice",
|
||||
"dmUnreadCount":7,
|
||||
"channelsUnreadCount":12,
|
||||
"notificationsUnreadCount":5,
|
||||
"notifications":{"replies":2,"connections":1,"events":2}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Current implementation emits this event after channel read-state writes, notification seen-state changes and applied incoming DM/read-receipt blocks. Future materialized counter logic can emit the same event immediately on any counter increment/decrement.
|
||||
|
||||
## Channel read behaviour
|
||||
- Opening a subscribed channel does **not** mark all messages read.
|
||||
- The UI advances readCount only when message cards cross the viewport read threshold while scrolling.
|
||||
- Writes are debounced; failed writes are shown to the user and retried.
|
||||
- Immediately after a successful subscription from inside an open channel, the client stores `readCount = current messagesCount`, because those existing messages are treated as already viewed at subscription time.
|
||||
@@ -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
@@ -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-а.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
В приложении: `Настройки → Архив блокчейна`.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
```
|
||||
@@ -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
|
||||
@@ -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` при этом не меняется.
|
||||
|
||||
@@ -131,6 +131,12 @@ dm_dialog_state хранит:
|
||||
WebSocket push ускоряет отображение, но после переподключения клиент запрашивает
|
||||
историю у своего единственного access-сервера.
|
||||
|
||||
UI-правило для открытого диалога: разделитель «Новые сообщения» относится только к
|
||||
непрочитанным сообщениям, которые уже существовали до открытия экрана чата. Если новое
|
||||
входящее сообщение приходит, пока этот диалог уже открыт, клиент просто добавляет его в
|
||||
текущий поток, не создавая новый разделитель «Новые сообщения»; затем обычный механизм
|
||||
видимого чтения/receipt отмечает его прочитанным.
|
||||
|
||||
## 9. Подтверждения прочтения
|
||||
|
||||
Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же
|
||||
@@ -222,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`.
|
||||
|
||||
@@ -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, прогрессом и длительностью. Аудио расшифровывается только на клиенте.
|
||||
@@ -264,6 +264,12 @@ ReadReceiptBody_v1_0
|
||||
- если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад.
|
||||
- в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview.
|
||||
|
||||
|
||||
UI-примечание (байтовый формат не меняет): разделитель «Новые сообщения» создаётся только
|
||||
для непрочитанного хвоста, который существовал до открытия диалога. Входящий `type=1`,
|
||||
полученный при уже открытом соответствующем чате, отображается сразу без создания нового
|
||||
разделителя; это не изменяет signed-контейнер и не вводит нового поля протокола.
|
||||
|
||||
## 9. Контент типов `5/6`
|
||||
|
||||
Типы:
|
||||
@@ -365,3 +371,25 @@ ReadReceiptBody_v1_0
|
||||
Контейнер `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
@@ -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`;
|
||||
- лимиты блокчейнов могут только увеличиваться;
|
||||
- занятый размер и номер последнего блока не могут уменьшаться;
|
||||
- при увеличении оплаченного лимита пользователь доплачивает комиссию;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user