SHA256
Compare commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
be696d116b | ||
|
|
23969121e5 | ||
|
|
4da2d7129a | ||
|
|
2be5ad0b7d | ||
|
|
f44ab75c64 | ||
|
|
7aa05dc609 | ||
|
|
7b81d140ec | ||
|
|
1be1d56599 | ||
|
|
2df0a78eb2 | ||
|
|
c3253dceac | ||
|
|
ef068eca81 | ||
|
|
f9ae811702 | ||
|
|
ea0098d705 | ||
|
|
4991a146d5 | ||
|
|
849250bfa8 | ||
|
|
d83f1d4cce | ||
|
|
4c7e71f21f | ||
|
|
107f85b818 | ||
|
|
3a466b4c38 | ||
|
|
08663fa339 | ||
|
|
8e86872aa7 | ||
|
|
2d059e9ff5 | ||
|
|
9f77c54955 | ||
|
|
f827c3e493 | ||
|
|
ae2f2fac41 | ||
|
|
1f70d36e74 | ||
|
|
7d27bfdcaf | ||
|
|
023d61a1e9 | ||
|
|
85d90a7f95 | ||
|
|
e0295eebde | ||
|
|
ebc9143593 | ||
|
|
e7c8fd748c | ||
|
|
aff601f61a | ||
|
|
0c5089fa79 | ||
|
|
e5556f3706 | ||
|
|
09c7f8541e | ||
|
|
8eb2ef438c | ||
|
|
34abe14151 | ||
|
|
90e37c198f | ||
|
|
1373283947 | ||
|
|
065616a18b | ||
|
|
53cd55a2a0 | ||
|
|
079be37fff | ||
|
|
806a8c57d4 | ||
|
|
797e769cc3 | ||
|
|
822a72e246 | ||
|
|
58b564b385 | ||
|
|
7aa9e2f6a4 | ||
|
|
f645686d87 | ||
|
|
6fcaa5d3d4 | ||
|
|
5d19e17a0b | ||
|
|
52a9f1ac1d | ||
|
|
3dec5e3225 | ||
|
|
90ded1612a | ||
|
|
7ab9fb7c28 | ||
|
|
6f499c68e2 | ||
|
|
19beba8fd7 | ||
|
|
f7e33802c8 | ||
|
|
c03d94105c | ||
|
|
632d56737b | ||
|
|
8801b93973 | ||
|
|
390a2b988e | ||
|
|
a66d7a64e6 | ||
|
|
a8596d3350 | ||
|
|
8a5dabee95 | ||
|
|
da859468d3 | ||
|
|
78ddb8ffb6 | ||
|
|
db37f35d09 | ||
|
|
d8f5fe4fd0 |
@@ -105,6 +105,7 @@ ESP32/**/*.a
|
||||
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
||||
deploy/backup/archive/**
|
||||
!deploy/backup/archive/.gitkeep
|
||||
SHiNE-bundle-*.zip
|
||||
|
||||
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
||||
.agents/
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# SHiNE ANS-104 `test5590` patch
|
||||
|
||||
Этот архив содержит **только новые/изменённые файлы** плюс `DELETE_FILES.txt`.
|
||||
|
||||
## Что меняется
|
||||
|
||||
- Frame v1 без поддержки старого формата.
|
||||
- Один пользовательский block = готовый Ed25519 ANS-104 DataItem.
|
||||
- Test tag: `App=test5590`; channel tag: `c=<canonical slug>`.
|
||||
- SHiNE `prevHash` остаётся `SHA-256(FrameV1Bytes)` и не зависит от Arweave ID.
|
||||
- PostgreSQL — единственное локальное хранилище пользовательских blocks.
|
||||
- Старый custom SHINE-ARCHIVE и filesystem blockchain storage удаляются.
|
||||
- Каждые ~15 минут локальные pending DataItems собираются в standard ANS-104 bundle и идут через существующий native Arweave L1 transport.
|
||||
- Другие серверы находят `App=test5590`, импортируют и не публикуют повторно.
|
||||
- `ESP32/**` **не изменён**.
|
||||
|
||||
## Состав
|
||||
|
||||
- изменено/добавлено файлов: 46
|
||||
- удалить файлов: 41
|
||||
|
||||
Применение и проверки: `docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md`.
|
||||
|
||||
## Проверки при сборке patch
|
||||
|
||||
- `node --check` для изменённых production JS: OK.
|
||||
- `diff -qr` каталога `ESP32` относительно исходного архива: без изменений.
|
||||
- Active Java refs на удалённые SHINE-ARCHIVE/file-recovery classes: отсутствуют.
|
||||
- `./gradlew testClasses --offline --no-daemon`: не запущен до компиляции, потому что wrapper требует скачать Gradle 8.14 с `services.gradle.org`, а рабочая среда не имеет внешнего сетевого доступа. Полный Gradle test обязателен после применения patch в dev-среде.
|
||||
@@ -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,103 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.Ans104DataItem;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/** Minimal ANS-104 binary bundle writer/reader. */
|
||||
public final class Ans104Bundle {
|
||||
public record Entry(byte[] id32, byte[] rawDataItem) {}
|
||||
|
||||
private Ans104Bundle() {}
|
||||
|
||||
public static byte[] write(List<byte[]> rawItems) {
|
||||
if (rawItems == null || rawItems.isEmpty()) throw new IllegalArgumentException("ANS-104 bundle is empty");
|
||||
if (rawItems.size() > 1_000_000) throw new IllegalArgumentException("Too many ANS-104 items");
|
||||
|
||||
List<Ans104DataItem> parsed = new ArrayList<>(rawItems.size());
|
||||
long total = 32L + 64L * rawItems.size();
|
||||
for (byte[] raw : rawItems) {
|
||||
Ans104DataItem item = new Ans104DataItem(raw);
|
||||
if (!item.verifySignature()) throw new IllegalArgumentException("Invalid ANS-104 DataItem signature");
|
||||
parsed.add(item);
|
||||
total = Math.addExact(total, raw.length);
|
||||
}
|
||||
if (total > Integer.MAX_VALUE) throw new IllegalArgumentException("Bundle too large for in-memory publisher: " + total);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream((int) total);
|
||||
out.writeBytes(uint256LE(rawItems.size()));
|
||||
for (int i = 0; i < rawItems.size(); i++) {
|
||||
out.writeBytes(uint256LE(rawItems.get(i).length));
|
||||
out.writeBytes(parsed.get(i).id32());
|
||||
}
|
||||
for (byte[] raw : rawItems) out.writeBytes(raw);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
public static List<Entry> read(byte[] bundle, int maxItems) {
|
||||
if (bundle == null || bundle.length < 32) throw new IllegalArgumentException("ANS-104 bundle too short");
|
||||
long countLong = readUint256AsLong(bundle, 0);
|
||||
if (countLong <= 0 || countLong > Math.max(1, maxItems)) {
|
||||
throw new IllegalArgumentException("Bad ANS-104 bundle item count: " + countLong);
|
||||
}
|
||||
int count = (int) countLong;
|
||||
long headerLong = 32L + 64L * count;
|
||||
if (headerLong > bundle.length) throw new IllegalArgumentException("ANS-104 bundle header truncated");
|
||||
int header = (int) headerLong;
|
||||
long[] sizes = new long[count];
|
||||
byte[][] ids = new byte[count][];
|
||||
int hp = 32;
|
||||
long payloadBytes = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
sizes[i] = readUint256AsLong(bundle, hp); hp += 32;
|
||||
ids[i] = Arrays.copyOfRange(bundle, hp, hp + 32); hp += 32;
|
||||
if (sizes[i] <= 0 || sizes[i] > Integer.MAX_VALUE) throw new IllegalArgumentException("Bad DataItem size: " + sizes[i]);
|
||||
payloadBytes = Math.addExact(payloadBytes, sizes[i]);
|
||||
}
|
||||
if (headerLong + payloadBytes != bundle.length) {
|
||||
throw new IllegalArgumentException("ANS-104 bundle size mismatch: header=" + headerLong + " payload=" + payloadBytes + " actual=" + bundle.length);
|
||||
}
|
||||
List<Entry> out = new ArrayList<>(count);
|
||||
int p = header;
|
||||
for (int i = 0; i < count; i++) {
|
||||
int len = (int) sizes[i];
|
||||
byte[] raw = Arrays.copyOfRange(bundle, p, p + len);
|
||||
p += len;
|
||||
Ans104DataItem item = new Ans104DataItem(raw);
|
||||
if (!Arrays.equals(ids[i], item.id32())) throw new IllegalArgumentException("Bundle entry ID mismatch at index " + i);
|
||||
out.add(new Entry(ids[i], raw));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public static byte[] find(byte[] bundle, byte[] wantedId32, int maxItems) {
|
||||
if (wantedId32 == null || wantedId32.length != 32) throw new IllegalArgumentException("wanted ID must be 32 bytes");
|
||||
for (Entry e : read(bundle, maxItems)) {
|
||||
if (Arrays.equals(e.id32(), wantedId32)) return e.rawDataItem();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** ANS-104 bundle integers are 32-byte unsigned little-endian values. */
|
||||
static byte[] uint256LE(long value) {
|
||||
if (value < 0) throw new IllegalArgumentException("negative uint256");
|
||||
byte[] out = new byte[32];
|
||||
long v = value;
|
||||
for (int i = 0; i < 8; i++) { out[i] = (byte) (v & 0xff); v >>>= 8; }
|
||||
return out;
|
||||
}
|
||||
|
||||
static long readUint256AsLong(byte[] data, int offset) {
|
||||
if (offset < 0 || offset + 32 > data.length) throw new IllegalArgumentException("uint256 truncated");
|
||||
for (int i = offset + 8; i < offset + 32; i++) {
|
||||
if (data[i] != 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
|
||||
}
|
||||
long v = 0;
|
||||
for (int i = 7; i >= 0; i--) v = (v << 8) | (data[offset + i] & 0xffL);
|
||||
if (v < 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
|
||||
return v;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Fixed-delay publisher. Empty cycles are no-ops. */
|
||||
public final class ArweaveBlockPublisherScheduler {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherScheduler.class);
|
||||
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||
private static ScheduledExecutorService executor;
|
||||
|
||||
private ArweaveBlockPublisherScheduler() {}
|
||||
|
||||
public static void startOrLog() {
|
||||
ArweaveBlocksConfig cfg;
|
||||
try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); }
|
||||
catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; }
|
||||
if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled"); return; }
|
||||
if (!STARTED.compareAndSet(false,true)) return;
|
||||
try {
|
||||
ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg);
|
||||
executor = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "shine-arweave-block-publisher"); t.setDaemon(true); return t;
|
||||
});
|
||||
Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } };
|
||||
executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES);
|
||||
log.info("Arweave user-block publisher enabled: interval={}m gateway={}", cfg.publishIntervalMinutes(), cfg.publishGateway());
|
||||
} catch (Exception e) { STARTED.set(false); log.error("Arweave user-block publisher failed to start", e); }
|
||||
}
|
||||
|
||||
public static void close() { ScheduledExecutorService e=executor; if(e!=null)e.shutdownNow(); }
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.entities.BlockEntry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Publishes locally-created signed user DataItems as one standard ANS-104 bundle. */
|
||||
public final class ArweaveBlockPublisherService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class);
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||
private final ArweaveL1Uploader uploader;
|
||||
|
||||
public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) {
|
||||
this.cfg = cfg;
|
||||
this.uploader = new ArweaveL1Uploader(cfg);
|
||||
}
|
||||
|
||||
public int runCycle() throws Exception {
|
||||
List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems());
|
||||
if (candidates.isEmpty()) return 0;
|
||||
|
||||
List<byte[]> items = new ArrayList<>();
|
||||
List<byte[]> ids = new ArrayList<>();
|
||||
long estimated = 32;
|
||||
for (BlockEntry e : candidates) {
|
||||
byte[] raw = e.getBlockBytes();
|
||||
byte[] id = e.getDataItemId();
|
||||
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
|
||||
long next = estimated + 64L + raw.length;
|
||||
if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break;
|
||||
if (next > cfg.publishMaxBundleBytes()) {
|
||||
log.error("One DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}",
|
||||
e.getBchName(), e.getBlockNumber(), raw.length);
|
||||
continue;
|
||||
}
|
||||
items.add(raw); ids.add(id); estimated = next;
|
||||
}
|
||||
if (items.isEmpty()) return 0;
|
||||
|
||||
byte[] bundle = Ans104Bundle.write(items);
|
||||
List<ArweaveL1Uploader.Tag> rootTags = List.of(
|
||||
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
|
||||
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
|
||||
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0"),
|
||||
// Deliberately different from App=test5590 so discovery returns child DataItems only.
|
||||
new ArweaveL1Uploader.Tag("App", "test5590-batch")
|
||||
);
|
||||
ArweaveL1Uploader.UploadResult result = uploader.upload(bundle, rootTags);
|
||||
blocksDAO.markArweavePublished(ids, result.txId(), System.currentTimeMillis());
|
||||
log.info("Published {} SHiNE test DataItems in root tx {} (bundle={} bytes)", items.size(), result.txId(), bundle.length);
|
||||
return items.size();
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class ArweaveBlockSyncScheduler {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncScheduler.class);
|
||||
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||
private static ScheduledExecutorService executor;
|
||||
private ArweaveBlockSyncScheduler() {}
|
||||
|
||||
public static void startOrLog() {
|
||||
ArweaveBlocksConfig cfg;
|
||||
try { cfg = ArweaveBlocksConfig.load(); }
|
||||
catch (Exception e) { log.error("Cannot read Arweave block sync config", e); return; }
|
||||
if (!cfg.syncEnabled()) { log.info("Arweave user-block sync disabled"); return; }
|
||||
if (!STARTED.compareAndSet(false,true)) return;
|
||||
try {
|
||||
ArweaveBlockSyncService service = new ArweaveBlockSyncService(cfg);
|
||||
executor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t=new Thread(r,"shine-arweave-block-sync");t.setDaemon(true);return t; });
|
||||
Runnable task=()->{try{service.runCycle();}catch(Exception e){log.error("Arweave block sync cycle failed",e);}};
|
||||
executor.scheduleWithFixedDelay(task,0,cfg.syncIntervalMinutes(),TimeUnit.MINUTES);
|
||||
log.info("Arweave user-block sync enabled: interval={}m gateway={}",cfg.syncIntervalMinutes(),cfg.syncGateway());
|
||||
} catch (Exception e) { STARTED.set(false); log.error("Arweave user-block sync failed to start",e); }
|
||||
}
|
||||
public static void close(){ScheduledExecutorService e=executor;if(e!=null)e.shutdownNow();}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.Ans104DataItem;
|
||||
import blockchain.BchBlockEntry;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||
import shine.db.dao.ArweaveBlockImportDAO;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.SolanaUserPdaCurrentDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||
|
||||
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.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Discovers App=test5590 child DataItems, extracts their exact serialized bytes
|
||||
* from the root ANS-104 bundle and imports them through the normal AddBlock checks.
|
||||
*/
|
||||
public final class ArweaveBlockSyncService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
private final ArweaveBlockImportDAO importDAO = ArweaveBlockImportDAO.getInstance();
|
||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||
private final SolanaUserPdaCurrentDAO usersDAO = SolanaUserPdaCurrentDAO.getInstance();
|
||||
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
||||
private final Net_AddBlock_Handler addBlock = new Net_AddBlock_Handler();
|
||||
|
||||
public ArweaveBlockSyncService(ArweaveBlocksConfig cfg) { this.cfg = cfg; }
|
||||
|
||||
public void runCycle() throws Exception {
|
||||
discover();
|
||||
drainQueue();
|
||||
}
|
||||
|
||||
private void discover() throws Exception {
|
||||
long stored = importDAO.getLastBlockHeight();
|
||||
long minHeight = Math.max(cfg.syncStartBlockHeight(), stored);
|
||||
String cursor = null;
|
||||
long maxHeight = minHeight;
|
||||
Map<String, byte[]> rootCache = new HashMap<>();
|
||||
int discovered = 0;
|
||||
|
||||
do {
|
||||
JsonNode response = graphQlPage(minHeight, cursor);
|
||||
JsonNode txs = response.path("data").path("transactions");
|
||||
if (response.has("errors")) throw new IOException("Arweave GraphQL errors: " + response.path("errors"));
|
||||
JsonNode edges = txs.path("edges");
|
||||
if (!edges.isArray()) throw new IOException("Arweave GraphQL returned no transactions.edges");
|
||||
|
||||
String nextCursor = null;
|
||||
for (JsonNode edge : edges) {
|
||||
nextCursor = edge.path("cursor").asText(null);
|
||||
JsonNode node = edge.path("node");
|
||||
String itemIdText = node.path("id").asText("").trim();
|
||||
String rootTx = node.path("bundledIn").path("id").asText("").trim();
|
||||
long height = node.path("block").path("height").asLong(-1L);
|
||||
if (itemIdText.isBlank() || rootTx.isBlank() || height < 0) continue;
|
||||
maxHeight = Math.max(maxHeight, height);
|
||||
byte[] id32;
|
||||
try { id32 = B64URL.decode(itemIdText); }
|
||||
catch (IllegalArgumentException bad) { continue; }
|
||||
if (id32.length != 32 || blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue;
|
||||
|
||||
byte[] bundle = rootCache.computeIfAbsent(rootTx, key -> {
|
||||
try { return downloadRootBundle(key); }
|
||||
catch (Exception e) { throw new RootDownloadRuntimeException(e); }
|
||||
});
|
||||
byte[] raw = Ans104Bundle.find(bundle, id32, Math.max(cfg.publishMaxItems() * 4, 100_000));
|
||||
if (raw == null) throw new IOException("DataItem " + itemIdText + " not found in root bundle " + rootTx);
|
||||
Ans104DataItem parsed = new Ans104DataItem(raw);
|
||||
if (!parsed.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) continue;
|
||||
if (!parsed.verifySignature()) throw new IOException("Bad ANS-104 signature for " + itemIdText);
|
||||
importDAO.enqueueIfMissing(id32, rootTx, height, raw, System.currentTimeMillis());
|
||||
discovered++;
|
||||
}
|
||||
boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false);
|
||||
cursor = hasNext ? nextCursor : null;
|
||||
if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor");
|
||||
} while (cursor != null);
|
||||
|
||||
// Keep one-height overlap: the next query includes this height and deduplicates IDs.
|
||||
importDAO.setLastBlockHeight(maxHeight, System.currentTimeMillis());
|
||||
if (discovered > 0) log.info("Arweave discovery queued {} new SHiNE test DataItems through height {}", discovered, maxHeight);
|
||||
}
|
||||
|
||||
private void drainQueue() throws Exception {
|
||||
int passes = 0;
|
||||
int importedTotal = 0;
|
||||
boolean progress;
|
||||
do {
|
||||
progress = false;
|
||||
List<ArweaveBlockImportDAO.QueueItem> pending = importDAO.listPending(cfg.syncQueueBatchSize());
|
||||
if (pending.isEmpty()) break;
|
||||
for (ArweaveBlockImportDAO.QueueItem q : pending) {
|
||||
Ans104DataItem item;
|
||||
BchBlockEntry block;
|
||||
try {
|
||||
item = new Ans104DataItem(q.rawDataItem());
|
||||
if (!Arrays.equals(item.id32(), q.dataItemId())) throw new IllegalArgumentException("data_item_id mismatch");
|
||||
if (!item.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) throw new IllegalArgumentException("bad App tag");
|
||||
if (!item.verifySignature()) throw new IllegalArgumentException("bad ANS-104 signature");
|
||||
block = new BchBlockEntry(q.rawDataItem());
|
||||
} catch (Exception e) {
|
||||
importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis());
|
||||
continue;
|
||||
}
|
||||
|
||||
SolanaUserPdaCurrentEntry user = usersDAO.getByBlockchainKey(item.owner32());
|
||||
if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) {
|
||||
importDAO.setPendingError(q.dataItemId(), "unknown_owner", System.currentTimeMillis());
|
||||
continue;
|
||||
}
|
||||
ensureState(user);
|
||||
Net_AddBlock_Handler.ArweaveImportResult result = addBlock.addBlockFromArweave(user.getBlockchainName(), q.rawDataItem());
|
||||
if (result.ok()) {
|
||||
importDAO.delete(q.dataItemId());
|
||||
importedTotal++; progress = true;
|
||||
continue;
|
||||
}
|
||||
String reason = result.reasonCode();
|
||||
if ("bad_block_number".equals(reason) || "bad_prev_hash".equals(reason)
|
||||
|| "prev_line_block_not_found".equals(reason) || "channel_not_found".equals(reason)
|
||||
|| "chain_resync_in_progress".equals(reason)) {
|
||||
importDAO.setPendingError(q.dataItemId(), reason, System.currentTimeMillis());
|
||||
} else {
|
||||
importDAO.reject(q.dataItemId(), reason, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
passes++;
|
||||
} while (progress && passes < 8);
|
||||
if (importedTotal > 0) log.info("Imported {} SHiNE test DataItems from Arweave", importedTotal);
|
||||
}
|
||||
|
||||
private void ensureState(SolanaUserPdaCurrentEntry user) throws Exception {
|
||||
BlockchainStateEntry existing = stateDAO.getByBlockchainName(user.getBlockchainName());
|
||||
if (existing != null) return;
|
||||
BlockchainStateEntry s = new BlockchainStateEntry();
|
||||
s.setBlockchainName(user.getBlockchainName()); s.setLogin(user.getLogin()); s.setBlockchainKey(user.getBlockchainKey());
|
||||
s.setSizeLimit(user.getPaidLimitBytes() > 0 ? user.getPaidLimitBytes() : 100_000L);
|
||||
s.setFileSizeBytes(0L); s.setLastBlockNumber(-1); s.setLastBlockHash(null); s.setUpdatedAtMs(System.currentTimeMillis());
|
||||
stateDAO.insertIfMissing(s);
|
||||
}
|
||||
|
||||
private JsonNode graphQlPage(long minHeight, String cursor) throws Exception {
|
||||
String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\"";
|
||||
String query = "query { transactions(tags:[{name:\"App\",values:[\"test5590\"]}], block:{min:" + minHeight + "}, first:" + cfg.syncPageSize() + ", after:" + after + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id bundledIn { id } block { height } } } } }";
|
||||
String body = MAPPER.writeValueAsString(Map.of("query", query));
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql"))
|
||||
.timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(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("GraphQL HTTP " + resp.statusCode());
|
||||
return MAPPER.readTree(resp.body());
|
||||
}
|
||||
|
||||
private byte[] downloadRootBundle(String txId) throws Exception {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/" + txId))
|
||||
.timeout(Duration.ofMinutes(5)).GET().build();
|
||||
HttpResponse<byte[]> resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Arweave root HTTP " + resp.statusCode() + " tx=" + txId);
|
||||
byte[] body = resp.body();
|
||||
if (body == null || body.length == 0) throw new IOException("Empty Arweave root bundle " + txId);
|
||||
if (body.length > cfg.syncMaxRootBundleBytes()) throw new IOException("Root bundle exceeds syncMaxRootBundleBytes: " + body.length);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");}
|
||||
private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");}
|
||||
private static final class RootDownloadRuntimeException extends RuntimeException { RootDownloadRuntimeException(Throwable cause){super(cause);} }
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package server.archive;
|
||||
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** Configuration of the new per-user-block ANS-104 Arweave transport. */
|
||||
public record ArweaveBlocksConfig(
|
||||
boolean publishEnabled,
|
||||
int publishIntervalMinutes,
|
||||
int publishMaxItems,
|
||||
long publishMaxBundleBytes,
|
||||
String publishGateway,
|
||||
Path walletJwkPath,
|
||||
int minConfirmations,
|
||||
int confirmPollSeconds,
|
||||
int confirmTimeoutMinutes,
|
||||
boolean syncEnabled,
|
||||
int syncIntervalMinutes,
|
||||
int syncPageSize,
|
||||
int syncQueueBatchSize,
|
||||
long syncStartBlockHeight,
|
||||
long syncMaxRootBundleBytes,
|
||||
String syncGateway
|
||||
) {
|
||||
public static final String TEST_TAG_NAME = "App";
|
||||
public static final String TEST_TAG_VALUE = "test5590";
|
||||
public static final String CHANNEL_TAG_NAME = "c";
|
||||
|
||||
public static ArweaveBlocksConfig load() {
|
||||
AppConfig c = AppConfig.getInstance();
|
||||
return new ArweaveBlocksConfig(
|
||||
c.getBoolean("arweave.blocks.publish.enabled", false),
|
||||
positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"),
|
||||
positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"),
|
||||
positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"),
|
||||
orDefault(c.getParam("arweave.blocks.publish.gateway"), "https://arweave.net"),
|
||||
optionalPath(c.getParam("arweave.blocks.publish.walletJwkPath")),
|
||||
nonNegative(c.getInt("arweave.blocks.publish.minConfirmations", 0), "publish.minConfirmations"),
|
||||
positive(c.getInt("arweave.blocks.publish.confirmPollSeconds", 30), "publish.confirmPollSeconds"),
|
||||
positive(c.getInt("arweave.blocks.publish.confirmTimeoutMinutes", 180), "publish.confirmTimeoutMinutes"),
|
||||
c.getBoolean("arweave.blocks.sync.enabled", false),
|
||||
positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"),
|
||||
clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100),
|
||||
positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"),
|
||||
nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"),
|
||||
positiveLong(parseLong(c.getParam("arweave.blocks.sync.maxRootBundleBytes"), 256L * 1024 * 1024), "sync.maxRootBundleBytes"),
|
||||
orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com")
|
||||
);
|
||||
}
|
||||
|
||||
public void validatePublisher() {
|
||||
if (publishEnabled && walletJwkPath == null) {
|
||||
throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required when publisher is enabled");
|
||||
}
|
||||
}
|
||||
|
||||
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
|
||||
private static Path optionalPath(String v) { return v == null || v.isBlank() ? null : Path.of(v.trim()); }
|
||||
private static long parseLong(String v, long d) { return v == null || v.isBlank() ? d : Long.parseLong(v.trim()); }
|
||||
private static int positive(int v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;}
|
||||
private static int nonNegative(int v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;}
|
||||
private static long positiveLong(long v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;}
|
||||
private static long nonNegativeLong(long v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;}
|
||||
private static int clamp(int v,int min,int max){return Math.max(min,Math.min(max,v));}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
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.security.*;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PSSParameterSpec;
|
||||
import java.security.spec.RSAPrivateCrtKeySpec;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/** Existing native Arweave L1 transport adapted to upload in-memory ANS-104 bundles. */
|
||||
public final class ArweaveL1Uploader {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveL1Uploader.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();
|
||||
|
||||
public record Tag(String name, String value) {}
|
||||
public record UploadResult(String txId, byte[] txIdBytes) {}
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
private volatile Wallet wallet;
|
||||
|
||||
public ArweaveL1Uploader(ArweaveBlocksConfig cfg) { this.cfg = Objects.requireNonNull(cfg); }
|
||||
|
||||
public UploadResult upload(byte[] data, List<Tag> tags) throws Exception {
|
||||
if (data == null || data.length == 0) throw new IllegalArgumentException("Arweave upload data is empty");
|
||||
Wallet w = wallet();
|
||||
String gateway = trimGateway(cfg.publishGateway());
|
||||
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(data);
|
||||
String anchor = getRequiredText(gateway, "/tx_anchor");
|
||||
String reward = getRequiredText(gateway, "/price/" + data.length);
|
||||
if (!reward.matches("^\\d+$")) throw new IOException("Bad Arweave price: " + reward);
|
||||
verifyBalance(gateway, w.address(), reward);
|
||||
|
||||
List<List<byte[]>> tagList = new ArrayList<>();
|
||||
List<Map<String,String>> jsonTags = new ArrayList<>();
|
||||
for (Tag tag : tags == null ? List.<Tag>of() : tags) addTag(jsonTags, tagList, tag.name(), tag.value());
|
||||
|
||||
byte[] signaturePayload = deepHash(List.of(
|
||||
utf8("2"), b64Decode(w.owner()), new byte[0], utf8("0"), utf8(reward),
|
||||
b64Decode(anchor), tagList, utf8(Integer.toString(data.length)), 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",Integer.toString(data.length)); tx.put("data","");
|
||||
tx.put("reward",reward); tx.put("signature",b64(rawSignature));
|
||||
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
|
||||
|
||||
for (int i=0;i<merkle.chunks().size();i++) {
|
||||
ArweaveMerkle.Chunk meta=merkle.chunks().get(i); ArweaveMerkle.Proof proof=merkle.proofs().get(i);
|
||||
Map<String,Object> body=new LinkedHashMap<>();
|
||||
body.put("data_root",b64(merkle.dataRoot())); body.put("data_size",Integer.toString(data.length));
|
||||
body.put("data_path",b64(proof.proof())); body.put("offset",Long.toString(proof.offset()));
|
||||
body.put("chunk",b64(ArweaveMerkle.readChunk(data,meta)));
|
||||
postChunkWithRetry(gateway,body,i,merkle.chunks().size());
|
||||
}
|
||||
if (cfg.minConfirmations() > 0) waitForConfirmations(txId);
|
||||
log.info("ANS-104 bundle uploaded to Arweave: tx={} bytes={} items-transported",txId,data.length);
|
||||
return new UploadResult(txId,txIdBytes);
|
||||
}
|
||||
|
||||
private int waitForConfirmations(String txId) throws Exception {
|
||||
String gateway=trimGateway(cfg.publishGateway());
|
||||
long deadline=System.currentTimeMillis()+cfg.confirmTimeoutMinutes()*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 j=MAPPER.readTree(resp.body());last=j.path("number_of_confirmations").asInt(0);if(last>=cfg.minConfirmations())return last;}
|
||||
else if(resp.statusCode()!=202&&resp.statusCode()!=404)log.warn("Arweave status tx={} HTTP {}",txId,resp.statusCode());
|
||||
Thread.sleep(cfg.confirmPollSeconds()*1000L);
|
||||
}
|
||||
throw new IOException("Arweave confirmation timeout for "+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));return;}catch(Exception e){last=e;if(attempt==12)break;Thread.sleep(5000L);}
|
||||
throw new IOException("Failed Arweave chunk "+(index+1)+"/"+total,last);
|
||||
}
|
||||
private void postJsonExpect2xx(String url,Object body,Duration timeout)throws Exception{
|
||||
HttpRequest req=HttpRequest.newBuilder(URI.create(url)).timeout(timeout).header("Content-Type","application/json").header("Accept","application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.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("HTTP "+resp.statusCode()+" "+safe(resp.body()));
|
||||
}
|
||||
private void verifyBalance(String gateway,String address,String reward)throws Exception{
|
||||
String balance=getRequiredText(gateway,"/wallet/"+address+"/balance");
|
||||
if(balance.matches("^\\d+$")&&new BigInteger(balance).compareTo(new BigInteger(reward))<0)throw new IllegalStateException("Not enough 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()+" for "+path+": "+safe(resp.body()));
|
||||
String s=resp.body()==null?"":resp.body().trim();if(s.isBlank())throw new IOException("Empty Arweave response for "+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.walletJwkPath(),StandardCharsets.UTF_8));String owner=required(jwk,"n");
|
||||
PrivateKey pk=buildPrivateKey(jwk);String address=b64(sha256(b64Decode(owner)));return wallet=new Wallet(owner,address,pk);}
|
||||
}
|
||||
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 s=Signature.getInstance("RSASSA-PSS");s.setParameter(new PSSParameterSpec("SHA-256","MGF1",MGF1ParameterSpec.SHA256,32,1));s.initSign(key);s.update(payload);return s.sign();
|
||||
}
|
||||
private static byte[] deepHash(Object data)throws GeneralSecurityException{
|
||||
if(data instanceof List<?> list){byte[] acc=sha384(utf8("list"+list.size()));for(Object item:list)acc=sha384(concat(acc,deepHash(item)));return acc;}
|
||||
if(!(data instanceof byte[] bytes))throw new IllegalArgumentException("deepHash byte[]/List expected");return sha384(concat(sha384(utf8("blob"+bytes.length)),sha384(bytes)));
|
||||
}
|
||||
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>m=new LinkedHashMap<>();m.put("name",b64(n));m.put("value",b64(v));json.add(m);raw.add(List.of(n,v));}
|
||||
private static String required(JsonNode n,String f){String s=n==null?"":n.path(f).asText("").trim();if(s.isBlank())throw new IllegalStateException("Arweave JWK missing "+f);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 byte[] concat(byte[]...a){int n=0;for(byte[]x:a)n=Math.addExact(n,x.length);byte[]o=new byte[n];int p=0;for(byte[]x:a){System.arraycopy(x,0,o,p,x.length);p+=x.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,206 @@
|
||||
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(byte[] data) throws IOException {
|
||||
if (data == null || data.length == 0) throw new IOException("Arweave data is empty");
|
||||
List<Chunk> chunks = chunkBytes(data);
|
||||
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()));
|
||||
}
|
||||
Node root = buildLayers(leaves);
|
||||
List<Proof> proofs = new ArrayList<>();
|
||||
resolveProofs(root, new byte[0], proofs);
|
||||
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> chunkBytes(byte[] data) {
|
||||
List<Chunk> out = new ArrayList<>();
|
||||
long remaining = data.length;
|
||||
long cursor = 0;
|
||||
int p = 0;
|
||||
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[] chunk = Arrays.copyOfRange(data, p, p + (int) chunkSize);
|
||||
out.add(new Chunk(sha256(chunk), cursor, cursor + chunkSize));
|
||||
p += (int) chunkSize;
|
||||
cursor += chunkSize;
|
||||
remaining -= chunkSize;
|
||||
}
|
||||
byte[] rest = Arrays.copyOfRange(data, p, p + (int) remaining);
|
||||
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
|
||||
return out;
|
||||
}
|
||||
|
||||
static byte[] readChunk(byte[] data, Chunk c) throws IOException {
|
||||
long len = c.size();
|
||||
if (data == null || len < 0 || len > Integer.MAX_VALUE || c.minByteRange() < 0 || c.maxByteRange() > data.length) {
|
||||
throw new IOException("Invalid Arweave chunk range");
|
||||
}
|
||||
return Arrays.copyOfRange(data, (int) c.minByteRange(), (int) c.maxByteRange());
|
||||
}
|
||||
|
||||
static Prepared prepare(Path file) throws IOException {
|
||||
long fileSize = Files.size(file);
|
||||
List<Chunk> chunks = chunkFile(file, fileSize);
|
||||
List<Node> leaves = new ArrayList<>(chunks.size());
|
||||
for (Chunk c : chunks) {
|
||||
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
|
||||
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
|
||||
}
|
||||
if (leaves.isEmpty()) throw new IOException("Arweave Merkle: нет leaves");
|
||||
Node root = buildLayers(leaves);
|
||||
List<Proof> proofs = new ArrayList<>();
|
||||
resolveProofs(root, new byte[0], proofs);
|
||||
|
||||
// arweave-js строит root вместе с нулевым последним chunk при точном кратном 256KiB,
|
||||
// но сам нулевой chunk/proof не загружает.
|
||||
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
|
||||
chunks = new ArrayList<>(chunks);
|
||||
chunks.remove(chunks.size() - 1);
|
||||
proofs.remove(proofs.size() - 1);
|
||||
}
|
||||
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
|
||||
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
|
||||
}
|
||||
|
||||
private static List<Chunk> chunkFile(Path file, long fileSize) throws IOException {
|
||||
List<Chunk> out = new ArrayList<>();
|
||||
long remaining = fileSize;
|
||||
long cursor = 0;
|
||||
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
|
||||
while (remaining >= MAX_CHUNK_SIZE) {
|
||||
long chunkSize = MAX_CHUNK_SIZE;
|
||||
long next = remaining - MAX_CHUNK_SIZE;
|
||||
if (next > 0 && next < MIN_CHUNK_SIZE) {
|
||||
chunkSize = (remaining + 1) / 2;
|
||||
}
|
||||
byte[] data = readExact(in, (int)chunkSize);
|
||||
out.add(new Chunk(sha256(data), cursor, cursor + chunkSize));
|
||||
cursor += chunkSize;
|
||||
remaining -= chunkSize;
|
||||
}
|
||||
byte[] rest = readExact(in, (int)remaining);
|
||||
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Node buildLayers(List<Node> nodes) {
|
||||
if (nodes.size() < 2) return nodes.get(0);
|
||||
List<Node> next = new ArrayList<>((nodes.size() + 1) / 2);
|
||||
for (int i = 0; i < nodes.size(); i += 2) {
|
||||
Node left = nodes.get(i);
|
||||
Node right = i + 1 < nodes.size() ? nodes.get(i + 1) : null;
|
||||
if (right == null) {
|
||||
next.add(left);
|
||||
continue;
|
||||
}
|
||||
long byteRange = left.maxByteRange();
|
||||
byte[] id = sha256(concat(
|
||||
sha256(left.id()),
|
||||
sha256(right.id()),
|
||||
sha256(note(byteRange))
|
||||
));
|
||||
next.add(new Branch(id, byteRange, right.maxByteRange(), left, right));
|
||||
}
|
||||
return buildLayers(next);
|
||||
}
|
||||
|
||||
private static void resolveProofs(Node node, byte[] prefix, List<Proof> out) {
|
||||
if (node instanceof Leaf leaf) {
|
||||
out.add(new Proof(leaf.maxByteRange() - 1,
|
||||
concat(prefix, leaf.dataHash(), note(leaf.maxByteRange()))));
|
||||
return;
|
||||
}
|
||||
Branch b = (Branch) node;
|
||||
byte[] partial = concat(prefix, b.left().id(), b.right().id(), note(b.byteRange()));
|
||||
resolveProofs(b.left(), partial, out);
|
||||
resolveProofs(b.right(), partial, out);
|
||||
}
|
||||
|
||||
static byte[] readChunk(Path file, Chunk c) throws IOException {
|
||||
long len = c.size();
|
||||
if (len < 0 || len > Integer.MAX_VALUE) throw new IOException("Некорректный Arweave chunk size: " + len);
|
||||
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
|
||||
raf.seek(c.minByteRange());
|
||||
byte[] data = new byte[(int)len];
|
||||
raf.readFully(data);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readExact(InputStream in, int len) throws IOException {
|
||||
byte[] out = new byte[len];
|
||||
int pos = 0;
|
||||
while (pos < len) {
|
||||
int n = in.read(out, pos, len - pos);
|
||||
if (n < 0) throw new EOFException("Неожиданный EOF");
|
||||
pos += n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] note(long value) {
|
||||
if (value < 0) throw new IllegalArgumentException("negative note");
|
||||
byte[] out = new byte[32];
|
||||
long v = value;
|
||||
for (int i = 31; i >= 0 && v != 0; i--) {
|
||||
out[i] = (byte)(v & 0xff);
|
||||
v >>>= 8;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] sha256(byte[] data) {
|
||||
try { return MessageDigest.getInstance("SHA-256").digest(data); }
|
||||
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
|
||||
private static byte[] concat(byte[]... arrays) {
|
||||
int len = 0;
|
||||
for (byte[] a : arrays) len = Math.addExact(len, a.length);
|
||||
byte[] out = new byte[len];
|
||||
int p=0;
|
||||
for (byte[] a : arrays) { System.arraycopy(a,0,out,p,a.length); p+=a.length; }
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.Ans104DataItem;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class Ans104BundleTest {
|
||||
@Test
|
||||
void writeReadAndFindPreserveExactDataItems() {
|
||||
byte[] a = item("a");
|
||||
byte[] b = item("b");
|
||||
byte[] bundle = Ans104Bundle.write(List.of(a, b));
|
||||
|
||||
List<Ans104Bundle.Entry> entries = Ans104Bundle.read(bundle, 10);
|
||||
assertEquals(2, entries.size());
|
||||
assertArrayEquals(a, entries.get(0).rawDataItem());
|
||||
assertArrayEquals(b, entries.get(1).rawDataItem());
|
||||
|
||||
byte[] bId = new Ans104DataItem(b).id32();
|
||||
assertArrayEquals(b, Ans104Bundle.find(bundle, bId, 10));
|
||||
}
|
||||
|
||||
private static byte[] item(String payload) {
|
||||
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("bundle-" + payload);
|
||||
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
|
||||
byte[] data = payload.getBytes(StandardCharsets.UTF_8);
|
||||
List<Ans104DataItem.Tag> tags = List.of(new Ans104DataItem.Tag("App", "test5590"));
|
||||
byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey);
|
||||
return Ans104DataItem.serializeEd25519(signature, owner, tags, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package blockchain;
|
||||
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Minimal ANS-104 DataItem parser/verifier used by SHiNE.
|
||||
*
|
||||
* SHiNE accepts only generic Ed25519 signature type 2:
|
||||
* uint16le signatureType
|
||||
* signature[64]
|
||||
* owner[32]
|
||||
* target_present[1] + target[32] when present
|
||||
* anchor_present[1] + anchor[32] when present
|
||||
* tags_count uint64le
|
||||
* tags_size uint64le
|
||||
* avro_tags[tags_size]
|
||||
* data[*]
|
||||
*
|
||||
* The signing message matches the deployed arbundles/Turbo DataItem implementation:
|
||||
* deepHash(["dataitem", "1", signatureType, owner, target, anchor, rawAvroTags, data]).
|
||||
*/
|
||||
public final class Ans104DataItem {
|
||||
public static final int SIGNATURE_TYPE_ED25519 = 2;
|
||||
public static final int SIGNATURE_LEN = 64;
|
||||
public static final int OWNER_LEN = 32;
|
||||
public static final int OPTIONAL_FIELD_LEN = 32;
|
||||
public static final int ID_LEN = 32;
|
||||
public static final int MAX_DATA_ITEM_BYTES = 8 * 1024 * 1024;
|
||||
public static final int MAX_TAGS_BYTES = 64 * 1024;
|
||||
public static final long MAX_TAGS_COUNT = 128;
|
||||
|
||||
public record Tag(String name, String value) {}
|
||||
|
||||
private final int signatureType;
|
||||
private final byte[] signature;
|
||||
private final byte[] owner;
|
||||
private final byte[] target;
|
||||
private final byte[] anchor;
|
||||
private final long tagsCount;
|
||||
private final byte[] rawTags;
|
||||
private final List<Tag> tags;
|
||||
private final byte[] data;
|
||||
private final byte[] rawBytes;
|
||||
private final byte[] id;
|
||||
|
||||
public Ans104DataItem(byte[] bytes) {
|
||||
Objects.requireNonNull(bytes, "bytes == null");
|
||||
if (bytes.length < 2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + 1) {
|
||||
throw new IllegalArgumentException("ANS-104 DataItem too short: " + bytes.length);
|
||||
}
|
||||
if (bytes.length > MAX_DATA_ITEM_BYTES) {
|
||||
throw new IllegalArgumentException("ANS-104 DataItem too large: " + bytes.length);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||
this.signatureType = Short.toUnsignedInt(bb.getShort());
|
||||
if (signatureType != SIGNATURE_TYPE_ED25519) {
|
||||
throw new IllegalArgumentException("Unsupported ANS-104 signature type: " + signatureType + " (expected 2)");
|
||||
}
|
||||
|
||||
this.signature = readExact(bb, SIGNATURE_LEN, "signature");
|
||||
this.owner = readExact(bb, OWNER_LEN, "owner");
|
||||
this.target = readOptional32(bb, "target");
|
||||
this.anchor = readOptional32(bb, "anchor");
|
||||
|
||||
this.tagsCount = readUnsignedLongAsNonNegative(bb, "tags_count");
|
||||
long tagsSizeLong = readUnsignedLongAsNonNegative(bb, "tags_size");
|
||||
if (tagsCount > MAX_TAGS_COUNT) {
|
||||
throw new IllegalArgumentException("Too many ANS-104 tags: " + tagsCount);
|
||||
}
|
||||
if (tagsSizeLong > MAX_TAGS_BYTES || tagsSizeLong > bb.remaining()) {
|
||||
throw new IllegalArgumentException("Bad ANS-104 tags_size: " + tagsSizeLong + ", remaining=" + bb.remaining());
|
||||
}
|
||||
this.rawTags = readExact(bb, (int) tagsSizeLong, "tags");
|
||||
this.tags = Collections.unmodifiableList(parseAvroTags(rawTags, tagsCount));
|
||||
this.data = new byte[bb.remaining()];
|
||||
bb.get(this.data);
|
||||
this.rawBytes = Arrays.copyOf(bytes, bytes.length);
|
||||
this.id = sha256(signature);
|
||||
}
|
||||
|
||||
public int signatureType() { return signatureType; }
|
||||
public byte[] signature64() { return Arrays.copyOf(signature, signature.length); }
|
||||
public byte[] owner32() { return Arrays.copyOf(owner, owner.length); }
|
||||
public byte[] target() { return Arrays.copyOf(target, target.length); }
|
||||
public byte[] anchor() { return Arrays.copyOf(anchor, anchor.length); }
|
||||
public long tagsCount() { return tagsCount; }
|
||||
public byte[] rawTags() { return Arrays.copyOf(rawTags, rawTags.length); }
|
||||
public List<Tag> tags() { return tags; }
|
||||
public byte[] data() { return Arrays.copyOf(data, data.length); }
|
||||
public byte[] rawBytes() { return Arrays.copyOf(rawBytes, rawBytes.length); }
|
||||
public byte[] id32() { return Arrays.copyOf(id, id.length); }
|
||||
|
||||
public String tagValue(String name) {
|
||||
if (name == null) return null;
|
||||
for (Tag tag : tags) {
|
||||
if (name.equals(tag.name())) return tag.value();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean hasTag(String name, String value) {
|
||||
for (Tag tag : tags) {
|
||||
if (Objects.equals(name, tag.name()) && Objects.equals(value, tag.value())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean verifySignature() {
|
||||
return Ed25519Util.verify(signingMessage(), signature, owner);
|
||||
}
|
||||
|
||||
public boolean ownerEquals(byte[] expectedOwner32) {
|
||||
return expectedOwner32 != null && expectedOwner32.length == OWNER_LEN && Arrays.equals(owner, expectedOwner32);
|
||||
}
|
||||
|
||||
public byte[] signingMessage() {
|
||||
return buildSigningMessage(owner, target, anchor, tags, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the exact ANS-104 signing message for generic Ed25519 DataItems.
|
||||
* Useful for SHiNE clients/tests that must sign a DataItem before serializing it.
|
||||
*/
|
||||
public static byte[] buildSigningMessage(byte[] owner32, List<Tag> tags, byte[] data) {
|
||||
return buildSigningMessage(owner32, new byte[0], new byte[0], tags, data);
|
||||
}
|
||||
|
||||
public static byte[] buildSigningMessage(byte[] owner32, byte[] target, byte[] anchor, List<Tag> tags, byte[] data) {
|
||||
requireLen(owner32, OWNER_LEN, "owner");
|
||||
byte[] safeTarget = target == null ? new byte[0] : Arrays.copyOf(target, target.length);
|
||||
byte[] safeAnchor = anchor == null ? new byte[0] : Arrays.copyOf(anchor, anchor.length);
|
||||
if (!(safeTarget.length == 0 || safeTarget.length == OPTIONAL_FIELD_LEN)) {
|
||||
throw new IllegalArgumentException("target must be empty or 32 bytes");
|
||||
}
|
||||
if (!(safeAnchor.length == 0 || safeAnchor.length == OPTIONAL_FIELD_LEN)) {
|
||||
throw new IllegalArgumentException("anchor must be empty or 32 bytes");
|
||||
}
|
||||
List<Tag> safeTags = validateTags(tags);
|
||||
byte[] safeData = data == null ? new byte[0] : Arrays.copyOf(data, data.length);
|
||||
|
||||
byte[] rawTags = serializeAvroTags(safeTags);
|
||||
return deepHash(List.of(
|
||||
utf8("dataitem"),
|
||||
utf8("1"),
|
||||
utf8(Integer.toString(SIGNATURE_TYPE_ED25519)),
|
||||
Arrays.copyOf(owner32, owner32.length),
|
||||
safeTarget,
|
||||
safeAnchor,
|
||||
rawTags,
|
||||
safeData
|
||||
));
|
||||
}
|
||||
|
||||
/** Serialize one SHiNE-ready ANS-104 DataItem with empty target/anchor. */
|
||||
public static byte[] serializeEd25519(byte[] signature64, byte[] owner32, List<Tag> tags, byte[] data) {
|
||||
requireLen(signature64, SIGNATURE_LEN, "signature");
|
||||
requireLen(owner32, OWNER_LEN, "owner");
|
||||
List<Tag> safeTags = validateTags(tags);
|
||||
byte[] rawTags = serializeAvroTags(safeTags);
|
||||
byte[] safeData = data == null ? new byte[0] : data;
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(
|
||||
2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + rawTags.length + safeData.length);
|
||||
writeU16LE(out, SIGNATURE_TYPE_ED25519);
|
||||
out.writeBytes(signature64);
|
||||
out.writeBytes(owner32);
|
||||
out.write(0); // target absent
|
||||
out.write(0); // anchor absent
|
||||
writeU64LE(out, safeTags.size());
|
||||
writeU64LE(out, rawTags.length);
|
||||
out.writeBytes(rawTags);
|
||||
out.writeBytes(safeData);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] readOptional32(ByteBuffer bb, String name) {
|
||||
if (!bb.hasRemaining()) throw new IllegalArgumentException("Missing " + name + " presence byte");
|
||||
int present = Byte.toUnsignedInt(bb.get());
|
||||
if (present == 0) return new byte[0];
|
||||
if (present != 1) throw new IllegalArgumentException("Bad " + name + " presence byte: " + present);
|
||||
return readExact(bb, OPTIONAL_FIELD_LEN, name);
|
||||
}
|
||||
|
||||
private static byte[] readExact(ByteBuffer bb, int len, String field) {
|
||||
if (len < 0 || bb.remaining() < len) {
|
||||
throw new IllegalArgumentException("ANS-104 " + field + " truncated: need=" + len + " remaining=" + bb.remaining());
|
||||
}
|
||||
byte[] out = new byte[len];
|
||||
bb.get(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static long readUnsignedLongAsNonNegative(ByteBuffer bb, String field) {
|
||||
if (bb.remaining() < 8) throw new IllegalArgumentException("Missing ANS-104 " + field);
|
||||
long v = bb.getLong();
|
||||
if (v < 0) throw new IllegalArgumentException("ANS-104 " + field + " exceeds signed long range");
|
||||
return v;
|
||||
}
|
||||
|
||||
private static List<Tag> validateTags(List<Tag> tags) {
|
||||
List<Tag> safe = tags == null ? List.of() : List.copyOf(tags);
|
||||
if (safe.size() > MAX_TAGS_COUNT) {
|
||||
throw new IllegalArgumentException("Too many ANS-104 tags: " + safe.size());
|
||||
}
|
||||
for (Tag tag : safe) {
|
||||
if (tag == null || tag.name() == null || tag.value() == null || tag.name().isEmpty() || tag.value().isEmpty()) {
|
||||
throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
|
||||
}
|
||||
if (utf8(tag.name()).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
|
||||
if (utf8(tag.value()).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
private static byte[] serializeAvroTags(List<Tag> tags) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
if (!tags.isEmpty()) {
|
||||
writeAvroLong(out, tags.size());
|
||||
for (Tag tag : tags) {
|
||||
writeAvroString(out, tag.name());
|
||||
writeAvroString(out, tag.value());
|
||||
}
|
||||
}
|
||||
writeAvroLong(out, 0); // array terminator; also the complete encoding for an empty array
|
||||
byte[] raw = out.toByteArray();
|
||||
if (raw.length > MAX_TAGS_BYTES) throw new IllegalArgumentException("ANS-104 tags exceed " + MAX_TAGS_BYTES + " bytes");
|
||||
return raw;
|
||||
}
|
||||
|
||||
private static void writeAvroString(ByteArrayOutputStream out, String value) {
|
||||
byte[] bytes = utf8(value);
|
||||
writeAvroLong(out, bytes.length);
|
||||
out.writeBytes(bytes);
|
||||
}
|
||||
|
||||
private static void writeAvroLong(ByteArrayOutputStream out, long value) {
|
||||
long n = (value << 1) ^ (value >> 63);
|
||||
while ((n & ~0x7fL) != 0) {
|
||||
out.write((int) ((n & 0x7f) | 0x80));
|
||||
n >>>= 7;
|
||||
}
|
||||
out.write((int) n);
|
||||
}
|
||||
|
||||
private static void writeU16LE(ByteArrayOutputStream out, int value) {
|
||||
out.write(value & 0xff);
|
||||
out.write((value >>> 8) & 0xff);
|
||||
}
|
||||
|
||||
private static void writeU64LE(ByteArrayOutputStream out, long value) {
|
||||
for (int i = 0; i < 8; i++) out.write((int) ((value >>> (8 * i)) & 0xff));
|
||||
}
|
||||
|
||||
private static void requireLen(byte[] bytes, int expected, String name) {
|
||||
if (bytes == null || bytes.length != expected) {
|
||||
throw new IllegalArgumentException(name + " must be " + expected + " bytes");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Tag> parseAvroTags(byte[] raw, long declaredCount) {
|
||||
AvroCursor c = new AvroCursor(raw);
|
||||
List<Tag> out = new ArrayList<>();
|
||||
while (!c.eof()) {
|
||||
long count = c.readLong();
|
||||
if (count == 0) break;
|
||||
if (count < 0) {
|
||||
count = -count;
|
||||
long blockBytes = c.readLong();
|
||||
if (blockBytes < 0) throw new IllegalArgumentException("Negative Avro tag block size");
|
||||
}
|
||||
if (count > MAX_TAGS_COUNT || out.size() + count > MAX_TAGS_COUNT) {
|
||||
throw new IllegalArgumentException("Too many Avro tags");
|
||||
}
|
||||
for (long i = 0; i < count; i++) {
|
||||
String name = c.readString();
|
||||
String value = c.readString();
|
||||
if (name.isEmpty() || value.isEmpty()) throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
|
||||
if (name.getBytes(StandardCharsets.UTF_8).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
|
||||
if (value.getBytes(StandardCharsets.UTF_8).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
|
||||
out.add(new Tag(name, value));
|
||||
}
|
||||
}
|
||||
if (!c.eof()) {
|
||||
throw new IllegalArgumentException("Trailing bytes in ANS-104 Avro tags");
|
||||
}
|
||||
if (out.size() != declaredCount) {
|
||||
throw new IllegalArgumentException("ANS-104 tags_count mismatch: declared=" + declaredCount + " parsed=" + out.size());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final class AvroCursor {
|
||||
private final byte[] data;
|
||||
private int p;
|
||||
private AvroCursor(byte[] data) { this.data = data == null ? new byte[0] : data; }
|
||||
boolean eof() { return p == data.length; }
|
||||
long readLong() {
|
||||
long raw = 0;
|
||||
int shift = 0;
|
||||
while (true) {
|
||||
if (p >= data.length) throw new IllegalArgumentException("Truncated Avro long");
|
||||
int b = data[p++] & 0xff;
|
||||
raw |= (long) (b & 0x7f) << shift;
|
||||
if ((b & 0x80) == 0) break;
|
||||
shift += 7;
|
||||
if (shift > 63) throw new IllegalArgumentException("Avro long too large");
|
||||
}
|
||||
return (raw >>> 1) ^ -(raw & 1L);
|
||||
}
|
||||
String readString() {
|
||||
long len = readLong();
|
||||
if (len < 0 || len > Integer.MAX_VALUE || p + len > data.length) {
|
||||
throw new IllegalArgumentException("Bad Avro string length: " + len);
|
||||
}
|
||||
String s = new String(data, p, (int) len, StandardCharsets.UTF_8);
|
||||
p += (int) len;
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] deepHash(Object item) {
|
||||
if (item instanceof byte[] bytes) {
|
||||
return deepHashBlob(bytes);
|
||||
}
|
||||
if (item instanceof List<?> items) {
|
||||
byte[] acc = sha384(utf8("list" + items.size()));
|
||||
for (Object child : items) {
|
||||
acc = sha384(concat(acc, deepHash(child)));
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported deepHash item: " + (item == null ? "null" : item.getClass()));
|
||||
}
|
||||
|
||||
private static byte[] deepHashBlob(byte[] bytes) {
|
||||
byte[] tag = utf8("blob" + bytes.length);
|
||||
return sha384(concat(sha384(tag), sha384(bytes)));
|
||||
}
|
||||
|
||||
private static byte[] utf8(String s) { return s.getBytes(StandardCharsets.UTF_8); }
|
||||
private static byte[] sha256(byte[] b) { return digest("SHA-256", b); }
|
||||
private static byte[] sha384(byte[] b) { return digest("SHA-384", b); }
|
||||
private static byte[] digest(String alg, byte[] b) {
|
||||
try { return MessageDigest.getInstance(alg).digest(b); }
|
||||
catch (Exception e) { throw new IllegalStateException(alg + " unavailable", e); }
|
||||
}
|
||||
private static byte[] concat(byte[] a, byte[] b) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(a.length + b.length);
|
||||
out.writeBytes(a); out.writeBytes(b); return out.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -9,363 +9,115 @@ import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* BchBlockEntry — универсальный блок формата SHiNE (Frame v0).
|
||||
* SHiNE user block, Frame v1, carried as the data payload of an ANS-104 DataItem.
|
||||
*
|
||||
* =========================================================================
|
||||
* FRAME v0 — ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА)
|
||||
* =========================================================================
|
||||
* The constructor accepts the complete serialized ANS-104 DataItem because this is
|
||||
* exactly what is stored in PostgreSQL and exchanged between servers.
|
||||
*
|
||||
* Все числа BigEndian.
|
||||
* SHiNE Frame v1 (all integer fields BigEndian):
|
||||
* [2] frameCode = 0x0001
|
||||
* [32] prevHash32 = SHA-256(Frame v1 bytes of previous SHiNE block)
|
||||
* [4] blockSize = exact Frame v1 byte length
|
||||
* [4] blockNumber
|
||||
* [8] timestamp unix seconds
|
||||
* [2] type
|
||||
* [2] subType
|
||||
* [2] version
|
||||
* [N] bodyBytes
|
||||
*
|
||||
* PREIMAGE (входит в blockSize, подписывается):
|
||||
* [2] frameCode (uint16) код/версия рамки:
|
||||
* - 0x0000 = Frame v0 (текущий)
|
||||
* [32] prevHash32 (bytes) SHA-256(preimage) предыдущего блока (цепочка)
|
||||
* [4] blockSize (int32) размер preimage (в байтах), ВКЛЮЧАЯ frameCode,
|
||||
* НО БЕЗ sigMarker и БЕЗ signature64
|
||||
* [4] blockNumber (int32) глобальный номер блока (>=0)
|
||||
* [8] timestamp (int64) unix seconds
|
||||
* [2] type (uint16) тип сообщения
|
||||
* [2] subType (uint16) подтип сообщения
|
||||
* [2] version (uint16) версия формата сообщения
|
||||
* [N] bodyBytes (bytes) тело сообщения (БЕЗ type/subType/version)
|
||||
*
|
||||
* TAIL (НЕ входит в blockSize, НЕ подписывается в Frame v0):
|
||||
* [2] sigMarker (uint16) маркер подписи:
|
||||
* - 0x0100 (256) = далее подпись Ed25519 64 байта
|
||||
* [64] signature64 (bytes) Ed25519 signature над hash32
|
||||
*
|
||||
* hash32 НЕ хранится в блоке.
|
||||
* hash32 вычисляется при парсинге:
|
||||
* preimage = первые blockSize байт
|
||||
* hash32 = SHA-256(preimage)
|
||||
*
|
||||
* Правила MVP-парсера (Frame v0):
|
||||
* - frameCode должен быть строго 0x0000, иначе REJECT.
|
||||
* - sigMarker должен быть строго 0x0100, иначе REJECT.
|
||||
* - подпись обязана присутствовать всегда (sigMarker+signature64).
|
||||
* - НИКАКИХ fallback-веток “если маркер другой, то подписи нет/другой хвост”.
|
||||
*
|
||||
* Важно по безопасности:
|
||||
* - sigMarker в v0 не входит в подписываемые байты → его можно подменить,
|
||||
* поэтому единственная безопасная логика: "если не 0x0100 — reject".
|
||||
* =========================================================================
|
||||
* The user signature is NOT stored in this frame. It is the single Ed25519/Solana
|
||||
* signature of the surrounding ANS-104 DataItem.
|
||||
*/
|
||||
public final class BchBlockEntry {
|
||||
|
||||
public static final int SIGNATURE_LEN = 64;
|
||||
public static final int HASH_LEN = 32;
|
||||
|
||||
public static final int FRAME_CODE_LEN = 2;
|
||||
public static final int SIG_MARKER_LEN = 2;
|
||||
|
||||
/** Frame v0 */
|
||||
public static final int FRAME_CODE_V0 = 0x0000;
|
||||
|
||||
/** sigMarker: 256 = 0x0100 */
|
||||
public static final int SIG_MARKER_ED25519 = 0x0100;
|
||||
|
||||
/**
|
||||
* Максимальный допустимый размер блока (fullBytes = preimage + sigMarker + signature),
|
||||
* чтобы не уложить сервер по памяти/диску.
|
||||
*/
|
||||
public static final int MAX_BLOCK_FULL_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Насколько блок может “обгонять” текущее время (защита от кривых часов/вбросов).
|
||||
* Если timestamp больше now + 60 сек — блок считаем неверным.
|
||||
*/
|
||||
public static final int FRAME_CODE_V1 = 0x0001;
|
||||
public static final int MAX_BLOCK_FULL_BYTES = Ans104DataItem.MAX_DATA_ITEM_BYTES;
|
||||
public static final long MAX_FUTURE_SECONDS = 60;
|
||||
public static final int FRAME_HEADER_SIZE = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2;
|
||||
|
||||
/**
|
||||
* Размер фиксированной части PREIMAGE (без bodyBytes).
|
||||
*
|
||||
* PREIMAGE header:
|
||||
* frameCode(2) + prevHash32(32) + blockSize(4) + blockNumber(4) + timestamp(8)
|
||||
* + type(2) + subType(2) + version(2)
|
||||
*/
|
||||
public static final int PREIMAGE_HEADER_SIZE =
|
||||
2 // frameCode
|
||||
+ 32 // prevHash32
|
||||
+ 4 // blockSize
|
||||
+ 4 // blockNumber
|
||||
+ 8 // timestamp
|
||||
+ 2 // type
|
||||
+ 2 // subType
|
||||
+ 2; // version
|
||||
|
||||
/** Минимальный полный размер блока (без bodyBytes). */
|
||||
public static final int MIN_FULL_BYTES =
|
||||
PREIMAGE_HEADER_SIZE + SIG_MARKER_LEN + SIGNATURE_LEN;
|
||||
|
||||
// --- HEADER (PREIMAGE) ---
|
||||
public final int frameCode; // uint16 (v0=0)
|
||||
public final byte[] prevHash32; // 32
|
||||
public final int blockSize; // preimage size (включая frameCode)
|
||||
public final int blockNumber; // >=0
|
||||
public final int frameCode;
|
||||
public final byte[] prevHash32;
|
||||
public final int blockSize;
|
||||
public final int blockNumber;
|
||||
public final long timestamp;
|
||||
public final short type;
|
||||
public final short subType;
|
||||
public final short version;
|
||||
|
||||
// --- BODY (PREIMAGE) ---
|
||||
public final byte[] bodyBytes;
|
||||
|
||||
/** Распарсенное тело (создаётся сразу при парсинге блока). */
|
||||
public final BodyRecord body;
|
||||
|
||||
// --- TAIL ---
|
||||
public final int sigMarker; // uint16 (v0: 0x0100)
|
||||
private final byte[] signature64; // 64
|
||||
private final Ans104DataItem dataItem;
|
||||
private final byte[] frameBytes;
|
||||
private final byte[] hash32;
|
||||
|
||||
// --- derived ---
|
||||
private final byte[] hash32; // 32, computed
|
||||
private final byte[] preimage; // blockSize bytes
|
||||
private final byte[] fullBytes; // preimage + sigMarker + signature
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Конструктор из байт ========================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
public BchBlockEntry(byte[] fullBytes) {
|
||||
Objects.requireNonNull(fullBytes, "fullBytes == null");
|
||||
|
||||
if (fullBytes.length < MIN_FULL_BYTES) {
|
||||
throw new IllegalArgumentException("Block too short: " + fullBytes.length + " < " + MIN_FULL_BYTES);
|
||||
}
|
||||
if (fullBytes.length > MAX_BLOCK_FULL_BYTES) {
|
||||
throw new IllegalArgumentException("Block too large: " + fullBytes.length + " > " + MAX_BLOCK_FULL_BYTES);
|
||||
/** Parse a complete serialized ANS-104 DataItem containing one SHiNE Frame v1. */
|
||||
public BchBlockEntry(byte[] fullDataItemBytes) {
|
||||
Objects.requireNonNull(fullDataItemBytes, "fullDataItemBytes == null");
|
||||
this.dataItem = new Ans104DataItem(fullDataItemBytes);
|
||||
this.frameBytes = dataItem.data();
|
||||
if (frameBytes.length < FRAME_HEADER_SIZE) {
|
||||
throw new IllegalArgumentException("SHiNE Frame v1 too short: " + frameBytes.length);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
// [2] frameCode
|
||||
ByteBuffer bb = ByteBuffer.wrap(frameBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
this.frameCode = Short.toUnsignedInt(bb.getShort());
|
||||
if (this.frameCode != FRAME_CODE_V0) {
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Bad frameCode: 0x%04X (expected 0x%04X)", this.frameCode, FRAME_CODE_V0
|
||||
));
|
||||
if (frameCode != FRAME_CODE_V1) {
|
||||
throw new IllegalArgumentException(String.format("Bad SHiNE frameCode: 0x%04X (expected 0x%04X)", frameCode, FRAME_CODE_V1));
|
||||
}
|
||||
|
||||
// [32] prevHash32
|
||||
this.prevHash32 = new byte[32];
|
||||
bb.get(this.prevHash32);
|
||||
|
||||
// [4] blockSize
|
||||
bb.get(prevHash32);
|
||||
this.blockSize = bb.getInt();
|
||||
if (blockSize < PREIMAGE_HEADER_SIZE) {
|
||||
throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE);
|
||||
if (blockSize != frameBytes.length || blockSize < FRAME_HEADER_SIZE) {
|
||||
throw new IllegalArgumentException("SHiNE blockSize mismatch: declared=" + blockSize + " actual=" + frameBytes.length);
|
||||
}
|
||||
|
||||
// fullLen must match exactly: blockSize + sigMarker(2) + signature(64)
|
||||
int expectedFullLen = blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
|
||||
if (expectedFullLen != fullBytes.length) {
|
||||
throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize
|
||||
+ " expectedFullLen=" + expectedFullLen
|
||||
+ " fullLen=" + fullBytes.length);
|
||||
}
|
||||
if (expectedFullLen > MAX_BLOCK_FULL_BYTES) {
|
||||
throw new IllegalArgumentException("Block too large by blockSize: " + expectedFullLen + " > " + MAX_BLOCK_FULL_BYTES);
|
||||
}
|
||||
|
||||
// [4] blockNumber
|
||||
this.blockNumber = bb.getInt();
|
||||
if (this.blockNumber < 0) {
|
||||
throw new IllegalArgumentException("blockNumber < 0: " + this.blockNumber);
|
||||
}
|
||||
|
||||
// [8] timestamp
|
||||
if (blockNumber < 0) throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
|
||||
this.timestamp = bb.getLong();
|
||||
|
||||
// запрет “в будущее” больше чем на 1 минуту
|
||||
long now = Instant.now().getEpochSecond();
|
||||
if (this.timestamp > now + MAX_FUTURE_SECONDS) {
|
||||
throw new IllegalArgumentException("timestamp is too far in future: ts=" + this.timestamp
|
||||
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
|
||||
if (timestamp > now + MAX_FUTURE_SECONDS) {
|
||||
throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp + " now=" + now);
|
||||
}
|
||||
|
||||
// [2][2][2] type/subType/version
|
||||
this.type = bb.getShort();
|
||||
this.subType = bb.getShort();
|
||||
this.version = bb.getShort();
|
||||
|
||||
// [N] bodyBytes
|
||||
int bodyLen = blockSize - PREIMAGE_HEADER_SIZE;
|
||||
if (bodyLen < 0) {
|
||||
throw new IllegalArgumentException("Invalid body length: " + bodyLen);
|
||||
}
|
||||
this.bodyBytes = new byte[bodyLen];
|
||||
bb.get(this.bodyBytes);
|
||||
|
||||
// TAIL: [2] sigMarker
|
||||
this.sigMarker = Short.toUnsignedInt(bb.getShort());
|
||||
if (this.sigMarker != SIG_MARKER_ED25519) {
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Bad sigMarker: 0x%04X (expected 0x%04X)", this.sigMarker, SIG_MARKER_ED25519
|
||||
));
|
||||
this.bodyBytes = new byte[bb.remaining()];
|
||||
bb.get(bodyBytes);
|
||||
this.body = BodyRecordParser.parse(type, subType, version, bodyBytes);
|
||||
this.hash32 = BchCryptoVerifier.sha256(frameBytes);
|
||||
}
|
||||
|
||||
// TAIL: [64] signature64
|
||||
this.signature64 = new byte[SIGNATURE_LEN];
|
||||
bb.get(this.signature64);
|
||||
|
||||
// preimage = первые blockSize байт (включая frameCode)
|
||||
this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize);
|
||||
|
||||
// hash32 = sha256(preimage)
|
||||
this.hash32 = BchCryptoVerifier.sha256(preimage);
|
||||
|
||||
// parse body по header.type/subType/version + ОБЯЗАТЕЛЬНЫЙ check()
|
||||
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
|
||||
|
||||
this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length);
|
||||
|
||||
if (bb.remaining() != 0) {
|
||||
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Конструктор сборки ============================ */
|
||||
/* ===================================================================== */
|
||||
|
||||
public BchBlockEntry(byte[] prevHash32,
|
||||
int blockNumber,
|
||||
long timestamp,
|
||||
short type,
|
||||
short subType,
|
||||
short version,
|
||||
byte[] bodyBytes,
|
||||
byte[] signature64) {
|
||||
|
||||
Objects.requireNonNull(prevHash32, "prevHash32 == null");
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
Objects.requireNonNull(signature64, "signature64 == null");
|
||||
|
||||
if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32");
|
||||
if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64");
|
||||
|
||||
if (blockNumber < 0) {
|
||||
throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
|
||||
}
|
||||
|
||||
// запрет “в будущее” больше чем на 1 минуту
|
||||
long now = Instant.now().getEpochSecond();
|
||||
if (timestamp > now + MAX_FUTURE_SECONDS) {
|
||||
throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp
|
||||
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
|
||||
}
|
||||
|
||||
this.frameCode = FRAME_CODE_V0;
|
||||
this.prevHash32 = Arrays.copyOf(prevHash32, 32);
|
||||
this.blockNumber = blockNumber;
|
||||
this.timestamp = timestamp;
|
||||
this.type = type;
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length);
|
||||
|
||||
// blockSize = размер preimage (включая frameCode)
|
||||
this.blockSize = PREIMAGE_HEADER_SIZE + this.bodyBytes.length;
|
||||
|
||||
int fullLen = this.blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
|
||||
if (fullLen > MAX_BLOCK_FULL_BYTES) {
|
||||
throw new IllegalArgumentException("Block too large: " + fullLen + " > " + MAX_BLOCK_FULL_BYTES);
|
||||
}
|
||||
|
||||
// parse body по header + ОБЯЗАТЕЛЬНЫЙ check()
|
||||
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
|
||||
|
||||
// tail marker фиксирован
|
||||
this.sigMarker = SIG_MARKER_ED25519;
|
||||
this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN);
|
||||
|
||||
// build preimage
|
||||
ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN);
|
||||
pre.putShort((short) (FRAME_CODE_V0 & 0xFFFF));
|
||||
pre.put(this.prevHash32);
|
||||
pre.putInt(this.blockSize);
|
||||
pre.putInt(this.blockNumber);
|
||||
pre.putLong(this.timestamp);
|
||||
pre.putShort(this.type);
|
||||
pre.putShort(this.subType);
|
||||
pre.putShort(this.version);
|
||||
pre.put(this.bodyBytes);
|
||||
|
||||
this.preimage = pre.array();
|
||||
this.hash32 = BchCryptoVerifier.sha256(preimage);
|
||||
|
||||
// build fullBytes: preimage + sigMarker + signature64
|
||||
ByteBuffer full = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN);
|
||||
full.put(this.preimage);
|
||||
full.putShort((short) (SIG_MARKER_ED25519 & 0xFFFF));
|
||||
full.put(this.signature64);
|
||||
this.fullBytes = full.array();
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ============================ Getters ================================= */
|
||||
/* ===================================================================== */
|
||||
|
||||
public byte[] getPreimageBytes() {
|
||||
return Arrays.copyOf(preimage, preimage.length);
|
||||
}
|
||||
|
||||
/** Возвращает подпись Ed25519 (64 байта). */
|
||||
public byte[] getSignature64() {
|
||||
return Arrays.copyOf(signature64, SIGNATURE_LEN);
|
||||
}
|
||||
|
||||
/** Возвращает hash32 = SHA-256(preimage). */
|
||||
public byte[] getHash32() {
|
||||
return Arrays.copyOf(hash32, HASH_LEN);
|
||||
}
|
||||
|
||||
/** Возвращает полный блок: preimage + sigMarker + signature. */
|
||||
public byte[] toBytes() {
|
||||
return Arrays.copyOf(fullBytes, fullBytes.length);
|
||||
}
|
||||
public Ans104DataItem getDataItem() { return dataItem; }
|
||||
public byte[] getFrameBytes() { return Arrays.copyOf(frameBytes, frameBytes.length); }
|
||||
/** Kept as an alias for code/docs that called the old field preimage. */
|
||||
public byte[] getPreimageBytes() { return getFrameBytes(); }
|
||||
public byte[] getSignature64() { return dataItem.signature64(); }
|
||||
public byte[] getOwner32() { return dataItem.owner32(); }
|
||||
public byte[] getDataItemId32() { return dataItem.id32(); }
|
||||
public byte[] getHash32() { return Arrays.copyOf(hash32, hash32.length); }
|
||||
/** Full serialized ANS-104 DataItem. */
|
||||
public byte[] toBytes() { return dataItem.rawBytes(); }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
String timeIso;
|
||||
try {
|
||||
timeIso = Instant.ofEpochSecond(timestamp).toString();
|
||||
} catch (Exception e) {
|
||||
timeIso = "некорректныйTimestamp";
|
||||
}
|
||||
|
||||
return "BchBlockEntry{"
|
||||
+ "FRAME{frameCode=0x" + hex4(frameCode)
|
||||
+ "}, HDR{"
|
||||
+ "blockSize=" + blockSize
|
||||
+ ", blockNumber=" + blockNumber
|
||||
+ ", timestamp=" + timestamp + " (" + timeIso + ")"
|
||||
+ ", type=" + (type & 0xFFFF)
|
||||
+ ", subType=" + (subType & 0xFFFF)
|
||||
+ ", version=" + (version & 0xFFFF)
|
||||
+ ", prevHash32(hex)=" + toHex(prevHash32)
|
||||
+ "}"
|
||||
+ ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}"
|
||||
+ ", TAIL{sigMarker=0x" + hex4(sigMarker) + ", signature64(hex)=" + toHex(signature64) + "}"
|
||||
+ ", DERIVED{hash32(hex)=" + toHex(hash32) + "}"
|
||||
+ "}";
|
||||
}
|
||||
|
||||
private static String hex4(int v) {
|
||||
String s = Integer.toHexString(v & 0xFFFF);
|
||||
while (s.length() < 4) s = "0" + s;
|
||||
return s;
|
||||
return "BchBlockEntry{" +
|
||||
"frameCode=0x" + Integer.toHexString(frameCode) +
|
||||
", blockNumber=" + blockNumber +
|
||||
", timestamp=" + timestamp +
|
||||
", type=" + (type & 0xffff) +
|
||||
", subType=" + (subType & 0xffff) +
|
||||
", version=" + (version & 0xffff) +
|
||||
", bodyLen=" + bodyBytes.length +
|
||||
", dataItemId=" + toHex(getDataItemId32()) +
|
||||
", blockHash=" + toHex(hash32) +
|
||||
'}';
|
||||
}
|
||||
|
||||
private static String toHex(byte[] bytes) {
|
||||
if (bytes == null) return "null";
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
char[] h = "0123456789abcdef".toCharArray();
|
||||
char[] out = new char[bytes.length * 2];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
int vv = bytes[i] & 0xFF;
|
||||
out[i * 2] = HEX[vv >>> 4];
|
||||
out[i * 2 + 1] = HEX[vv & 0x0F];
|
||||
int v = bytes[i] & 0xff;
|
||||
out[i * 2] = h[v >>> 4]; out[i * 2 + 1] = h[v & 15];
|
||||
}
|
||||
return new String(out);
|
||||
}
|
||||
|
||||
+9
-24
@@ -1,42 +1,27 @@
|
||||
package blockchain;
|
||||
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Верификатор SHiNE (Frame v0):
|
||||
*
|
||||
* preimage = первые blockSize байт блока (ВКЛЮЧАЯ frameCode=0x0000),
|
||||
* = всё до TAIL (sigMarker+signature).
|
||||
*
|
||||
* hash32 = SHA-256(preimage)
|
||||
* verify = Ed25519.verify(hash32, signature64, pubKey32)
|
||||
*/
|
||||
/** Cryptographic checks for SHiNE Frame v1 + surrounding ANS-104 DataItem. */
|
||||
public final class BchCryptoVerifier {
|
||||
|
||||
private BchCryptoVerifier() {}
|
||||
|
||||
public static byte[] sha256(byte[] data) {
|
||||
Objects.requireNonNull(data, "data == null");
|
||||
try {
|
||||
MessageDigest d = MessageDigest.getInstance("SHA-256");
|
||||
return d.digest(data);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||
}
|
||||
try { return MessageDigest.getInstance("SHA-256").digest(data); }
|
||||
catch (Exception e) { throw new IllegalStateException("SHA-256 unavailable", e); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the ANS-104 owner is the current blockchain key and that the
|
||||
* ANS-104 Ed25519 signature is valid. The SHiNE block hash itself remains
|
||||
* SHA-256(Frame v1 bytes) and is intentionally independent of Arweave.
|
||||
*/
|
||||
public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) {
|
||||
Objects.requireNonNull(block, "block == null");
|
||||
Objects.requireNonNull(publicKey32, "publicKey32 == null");
|
||||
|
||||
if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32");
|
||||
|
||||
byte[] hash32 = block.getHash32();
|
||||
byte[] sig64 = block.getSignature64();
|
||||
|
||||
return Ed25519Util.verify(hash32, sig64, publicKey32);
|
||||
return block.getDataItem().ownerEquals(publicKey32) && block.getDataItem().verifySignature();
|
||||
}
|
||||
}
|
||||
@@ -102,16 +102,23 @@ public final class MsgSubType {
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в друзья. */
|
||||
public static final short CONNECTION_FRIEND = 14;
|
||||
/** Удалить из друзей. */
|
||||
public static final short CONNECTION_UNFRIEND = 15;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
/** Удалить из контактов. */
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
|
||||
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя (target header/block 0) считается legacy. DO NOT REMOVE.
|
||||
/** Подписаться (follow). */
|
||||
public static final short CONNECTION_FOLLOW = 30;
|
||||
/** Отписаться (unfollow). */
|
||||
public static final short CONNECTION_UNFOLLOW = 31;
|
||||
|
||||
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
|
||||
/** Добавить связь "жена/муж". */
|
||||
public static final short CONNECTION_SPOUSE = 40;
|
||||
/** Удалить связь "жена/муж". */
|
||||
@@ -132,9 +139,8 @@ public final class MsgSubType {
|
||||
/** Удалить связь "брат/сестра". */
|
||||
public static final short CONNECTION_UNSIBLING = 55;
|
||||
|
||||
/** Просто знаю этого человека. */
|
||||
// Legacy. Допускаем старые блоки 60/61, но новый UI их не создаёт и не использует. DO NOT REMOVE.
|
||||
public static final short CONNECTION_KNOWN_PERSON = 60;
|
||||
/** Не знаю этого человека. */
|
||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
||||
|
||||
/** Точно уверен, что сияющий. */
|
||||
@@ -142,11 +148,17 @@ public final class MsgSubType {
|
||||
/** Не подтверждаю, что сияющий. */
|
||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
||||
|
||||
// Пока не используется в UI. Сохраняем для совместимости протокола/блокчейна. DO NOT REMOVE.
|
||||
/** Мало знаком, но видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||
/** Не отмечаю, что видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||
|
||||
/** Подтверждаю, что это действительно официальный аккаунт этого человека. */
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
|
||||
/** Снимаю подтверждение официального аккаунта этого человека. */
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
|
||||
|
||||
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||
|
||||
/** Параметр профиля key/value (обе строки). */
|
||||
|
||||
+9
-3
@@ -20,9 +20,11 @@ import java.util.Objects;
|
||||
* PARENT=50, UNPARENT=51
|
||||
* CHILD=52, UNCHILD=53
|
||||
* SIBLING=54, UNSIBLING=55
|
||||
* KNOWN_PERSON=60, UNKNOWN_PERSON=61
|
||||
* FRIEND=14, UNFRIEND=15
|
||||
* KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI)
|
||||
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
|
||||
* SHINE_SEEN=74, SHINE_UNSEEN=75
|
||||
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
|
||||
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
|
||||
*
|
||||
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
||||
* [4] lineCode
|
||||
@@ -185,6 +187,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
int v = st & 0xFFFF;
|
||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||
@@ -202,7 +206,9 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF);
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
package utils.files;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* ===============================================================
|
||||
* FileStoreUtil — утилита работы с файлами в папке data/.
|
||||
*
|
||||
* Теперь поддерживает:
|
||||
* - основной файл блокчейна: <blockchainName>.bch
|
||||
* - временный файл блокчейна: <blockchainName>.tmp_bch
|
||||
* - sidecar-файл проверки записи: <blockchainName>.write_check
|
||||
* - marker-файл записи: <blockchainName>.write_pending
|
||||
*
|
||||
* Важное:
|
||||
* - validateSimpleFileName() запрещает path traversal.
|
||||
* - atomicReplaceBlockchainFile(): пытается сделать ATOMIC_MOVE (если ФС поддерживает),
|
||||
* иначе делает обычный REPLACE_EXISTING move.
|
||||
* ===============================================================
|
||||
*/
|
||||
public final class FileStoreUtil {
|
||||
|
||||
/** Базовая папка для хранения всех файлов (создаётся автоматически). */
|
||||
public static final String DATA_DIR_NAME = "data";
|
||||
|
||||
/** Расширение основного файла блокчейна. */
|
||||
public static final String BLOCKCHAIN_FILE_EXTENSION = ".bch";
|
||||
|
||||
/** Расширение временного файла (старое+новое). */
|
||||
public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch";
|
||||
|
||||
/** Маркер того, что chain сейчас в процессе полного resync. */
|
||||
public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending";
|
||||
|
||||
/** Marker того, что обычный AddBlock находится в опасной фазе записи. */
|
||||
public static final String BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION = ".write_pending";
|
||||
|
||||
/** Sidecar-файл с blockNumber/blockHash для обычного AddBlock. */
|
||||
public static final String BLOCKCHAIN_WRITE_CHECK_EXTENSION = ".write_check";
|
||||
|
||||
private static final FileStoreUtil INSTANCE = new FileStoreUtil();
|
||||
|
||||
private final Path dataDirPath;
|
||||
|
||||
private FileStoreUtil() {
|
||||
this.dataDirPath = Paths.get(DATA_DIR_NAME);
|
||||
ensureDataDirExists();
|
||||
}
|
||||
|
||||
public static FileStoreUtil getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ======================== Базовые операции =========================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
public void newFile(String fileName, byte[] data) {
|
||||
Objects.requireNonNull(data, "data == null");
|
||||
Path target = resolveSafe(fileName);
|
||||
try {
|
||||
Files.write(target, data,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось записать файл: " + target, e);
|
||||
}
|
||||
}
|
||||
|
||||
public void addDataToFile(String fileName, byte[] data) {
|
||||
Objects.requireNonNull(data, "data == null");
|
||||
Path target = resolveSafe(fileName);
|
||||
try {
|
||||
Files.write(target, data,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.WRITE,
|
||||
StandardOpenOption.APPEND);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось дописать файл: " + target, e);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] readAllDataFromFile(String fileName) {
|
||||
Path target = resolveSafe(fileName);
|
||||
if (!Files.exists(target)) {
|
||||
throw new IllegalStateException("Файл не найден: " + target);
|
||||
}
|
||||
try {
|
||||
return Files.readAllBytes(target);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось прочитать файл: " + target, e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean exists(String fileName) {
|
||||
Path target = resolveSafe(fileName);
|
||||
return Files.exists(target);
|
||||
}
|
||||
|
||||
public long size(String fileName) {
|
||||
Path target = resolveSafe(fileName);
|
||||
try {
|
||||
return Files.size(target);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось получить размер файла: " + target, e);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ===================== Блокчейн-файлы по имени ======================= */
|
||||
/* ===================================================================== */
|
||||
|
||||
/** <blockchainName>.bch */
|
||||
public String buildBlockchainFileName(String blockchainName) {
|
||||
validateSimpleFileName(blockchainName);
|
||||
return blockchainName + BLOCKCHAIN_FILE_EXTENSION;
|
||||
}
|
||||
|
||||
/** <blockchainName>.tmp_bch */
|
||||
public String buildBlockchainTmpFileName(String blockchainName) {
|
||||
validateSimpleFileName(blockchainName);
|
||||
return blockchainName + BLOCKCHAIN_TMP_EXTENSION;
|
||||
}
|
||||
|
||||
public Path resolveBlockchainPath(String blockchainName) {
|
||||
return resolveSafe(buildBlockchainFileName(blockchainName));
|
||||
}
|
||||
|
||||
public Path resolveBlockchainTmpPath(String blockchainName) {
|
||||
return resolveSafe(buildBlockchainTmpFileName(blockchainName));
|
||||
}
|
||||
|
||||
public byte[] readBlockchain(String blockchainName) {
|
||||
return readAllDataFromFile(buildBlockchainFileName(blockchainName));
|
||||
}
|
||||
|
||||
public void writeBlockchainTmp(String blockchainName, byte[] data) {
|
||||
newFile(buildBlockchainTmpFileName(blockchainName), data);
|
||||
}
|
||||
|
||||
/** <blockchainName>.write_check */
|
||||
public String buildBlockchainWriteCheckFileName(String blockchainName) {
|
||||
validateSimpleFileName(blockchainName);
|
||||
return blockchainName + BLOCKCHAIN_WRITE_CHECK_EXTENSION;
|
||||
}
|
||||
|
||||
public Path resolveBlockchainWriteCheckPath(String blockchainName) {
|
||||
return resolveSafe(buildBlockchainWriteCheckFileName(blockchainName));
|
||||
}
|
||||
|
||||
public void writeBlockchainWriteCheck(String blockchainName, int blockNumber, String blockHashHex) {
|
||||
StringBuilder sb = new StringBuilder(128);
|
||||
sb.append("blockNumber=").append(blockNumber).append('\n');
|
||||
sb.append("blockHash=").append(blockHashHex == null ? "" : blockHashHex).append('\n');
|
||||
newFile(buildBlockchainWriteCheckFileName(blockchainName), sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** <blockchainName>.write_pending */
|
||||
public String buildBlockchainWritePendingMarkerFileName(String blockchainName) {
|
||||
validateSimpleFileName(blockchainName);
|
||||
return blockchainName + BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION;
|
||||
}
|
||||
|
||||
public Path resolveBlockchainWritePendingMarkerPath(String blockchainName) {
|
||||
return resolveSafe(buildBlockchainWritePendingMarkerFileName(blockchainName));
|
||||
}
|
||||
|
||||
public void writeBlockchainWritePendingMarker(String blockchainName) {
|
||||
newFile(buildBlockchainWritePendingMarkerFileName(blockchainName), new byte[0]);
|
||||
}
|
||||
|
||||
/** <blockchainName>.resync_pending */
|
||||
public String buildBlockchainResyncMarkerFileName(String blockchainName) {
|
||||
validateSimpleFileName(blockchainName);
|
||||
return blockchainName + BLOCKCHAIN_RESYNC_MARKER_EXTENSION;
|
||||
}
|
||||
|
||||
public Path resolveBlockchainResyncMarkerPath(String blockchainName) {
|
||||
return resolveSafe(buildBlockchainResyncMarkerFileName(blockchainName));
|
||||
}
|
||||
|
||||
public void writeBlockchainResyncMarker(String blockchainName, String markerContent) {
|
||||
byte[] data = markerContent == null ? new byte[0] : markerContent.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
newFile(buildBlockchainResyncMarkerFileName(blockchainName), data);
|
||||
}
|
||||
|
||||
public void deleteIfExists(Path path) {
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось удалить файл: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteBlockchainFileIfExists(String blockchainName) {
|
||||
deleteIfExists(resolveBlockchainPath(blockchainName));
|
||||
}
|
||||
|
||||
public void deleteBlockchainTmpFileIfExists(String blockchainName) {
|
||||
deleteIfExists(resolveBlockchainTmpPath(blockchainName));
|
||||
}
|
||||
|
||||
public void deleteBlockchainWriteCheckIfExists(String blockchainName) {
|
||||
deleteIfExists(resolveBlockchainWriteCheckPath(blockchainName));
|
||||
}
|
||||
|
||||
public void deleteBlockchainWritePendingMarkerIfExists(String blockchainName) {
|
||||
deleteIfExists(resolveBlockchainWritePendingMarkerPath(blockchainName));
|
||||
}
|
||||
|
||||
public void deleteBlockchainResyncMarkerIfExists(String blockchainName) {
|
||||
deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName));
|
||||
}
|
||||
|
||||
public boolean existsBlockchainResyncMarker(String blockchainName) {
|
||||
return exists(buildBlockchainResyncMarkerFileName(blockchainName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Атомарно заменить основной файл блокчейна временным:
|
||||
* <name>.tmp_bch -> <name>.bch
|
||||
*
|
||||
* Стратегия:
|
||||
* 1) Пытаемся Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING)
|
||||
* 2) Если ATOMIC_MOVE не поддерживается — делаем move с REPLACE_EXISTING без атомарности
|
||||
*
|
||||
* Важный нюанс:
|
||||
* - атомарность гарантируется только в пределах одной файловой системы.
|
||||
*/
|
||||
public void atomicReplaceBlockchainFile(String blockchainName) {
|
||||
Path tmp = resolveBlockchainTmpPath(blockchainName);
|
||||
Path main = resolveBlockchainPath(blockchainName);
|
||||
|
||||
if (!Files.exists(tmp)) {
|
||||
throw new IllegalStateException("TMP-файл не найден: " + tmp);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1) Пытаемся атомарный move
|
||||
Files.move(tmp, main,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
// 2) Если ФС не поддерживает атомарный move — делаем обычный replace
|
||||
try {
|
||||
Files.move(tmp, main, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Не удалось заменить файл блокчейна (non-atomic): " + main, ex);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось заменить файл блокчейна (atomic): " + main, e);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ============================ Helpers ================================= */
|
||||
/* ===================================================================== */
|
||||
|
||||
private void ensureDataDirExists() {
|
||||
try {
|
||||
if (!Files.exists(dataDirPath)) {
|
||||
Files.createDirectories(dataDirPath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось создать директорию хранения: " + dataDirPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveSafe(String fileName) {
|
||||
validateSimpleFileName(fileName);
|
||||
return dataDirPath.resolve(fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация "простого имени":
|
||||
* - запрещаем слэши, обратные слэши, ".."
|
||||
* - запрещаем пустоту
|
||||
*
|
||||
* Важно: сюда у нас попадает и blockchainName (как часть имени файла),
|
||||
* поэтому blockchainName должен быть "простым": без путей.
|
||||
*/
|
||||
private void validateSimpleFileName(String fileName) {
|
||||
Objects.requireNonNull(fileName, "fileName == null");
|
||||
if (fileName.isBlank()) {
|
||||
throw new IllegalArgumentException("Имя файла не должно быть пустым");
|
||||
}
|
||||
if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
|
||||
throw new IllegalArgumentException("Недопустимое имя файла: " + fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package blockchain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class Ans104DataItemTest {
|
||||
@Test
|
||||
void ed25519RoundTripWithTestAndChannelTags() {
|
||||
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-test-key");
|
||||
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
|
||||
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
|
||||
List<Ans104DataItem.Tag> tags = List.of(
|
||||
new Ans104DataItem.Tag("App", "test5590"),
|
||||
new Ans104DataItem.Tag("c", "books")
|
||||
);
|
||||
|
||||
byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data);
|
||||
byte[] signature = Ed25519Util.sign(message, privateKey);
|
||||
byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data);
|
||||
|
||||
Ans104DataItem parsed = new Ans104DataItem(raw);
|
||||
assertEquals(2, parsed.signatureType());
|
||||
assertArrayEquals(owner, parsed.owner32());
|
||||
assertArrayEquals(data, parsed.data());
|
||||
assertTrue(parsed.hasTag("App", "test5590"));
|
||||
assertEquals("books", parsed.tagValue("c"));
|
||||
assertTrue(parsed.verifySignature());
|
||||
assertEquals(32, parsed.id32().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mutationBreaksSignature() {
|
||||
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-mutation-key");
|
||||
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
|
||||
byte[] data = "original".getBytes(StandardCharsets.UTF_8);
|
||||
List<Ans104DataItem.Tag> tags = List.of(new Ans104DataItem.Tag("App", "test5590"));
|
||||
byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey);
|
||||
byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data);
|
||||
raw[raw.length - 1] ^= 1;
|
||||
assertFalse(new Ans104DataItem(raw).verifySignature());
|
||||
}
|
||||
@Test
|
||||
void signingMessageMatchesArbundlesTurboCompatibilityVector() {
|
||||
byte[] owner = new byte[32];
|
||||
for (int i = 0; i < owner.length; i++) owner[i] = (byte) i;
|
||||
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
|
||||
List<Ans104DataItem.Tag> tags = List.of(
|
||||
new Ans104DataItem.Tag("App", "test5590"),
|
||||
new Ans104DataItem.Tag("c", "books")
|
||||
);
|
||||
|
||||
byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data);
|
||||
byte[] expected = hex("7e67d0debce103606d697a1e3785130ca20cb89cd8a06f9a65b98b2d2427eaf411fcf7d482da268e44f3ac25c57c3cb9");
|
||||
assertArrayEquals(expected, actual);
|
||||
}
|
||||
|
||||
private static byte[] hex(String value) {
|
||||
if ((value.length() & 1) != 0) throw new IllegalArgumentException("odd hex length");
|
||||
byte[] out = new byte[value.length() / 2];
|
||||
for (int i = 0; i < out.length; i++) {
|
||||
int hi = Character.digit(value.charAt(i * 2), 16);
|
||||
int lo = Character.digit(value.charAt(i * 2 + 1), 16);
|
||||
if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex");
|
||||
out[i] = (byte) ((hi << 4) | lo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# utils.crypto
|
||||
|
||||
Пакет отвечает за криптографию — подписи и хэши блоков.
|
||||
Используется при создании и проверке целостности `.bch`-блоков.
|
||||
Используется при создании и проверке криптографических сущностей SHiNE; пользовательские blockchain-файлы `.bch` больше не используются.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_14 = 14;
|
||||
public static final int SCHEMA_VERSION_15 = 15;
|
||||
public static final int SCHEMA_VERSION_16 = 16;
|
||||
public static final int SCHEMA_VERSION_17 = 17;
|
||||
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 int SCHEMA_VERSION_24 = 24;
|
||||
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";
|
||||
@@ -49,6 +57,14 @@ public final class DatabaseInitializer {
|
||||
public static final String POSTGRES_MIGRATION_V14_RESOURCE = "postgres/migration_v14.sql";
|
||||
public static final String POSTGRES_MIGRATION_V15_RESOURCE = "postgres/migration_v15.sql";
|
||||
public static final String POSTGRES_MIGRATION_V16_RESOURCE = "postgres/migration_v16.sql";
|
||||
public static final String POSTGRES_MIGRATION_V17_RESOURCE = "postgres/migration_v17.sql";
|
||||
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";
|
||||
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -70,12 +86,17 @@ public final class DatabaseInitializer {
|
||||
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
public static final short CONNECTION_FRIEND = 14;
|
||||
public static final short CONNECTION_UNFRIEND = 15;
|
||||
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
|
||||
// FOLLOW на публичный канал используется; user-to-user FOLLOW legacy. DO NOT REMOVE.
|
||||
public static final short CONNECTION_FOLLOW = 30;
|
||||
public static final short CONNECTION_UNFOLLOW = 31;
|
||||
|
||||
// Родственные связи пока не используются в UI. DO NOT REMOVE.
|
||||
public static final short CONNECTION_SPOUSE = 40;
|
||||
public static final short CONNECTION_UNSPOUSE = 41;
|
||||
|
||||
@@ -88,15 +109,20 @@ public final class DatabaseInitializer {
|
||||
public static final short CONNECTION_SIBLING = 54;
|
||||
public static final short CONNECTION_UNSIBLING = 55;
|
||||
|
||||
// Legacy 60/61: новый UI не использует. DO NOT REMOVE.
|
||||
public static final short CONNECTION_KNOWN_PERSON = 60;
|
||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
||||
|
||||
public static final short CONNECTION_SHINE_CONFIRMED = 70;
|
||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
||||
|
||||
// Пока не используется в UI. DO NOT REMOVE.
|
||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
|
||||
|
||||
public static void ensurePostgresSchemaInitialized(String jdbcUrl,
|
||||
String user,
|
||||
String password) throws SQLException {
|
||||
@@ -172,6 +198,38 @@ public final class DatabaseInitializer {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V16_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_16;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_17) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V17_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_17;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_18) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V18_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_18;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_19) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_19;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_20) {
|
||||
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;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_24) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_24;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,10 +58,10 @@ public final class MsgSubType {
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
/**
|
||||
* Совпадает с ConnectionBody:
|
||||
* SET: CLOSE_FRIEND=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
|
||||
* UNSET: UNCLOSE_FRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
|
||||
* SET: CLOSE_FRIEND=10, FRIEND=14, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||
* KNOWN_PERSON(legacy)=60, SHINE_CONFIRMED=70, SHINE_SEEN=74, OFFICIAL_ACCOUNT_CONFIRMED=80
|
||||
* UNSET: UNCLOSE_FRIEND=11, UNFRIEND=15, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNKNOWN_PERSON(legacy)=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75, OFFICIAL_ACCOUNT_UNCONFIRMED=81
|
||||
*/
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
@@ -70,18 +70,26 @@ public final class MsgSubType {
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
|
||||
/** Добавить в друзья. */
|
||||
public static final short CONNECTION_FRIEND = 14;
|
||||
|
||||
/** Удалить из друзей. */
|
||||
public static final short CONNECTION_UNFRIEND = 15;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
|
||||
/** Удалить из контактов. */
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
|
||||
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя считается legacy. DO NOT REMOVE.
|
||||
/** Подписаться (follow). */
|
||||
public static final short CONNECTION_FOLLOW = 30;
|
||||
|
||||
/** Отписаться (unfollow). */
|
||||
public static final short CONNECTION_UNFOLLOW = 31;
|
||||
|
||||
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
|
||||
/** Добавить связь "жена/муж". */
|
||||
public static final short CONNECTION_SPOUSE = 40;
|
||||
|
||||
@@ -106,10 +114,8 @@ public final class MsgSubType {
|
||||
/** Удалить связь "брат/сестра". */
|
||||
public static final short CONNECTION_UNSIBLING = 55;
|
||||
|
||||
/** Просто знаю этого человека. */
|
||||
// Legacy 60/61: принимаем старые данные, но новый UI их не использует. DO NOT REMOVE.
|
||||
public static final short CONNECTION_KNOWN_PERSON = 60;
|
||||
|
||||
/** Не знаю этого человека. */
|
||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
||||
|
||||
/** Точно уверен, что сияющий. */
|
||||
@@ -118,12 +124,19 @@ public final class MsgSubType {
|
||||
/** Не подтверждаю, что сияющий. */
|
||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
||||
|
||||
// Пока не используется в UI. Сохраняем для совместимости. DO NOT REMOVE.
|
||||
/** Мало знаком, но видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||
|
||||
/** Не отмечаю, что видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||
|
||||
/** Подтверждение официального аккаунта человека. */
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
|
||||
|
||||
/** Снятие подтверждения официального аккаунта человека. */
|
||||
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
|
||||
|
||||
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||
|
||||
/** Параметр профиля key/value (обе строки). */
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Persistent discovery/import queue for ANS-104 SHiNE blocks found through Arweave. */
|
||||
public final class ArweaveBlockImportDAO {
|
||||
public static final String STATUS_PENDING = "PENDING";
|
||||
public static final String STATUS_REJECTED = "REJECTED";
|
||||
|
||||
public record QueueItem(byte[] dataItemId, String rootTxId, long blockHeight,
|
||||
byte[] rawDataItem, String status, String lastError,
|
||||
long firstSeenAtMs, long updatedAtMs) {}
|
||||
|
||||
private static volatile ArweaveBlockImportDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private ArweaveBlockImportDAO() {}
|
||||
|
||||
public static ArweaveBlockImportDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ArweaveBlockImportDAO.class) {
|
||||
if (instance == null) instance = new ArweaveBlockImportDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public long getLastBlockHeight() throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("SELECT last_block_height FROM arweave_block_sync_state WHERE id=1");
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getLong(1) : 0L;
|
||||
}
|
||||
}
|
||||
|
||||
public void setLastBlockHeight(long height, long nowMs) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
|
||||
VALUES(1,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET last_block_height=EXCLUDED.last_block_height, updated_at_ms=EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, Math.max(0L, height));
|
||||
ps.setLong(2, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */
|
||||
public boolean enqueueIfMissing(byte[] dataItemId, String rootTxId, long blockHeight, byte[] rawDataItem, long nowMs)
|
||||
throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO arweave_block_import_queue(
|
||||
data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||
) VALUES(?,?,?,?,?,'',?,?)
|
||||
ON CONFLICT(data_item_id) DO NOTHING
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setBytes(1, dataItemId);
|
||||
ps.setString(2, rootTxId);
|
||||
ps.setLong(3, blockHeight);
|
||||
ps.setBytes(4, rawDataItem);
|
||||
ps.setString(5, STATUS_PENDING);
|
||||
ps.setLong(6, nowMs);
|
||||
ps.setLong(7, nowMs);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean exists(byte[] dataItemId) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("SELECT 1 FROM arweave_block_import_queue WHERE data_item_id=? LIMIT 1")) {
|
||||
ps.setBytes(1, dataItemId);
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
|
||||
}
|
||||
}
|
||||
|
||||
public List<QueueItem> listPending(int limit) throws SQLException {
|
||||
int safeLimit = Math.max(1, Math.min(limit, 100_000));
|
||||
String sql = """
|
||||
SELECT data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||
FROM arweave_block_import_queue
|
||||
WHERE status=?
|
||||
ORDER BY block_height ASC, first_seen_at_ms ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<QueueItem> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, STATUS_PENDING);
|
||||
ps.setInt(2, safeLimit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
out.add(new QueueItem(
|
||||
rs.getBytes("data_item_id"), rs.getString("root_tx_id"), rs.getLong("block_height"),
|
||||
rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"),
|
||||
rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms")));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public void setPendingError(byte[] id, String error, long nowMs) throws SQLException {
|
||||
updateStatus(id, STATUS_PENDING, error, nowMs);
|
||||
}
|
||||
|
||||
public void reject(byte[] id, String error, long nowMs) throws SQLException {
|
||||
updateStatus(id, STATUS_REJECTED, error, nowMs);
|
||||
}
|
||||
|
||||
public void delete(byte[] id) throws SQLException {
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
|
||||
"DELETE FROM arweave_block_import_queue WHERE data_item_id=?")) {
|
||||
ps.setBytes(1, id);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStatus(byte[] id, String status, String error, long nowMs) throws SQLException {
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
|
||||
"UPDATE arweave_block_import_queue SET status=?,last_error=?,updated_at_ms=? WHERE data_item_id=?")) {
|
||||
ps.setString(1, status);
|
||||
ps.setString(2, trimError(error));
|
||||
ps.setLong(3, nowMs);
|
||||
ps.setBytes(4, id);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimError(String s) {
|
||||
String v = s == null ? "" : s.replace('\n',' ').replace('\r',' ').trim();
|
||||
return v.length() <= 500 ? v : v.substring(0,500);
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -23,7 +23,7 @@ import java.sql.SQLException;
|
||||
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
|
||||
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
|
||||
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
|
||||
* - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию:
|
||||
* - пользовательские blockchain-файлы больше не существуют; cleanup полностью DB-only.
|
||||
* SQL-транзакция и файловая система не коммитятся атомарно вместе;
|
||||
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
|
||||
* отдельным recovery/resync-слоем после успешного commit.
|
||||
@@ -71,7 +71,7 @@ public final class BlockchainResyncCleanupDAO {
|
||||
* - если blockchain_state уже отсутствует, login берём из blockchainName.
|
||||
*
|
||||
* Отдельно важно:
|
||||
* - здесь НЕТ удаления .bch/.tmp_bch;
|
||||
* - filesystem blockchain storage отсутствует;
|
||||
* - здесь НЕТ повторной загрузки цепочки;
|
||||
* - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow.
|
||||
*/
|
||||
@@ -426,6 +426,25 @@ public final class BlockchainResyncCleanupDAO {
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(login) FROM solana_user_pda_current")) {
|
||||
ps.execute();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_stats(login) FROM solana_user_pda_current")) {
|
||||
ps.execute();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE message_stats ms SET
|
||||
primary_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||
AND shine_is_primary(rs.from_login)),
|
||||
shining_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login))
|
||||
""")) {
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
|
||||
@@ -9,286 +9,147 @@ import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DAO для таблицы blocks (новый формат).
|
||||
*
|
||||
* Правило:
|
||||
* - методы с Connection НЕ закрывают соединение
|
||||
* - методы без Connection сами открывают и закрывают соединение
|
||||
*
|
||||
* Ключ:
|
||||
* - (bch_name, block_number) — уникальная пара в рамках общей БД сервера.
|
||||
*/
|
||||
/** DAO for SHiNE Frame v1 blocks. block_bytes contains the complete ANS-104 DataItem. */
|
||||
public final class BlocksDAO {
|
||||
|
||||
private static volatile BlocksDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class);
|
||||
|
||||
private BlocksDAO() { }
|
||||
|
||||
public static BlocksDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (BlocksDAO.class) {
|
||||
if (instance == null) instance = new BlocksDAO();
|
||||
}
|
||||
}
|
||||
if (instance == null) synchronized (BlocksDAO.class) { if (instance == null) instance = new BlocksDAO(); }
|
||||
return instance;
|
||||
}
|
||||
|
||||
// -------------------- INSERT --------------------
|
||||
|
||||
/** Вставка с внешним соединением. Соединение НЕ закрывает. */
|
||||
public void insert(Connection c, BlockEntry e) throws SQLException {
|
||||
log.info("DBG BlockEntry: type={} sub={} lineCode={} prevLineNumber={} thisLineNumber={} prevLineHashLen={}",
|
||||
e.getMsgType(), e.getMsgSubType(),
|
||||
e.getLineCode(), e.getPrevLineNumber(), e.getThisLineNumber(),
|
||||
e.getPrevLineHash() == null ? null : e.getPrevLineHash().length
|
||||
);
|
||||
|
||||
String sql = """
|
||||
INSERT INTO blocks (
|
||||
login,
|
||||
bch_name,
|
||||
block_number,
|
||||
msg_type,
|
||||
msg_sub_type,
|
||||
block_bytes,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash,
|
||||
block_hash,
|
||||
block_signature,
|
||||
edited_by_block_number,
|
||||
line_code,
|
||||
prev_line_number,
|
||||
prev_line_hash,
|
||||
this_line_number
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||
to_login,to_bch_name,to_block_number,to_block_hash,
|
||||
block_hash,block_signature,data_item_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id,
|
||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
|
||||
ps.setString(i++, e.getLogin());
|
||||
ps.setString(i++, e.getBchName());
|
||||
ps.setInt(i++, e.getBlockNumber());
|
||||
|
||||
ps.setInt(i++, e.getMsgType());
|
||||
ps.setInt(i++, e.getMsgSubType());
|
||||
|
||||
ps.setBytes(i++, e.getBlockBytes());
|
||||
|
||||
if (e.getToLogin() != null) ps.setString(i++, e.getToLogin());
|
||||
else ps.setNull(i++, Types.VARCHAR);
|
||||
|
||||
if (e.getToBchName() != null) ps.setString(i++, e.getToBchName());
|
||||
else ps.setNull(i++, Types.VARCHAR);
|
||||
|
||||
if (e.getToBlockNumber() != null) ps.setInt(i++, e.getToBlockNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash());
|
||||
else ps.setNull(i++, Types.BINARY);
|
||||
|
||||
setNullableString(ps, i++, e.getToLogin());
|
||||
setNullableString(ps, i++, e.getToBchName());
|
||||
setNullableInt(ps, i++, e.getToBlockNumber());
|
||||
setNullableBytes(ps, i++, e.getToBlockHash());
|
||||
ps.setBytes(i++, e.getBlockHash());
|
||||
ps.setBytes(i++, e.getBlockSignature());
|
||||
|
||||
if (e.getEditedByBlockNumber() != null) ps.setInt(i++, e.getEditedByBlockNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
// NEW: line_code
|
||||
if (e.getLineCode() != null) ps.setInt(i++, e.getLineCode());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
if (e.getPrevLineNumber() != null) ps.setInt(i++, e.getPrevLineNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash());
|
||||
else ps.setNull(i++, Types.BINARY);
|
||||
|
||||
if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
ps.setBytes(i++, e.getDataItemId());
|
||||
ps.setBoolean(i++, e.isArweavePublishPending());
|
||||
if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs());
|
||||
setNullableString(ps, i++, e.getArweaveRootTxId());
|
||||
setNullableInt(ps, i++, e.getEditedByBlockNumber());
|
||||
setNullableInt(ps, i++, e.getLineCode());
|
||||
setNullableInt(ps, i++, e.getPrevLineNumber());
|
||||
setNullableBytes(ps, i++, e.getPrevLineHash());
|
||||
setNullableInt(ps, i++, e.getThisLineNumber());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Вставка без внешнего соединения. Сам открывает/закрывает. */
|
||||
public void insert(BlockEntry e) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
insert(c, e);
|
||||
public void insert(BlockEntry e) throws SQLException { try (Connection c = db.getConnection()) { insert(c, e); } }
|
||||
|
||||
public boolean existsByDataItemId(byte[] dataItemId) throws SQLException {
|
||||
if (dataItemId == null || dataItemId.length != 32) return false;
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("SELECT 1 FROM blocks WHERE data_item_id=? LIMIT 1")) {
|
||||
ps.setBytes(1, dataItemId);
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT: HASH BY NUMBER --------------------
|
||||
|
||||
/** Получить block_hash по (bch_name, block_number). Нужен для линейной проверки. */
|
||||
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
||||
public List<BlockEntry> listPendingArweave(int limit) throws SQLException {
|
||||
int safeLimit = Math.max(1, Math.min(limit, 100_000));
|
||||
String sql = """
|
||||
SELECT block_hash
|
||||
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id,
|
||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name = ? AND block_number = ?
|
||||
LIMIT 1
|
||||
WHERE arweave_publish_pending = TRUE
|
||||
ORDER BY bch_name ASC, block_number ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bchName);
|
||||
ps.setInt(2, blockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return rs.getBytes("block_hash");
|
||||
}
|
||||
List<BlockEntry> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setInt(1, safeLimit);
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException {
|
||||
public void markArweavePublished(List<byte[]> dataItemIds, String rootTxId, long publishedAtMs) throws SQLException {
|
||||
if (dataItemIds == null || dataItemIds.isEmpty()) return;
|
||||
String sql = """
|
||||
UPDATE blocks
|
||||
SET arweave_publish_pending=FALSE, arweave_published_at_ms=?, arweave_root_tx_id=?
|
||||
WHERE data_item_id=?
|
||||
""";
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getHashByNumber(c, bchName, blockNumber);
|
||||
c.setAutoCommit(false);
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (byte[] id : dataItemIds) {
|
||||
ps.setLong(1, publishedAtMs); ps.setString(2, rootTxId); ps.setBytes(3, id); ps.addBatch();
|
||||
}
|
||||
ps.executeBatch(); c.commit();
|
||||
} catch (Exception e) { c.rollback(); throw e; }
|
||||
finally { c.setAutoCommit(true); }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT: FULL ENTRY --------------------
|
||||
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT block_hash FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1")) {
|
||||
ps.setString(1,bchName); ps.setInt(2,blockNumber);
|
||||
try (ResultSet rs=ps.executeQuery()) { return rs.next()?rs.getBytes(1):null; }
|
||||
}
|
||||
}
|
||||
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException { try(Connection c=db.getConnection()){return getHashByNumber(c,bchName,blockNumber);} }
|
||||
|
||||
public BlockEntry getByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
bch_name,
|
||||
block_number,
|
||||
msg_type,
|
||||
msg_sub_type,
|
||||
block_bytes,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash,
|
||||
block_hash,
|
||||
block_signature,
|
||||
edited_by_block_number,
|
||||
line_code,
|
||||
prev_line_number,
|
||||
prev_line_hash,
|
||||
this_line_number
|
||||
String sql = baseSelect()+" WHERE bch_name=? AND block_number=? LIMIT 1";
|
||||
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,bchName); ps.setInt(2,blockNumber); try(ResultSet rs=ps.executeQuery()){return rs.next()?mapRow(rs):null;} }
|
||||
}
|
||||
public BlockEntry getByNumber(String bchName,int blockNumber)throws SQLException{try(Connection c=db.getConnection()){return getByNumber(c,bchName,blockNumber);} }
|
||||
|
||||
public List<BlockEntry> listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} }
|
||||
public List<BlockEntry> listRangeByNumber(Connection c,String bchName,int from,int to)throws SQLException{
|
||||
String sql=baseSelect()+" WHERE bch_name=? AND block_number>=? AND block_number<=? ORDER BY block_number ASC";
|
||||
List<BlockEntry> out=new ArrayList<>();
|
||||
try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,bchName);ps.setInt(2,from);ps.setInt(3,to);try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(mapRow(rs));}}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String baseSelect(){return """
|
||||
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id,
|
||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name = ? AND block_number = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bchName);
|
||||
ps.setInt(2, blockNumber);
|
||||
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public BlockEntry getByNumber(String bchName, int blockNumber) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByNumber(c, bchName, blockNumber);
|
||||
}
|
||||
}
|
||||
|
||||
public List<BlockEntry> listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive);
|
||||
}
|
||||
}
|
||||
|
||||
public List<BlockEntry> listRangeByNumber(Connection c, String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
bch_name,
|
||||
block_number,
|
||||
msg_type,
|
||||
msg_sub_type,
|
||||
block_bytes,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash,
|
||||
block_hash,
|
||||
block_signature,
|
||||
edited_by_block_number,
|
||||
line_code,
|
||||
prev_line_number,
|
||||
prev_line_hash,
|
||||
this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name = ?
|
||||
AND block_number >= ?
|
||||
AND block_number <= ?
|
||||
ORDER BY block_number ASC
|
||||
""";
|
||||
|
||||
List<BlockEntry> result = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bchName);
|
||||
ps.setInt(2, fromBlockNumberInclusive);
|
||||
ps.setInt(3, toBlockNumberInclusive);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------- INTERNAL --------------------
|
||||
""";}
|
||||
|
||||
private BlockEntry mapRow(ResultSet rs)throws SQLException{
|
||||
BlockEntry e=new BlockEntry();
|
||||
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setBchName(rs.getString("bch_name"));
|
||||
e.setBlockNumber(rs.getInt("block_number"));
|
||||
|
||||
e.setMsgType(rs.getInt("msg_type"));
|
||||
e.setMsgSubType(rs.getInt("msg_sub_type"));
|
||||
|
||||
e.setBlockBytes(rs.getBytes("block_bytes"));
|
||||
|
||||
String toLogin = rs.getString("to_login");
|
||||
if (rs.wasNull()) toLogin = null;
|
||||
e.setToLogin(toLogin);
|
||||
|
||||
String toBchName = rs.getString("to_bch_name");
|
||||
if (rs.wasNull()) toBchName = null;
|
||||
e.setToBchName(toBchName);
|
||||
|
||||
Integer toBlockNumber = (Integer) rs.getObject("to_block_number");
|
||||
e.setToBlockNumber(toBlockNumber);
|
||||
|
||||
byte[] toHash = rs.getBytes("to_block_hash");
|
||||
if (rs.wasNull()) toHash = null;
|
||||
e.setToBlockHash(toHash);
|
||||
|
||||
e.setBlockHash(rs.getBytes("block_hash"));
|
||||
e.setBlockSignature(rs.getBytes("block_signature"));
|
||||
|
||||
Integer editedBy = (Integer) rs.getObject("edited_by_block_number");
|
||||
e.setEditedByBlockNumber(editedBy);
|
||||
|
||||
// NEW: line_code
|
||||
Integer lineCode = (Integer) rs.getObject("line_code");
|
||||
e.setLineCode(lineCode);
|
||||
|
||||
Integer prevLn = (Integer) rs.getObject("prev_line_number");
|
||||
e.setPrevLineNumber(prevLn);
|
||||
|
||||
byte[] prevLh = rs.getBytes("prev_line_hash");
|
||||
if (rs.wasNull()) prevLh = null;
|
||||
e.setPrevLineHash(prevLh);
|
||||
|
||||
Integer thisLn = (Integer) rs.getObject("this_line_number");
|
||||
e.setThisLineNumber(thisLn);
|
||||
|
||||
e.setLogin(rs.getString("login")); e.setBchName(rs.getString("bch_name")); e.setBlockNumber(rs.getInt("block_number"));
|
||||
e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes"));
|
||||
e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash"));
|
||||
e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id"));
|
||||
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms")); e.setArweaveRootTxId(rs.getString("arweave_root_tx_id"));
|
||||
e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number"));
|
||||
return e;
|
||||
}
|
||||
|
||||
private static void setNullableString(PreparedStatement ps,int i,String v)throws SQLException{if(v==null)ps.setNull(i,Types.VARCHAR);else ps.setString(i,v);}
|
||||
private static void setNullableInt(PreparedStatement ps,int i,Integer v)throws SQLException{if(v==null)ps.setNull(i,Types.INTEGER);else ps.setInt(i,v);}
|
||||
private static void setNullableBytes(PreparedStatement ps,int i,byte[] v)throws SQLException{if(v==null)ps.setNull(i,Types.BINARY);else ps.setBytes(i,v);}
|
||||
}
|
||||
|
||||
@@ -120,4 +120,15 @@ public final class ChannelNameStateDAO {
|
||||
insert(c, entry);
|
||||
}
|
||||
}
|
||||
public String getSlugByOwnerAndRoot(String ownerBchName, int rootBlockNumber) throws SQLException {
|
||||
String sql = "SELECT slug FROM channel_names_state WHERE owner_bch_name=? AND channel_root_block_number=? LIMIT 1";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setInt(2, rootBlockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString(1) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ public final class DmDialogStateDAO {
|
||||
|
||||
Map<String, DialogSummary> byPeer = new LinkedHashMap<>();
|
||||
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CONTACT);
|
||||
List<String> friends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_FRIEND);
|
||||
List<String> closeFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
@@ -76,16 +77,18 @@ public final class DmDialogStateDAO {
|
||||
ps.setString(1, cleanOwner);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
long lastMessageTimeMs = rs.getLong("last_message_time_ms");
|
||||
int unreadCount = rs.getInt("unread_count");
|
||||
DialogSummary row = new DialogSummary(
|
||||
rs.getString("owner_login"),
|
||||
rs.getString("peer_login"),
|
||||
normalizeRelationFlag(rs.getString("relation_flag")),
|
||||
rs.getString("last_message_blob_b64"),
|
||||
rs.getLong("last_message_time_ms"),
|
||||
rs.getInt("unread_count"),
|
||||
lastMessageTimeMs,
|
||||
unreadCount,
|
||||
rs.getLong("last_read_receipt_time_ms"),
|
||||
rs.getLong("updated_at_ms"),
|
||||
true
|
||||
lastMessageTimeMs > 0 || unreadCount > 0
|
||||
);
|
||||
byPeer.put(normKey(row.peerLogin()), row);
|
||||
}
|
||||
@@ -95,6 +98,9 @@ public final class DmDialogStateDAO {
|
||||
for (String peer : contacts) {
|
||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "contact", false);
|
||||
}
|
||||
for (String peer : friends) {
|
||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "friend", false);
|
||||
}
|
||||
for (String peer : closeFriends) {
|
||||
addOrUpdateSummary(byPeer, cleanOwner, peer, "close_friend", false);
|
||||
}
|
||||
@@ -286,6 +292,9 @@ public final class DmDialogStateDAO {
|
||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
|
||||
return "close_friend";
|
||||
}
|
||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_FRIEND)) {
|
||||
return "friend";
|
||||
}
|
||||
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CONTACT)) {
|
||||
return "contact";
|
||||
}
|
||||
@@ -357,6 +366,8 @@ public final class DmDialogStateDAO {
|
||||
String nextRelation = current.relationFlag();
|
||||
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
|
||||
nextRelation = "close_friend";
|
||||
} else if ("friend".equalsIgnoreCase(relationFlag) || "friend".equalsIgnoreCase(nextRelation)) {
|
||||
nextRelation = "friend";
|
||||
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
|
||||
nextRelation = "contact";
|
||||
} else {
|
||||
@@ -433,7 +444,7 @@ public final class DmDialogStateDAO {
|
||||
|
||||
private String normalizeRelationFlag(String value) {
|
||||
String clean = normalize(value).toLowerCase(Locale.ROOT);
|
||||
if ("close_friend".equals(clean) || "contact".equals(clean)) return clean;
|
||||
if ("close_friend".equals(clean) || "friend".equals(clean) || "contact".equals(clean)) return clean;
|
||||
return "none";
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем.
|
||||
@@ -60,6 +61,23 @@ public final class SolanaUserPdaCurrentDAO {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a current user by the 32-byte Ed25519 blockchain key used as ANS-104 owner. */
|
||||
public SolanaUserPdaCurrentEntry getByBlockchainKey(byte[] owner32) throws SQLException {
|
||||
if (owner32 == null || owner32.length != 32) return null;
|
||||
String wanted = Base64.getEncoder().encodeToString(owner32);
|
||||
String sql = """
|
||||
SELECT login, blockchain_name, blockchain_key, paid_limit_bytes
|
||||
FROM solana_user_pda_current
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql); ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
SolanaUserPdaCurrentEntry e = mapRow(rs);
|
||||
if (wanted.equals(e.getBlockchainKey())) return e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException {
|
||||
SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry();
|
||||
entry.setLogin(rs.getString("login"));
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
|
||||
public final class UserNotificationSeenStateDAO {
|
||||
private static final UserNotificationSeenStateDAO INSTANCE = new UserNotificationSeenStateDAO();
|
||||
private UserNotificationSeenStateDAO() {}
|
||||
public static UserNotificationSeenStateDAO getInstance() { return INSTANCE; }
|
||||
|
||||
public long getSeenAt(Connection c, String login, String category) throws Exception {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT seen_at_ms FROM user_notification_seen_state WHERE owner_login=? AND category=?")) {
|
||||
ps.setString(1, login); ps.setString(2, category);
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||
}
|
||||
}
|
||||
|
||||
public long advance(Connection c, String login, String category, long seenAtMs, long signedAtMs, byte[] signedBlob) throws Exception {
|
||||
String sql = """
|
||||
INSERT INTO user_notification_seen_state(owner_login, category, seen_at_ms, signed_blob, signed_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(owner_login, category) DO UPDATE SET
|
||||
seen_at_ms=EXCLUDED.seen_at_ms, signed_blob=EXCLUDED.signed_blob,
|
||||
signed_at_ms=EXCLUDED.signed_at_ms, updated_at_ms=EXCLUDED.updated_at_ms
|
||||
WHERE user_notification_seen_state.seen_at_ms < EXCLUDED.seen_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login); ps.setString(2, category); ps.setLong(3, seenAtMs);
|
||||
ps.setBytes(4, signedBlob); ps.setLong(5, signedAtMs); ps.setLong(6, System.currentTimeMillis());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
return getSeenAt(c, login, category);
|
||||
}
|
||||
}
|
||||
+27
@@ -84,6 +84,33 @@ public final class UserNotificationsStateDAO {
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<UserNotificationEntry> listVisible(Connection c, String ownerLogin, String kind, long seenAtMs, long cutoffMs) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||
FROM user_notifications_state
|
||||
WHERE owner_login = ? AND notification_kind = ?
|
||||
AND (created_at_ms > ? OR created_at_ms >= ?)
|
||||
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||
""";
|
||||
List<UserNotificationEntry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||
ps.setLong(4, Math.max(0, cutoffMs));
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public long countUnseen(Connection c, String ownerLogin, String kind, long seenAtMs) throws SQLException {
|
||||
String sql = "SELECT COUNT(*) FROM user_notifications_state WHERE owner_login = ? AND notification_kind = ? AND created_at_ms > ?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||
}
|
||||
}
|
||||
|
||||
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserNotificationEntry e = new UserNotificationEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
|
||||
@@ -55,6 +55,7 @@ public final class UserParamsDAO {
|
||||
WHERE users_params.time_ms < excluded.time_ms
|
||||
""";
|
||||
|
||||
int changed;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getLogin());
|
||||
ps.setString(2, e.getParam());
|
||||
@@ -67,8 +68,12 @@ public final class UserParamsDAO {
|
||||
if (e.getSignature() != null) ps.setString(6, e.getSignature());
|
||||
else ps.setNull(6, Types.VARCHAR);
|
||||
|
||||
return ps.executeUpdate();
|
||||
changed = ps.executeUpdate();
|
||||
}
|
||||
if (changed > 0 && affectsFastProfile(e.getParam())) {
|
||||
refreshFastProfileAndDerivedState(c, e.getLogin(), e.getParam());
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public int upsertIfNewer(UserParamEntry e) throws SQLException {
|
||||
@@ -77,6 +82,32 @@ public final class UserParamsDAO {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static boolean affectsFastProfile(String param) {
|
||||
if (param == null) return false;
|
||||
return switch (param) {
|
||||
case "first_name", "last_name", "ava", "account_role", "shine" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private static void refreshFastProfileAndDerivedState(Connection c, String login, String param) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(?)")) {
|
||||
ps.setString(1, login);
|
||||
ps.execute();
|
||||
}
|
||||
if ("account_role".equals(param) || "shine".equals(param)) {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_related_user_stats(?)")) {
|
||||
ps.setString(1, login);
|
||||
ps.execute();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_message_qualified_likes_for_actor(?)")) {
|
||||
ps.setString(1, login);
|
||||
ps.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT --------------------
|
||||
|
||||
public UserParamEntry getByLoginAndParam(Connection c, String login, String param) throws SQLException {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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;
|
||||
|
||||
import shine.db.MsgSubType;
|
||||
|
||||
/** Fast denormalized projection for user cards shown in dialogs/relations/profile lists. */
|
||||
public final class UserProfileStateDAO {
|
||||
private static volatile UserProfileStateDAO instance;
|
||||
private UserProfileStateDAO() {}
|
||||
public static UserProfileStateDAO getInstance() {
|
||||
if (instance == null) synchronized (UserProfileStateDAO.class) {
|
||||
if (instance == null) instance = new UserProfileStateDAO();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ProfileCard get(Connection c, String login) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT login, first_name, last_name, ava_ar, account_role, shine_status
|
||||
FROM user_profile_state WHERE LOWER(login)=LOWER(?) LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return new ProfileCard(login, "", "", "", null, null);
|
||||
return map(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Effective outgoing social relation with priority close_friend > friend > contact > none.
|
||||
*
|
||||
* Use the canonical relation lookup instead of comparing only connections_state.to_login:
|
||||
* older/current blocks may address a user by blockchain name, so a direct to_login-only
|
||||
* query can incorrectly report "none" even when ListContacts/Connections sees the relation.
|
||||
*/
|
||||
public String getEffectiveRelationType(Connection c, String ownerLogin, String targetLogin) throws SQLException {
|
||||
if (ownerLogin == null || ownerLogin.isBlank() || targetLogin == null || targetLogin.isBlank()) return "none";
|
||||
ConnectionsStateDAO relations = ConnectionsStateDAO.getInstance();
|
||||
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
|
||||
return "close_friend";
|
||||
}
|
||||
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_FRIEND)) {
|
||||
return "friend";
|
||||
}
|
||||
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CONTACT)) {
|
||||
return "contact";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
public List<RelationCard> listRelations(Connection c, String ownerLogin, String listType, int limit, int offset) throws SQLException {
|
||||
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 100 : limit, 500));
|
||||
int safeOffset = Math.max(0, offset);
|
||||
String where = switch (String.valueOf(listType)) {
|
||||
case "friends" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=14 AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(cs.login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)";
|
||||
case "close_friends" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=10";
|
||||
case "primary_received" -> "LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)";
|
||||
case "primary_given" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)";
|
||||
case "shine_received" -> "LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(cs.to_login)";
|
||||
case "shine_given" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(cs.to_login)";
|
||||
default -> throw new IllegalArgumentException("Unsupported listType: " + listType);
|
||||
};
|
||||
boolean received = "primary_received".equals(listType) || "shine_received".equals(listType);
|
||||
String personExpr = received ? "cs.login" : "cs.to_login";
|
||||
String sql = """
|
||||
SELECT DISTINCT ON (LOWER(%s))
|
||||
%s AS person_login,
|
||||
COALESCE(up.first_name,'') AS first_name,
|
||||
COALESCE(up.last_name,'') AS last_name,
|
||||
COALESCE(up.ava_ar,'') AS ava_ar,
|
||||
up.account_role, up.shine_status,
|
||||
EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=80
|
||||
AND shine_is_primary(x.login) AND shine_target_accepts_primary_confirmation(x.to_login)) AS primary_confirmed,
|
||||
EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=70
|
||||
AND shine_is_primary(x.login) AND shine_is_shining(x.login) AND shine_target_accepts_shine_confirmation(x.to_login)) AS shine_confirmed,
|
||||
CASE
|
||||
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=10) THEN 'close_friend'
|
||||
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=14) THEN 'friend'
|
||||
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=20) THEN 'contact'
|
||||
ELSE 'none' END AS relation_type,
|
||||
COALESCE(us.primary_confirmations_received_count,0) AS primary_confirmations_count,
|
||||
COALESCE(us.shine_confirmations_received_count,0) AS shine_confirmations_count
|
||||
FROM connections_state cs
|
||||
LEFT JOIN user_profile_state up ON LOWER(up.login)=LOWER(%s)
|
||||
LEFT JOIN user_stats_state us ON LOWER(us.login)=LOWER(%s)
|
||||
WHERE %s
|
||||
ORDER BY LOWER(%s), %s
|
||||
LIMIT ? OFFSET ?
|
||||
""".formatted(personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, where, personExpr, personExpr);
|
||||
List<RelationCard> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i=1;
|
||||
ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin);
|
||||
ps.setString(i++, ownerLogin);
|
||||
ps.setInt(i++, safeLimit); ps.setInt(i, safeOffset);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(new RelationCard(
|
||||
rs.getString("person_login"), rs.getString("first_name"), rs.getString("last_name"), rs.getString("ava_ar"),
|
||||
rs.getString("account_role"), rs.getString("shine_status"), rs.getBoolean("primary_confirmed"), rs.getBoolean("shine_confirmed"),
|
||||
rs.getString("relation_type"), rs.getInt("primary_confirmations_count"), rs.getInt("shine_confirmations_count")
|
||||
));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<ChannelCard> listPublicChannels(Connection c, String login, String mode, int limit, int offset) throws SQLException {
|
||||
int safeLimit=Math.max(1,Math.min(limit<=0?100:limit,500)), safeOffset=Math.max(0,offset);
|
||||
String sql;
|
||||
if ("owned".equals(mode)) {
|
||||
sql="""
|
||||
SELECT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
||||
FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(?) AND cn.channel_type_code=1
|
||||
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
|
||||
""";
|
||||
} else if ("following".equals(mode)) {
|
||||
sql="""
|
||||
SELECT DISTINCT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
||||
FROM connections_state cs JOIN channel_names_state cn ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
||||
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30 AND cn.channel_type_code=1
|
||||
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
|
||||
""";
|
||||
} else throw new IllegalArgumentException("Unsupported channel mode: "+mode);
|
||||
List<ChannelCard> out=new ArrayList<>();
|
||||
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,login);ps.setInt(2,safeLimit);ps.setInt(3,safeOffset);
|
||||
try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(new ChannelCard(rs.getString("owner_login"),rs.getString("slug"),rs.getString("display_name"),rs.getString("ava_ar"),rs.getString("owner_bch_name"),rs.getInt("channel_root_block_number"),rs.getString("root_hash")));}}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static ProfileCard map(ResultSet rs)throws SQLException{return new ProfileCard(rs.getString("login"),rs.getString("first_name"),rs.getString("last_name"),rs.getString("ava_ar"),rs.getString("account_role"),rs.getString("shine_status"));}
|
||||
public record ProfileCard(String login,String firstName,String lastName,String avatarAr,String accountRole,String shineStatus){}
|
||||
public record RelationCard(String login,String firstName,String lastName,String avatarAr,String accountRole,String shineStatus,boolean primaryConfirmed,boolean shineConfirmed,String relationType,int primaryConfirmationsCount,int shineConfirmationsCount){}
|
||||
public record ChannelCard(String ownerLogin,String slug,String displayName,String avatarAr,String ownerBlockchainName,int rootBlockNumber,String rootBlockHashHex){}
|
||||
}
|
||||
@@ -37,6 +37,10 @@ public class BlockEntry {
|
||||
|
||||
private byte[] blockHash;
|
||||
private byte[] blockSignature;
|
||||
private byte[] dataItemId;
|
||||
private boolean arweavePublishPending;
|
||||
private Long arweavePublishedAtMs;
|
||||
private String arweaveRootTxId;
|
||||
|
||||
private Integer editedByBlockNumber;
|
||||
|
||||
@@ -85,6 +89,15 @@ public class BlockEntry {
|
||||
public byte[] getBlockSignature() { return blockSignature; }
|
||||
public void setBlockSignature(byte[] blockSignature) { this.blockSignature = blockSignature; }
|
||||
|
||||
public byte[] getDataItemId() { return dataItemId; }
|
||||
public void setDataItemId(byte[] dataItemId) { this.dataItemId = dataItemId; }
|
||||
public boolean isArweavePublishPending() { return arweavePublishPending; }
|
||||
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
|
||||
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
|
||||
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
|
||||
public String getArweaveRootTxId() { return arweaveRootTxId; }
|
||||
public void setArweaveRootTxId(String arweaveRootTxId) { this.arweaveRootTxId = arweaveRootTxId; }
|
||||
|
||||
public Integer getEditedByBlockNumber() { return editedByBlockNumber; }
|
||||
public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; }
|
||||
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
BEGIN;
|
||||
|
||||
-- Add active FRIEND 14/15 and OFFICIAL_ACCOUNT confirmation 80/81 to connection state.
|
||||
-- Legacy 60/61 remain accepted at protocol level; this migration does not reinterpret existing rows.
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
resolved_login TEXT;
|
||||
positive_rel_type INTEGER;
|
||||
existed_before BOOLEAN;
|
||||
target_channel_type INTEGER;
|
||||
BEGIN
|
||||
IF NEW.msg_type <> 3 THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
|
||||
|
||||
IF NEW.msg_sub_type IN (10, 14, 20, 30, 40, 50, 52, 54, 60, 70, 74, 80) THEN
|
||||
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = NEW.msg_sub_type
|
||||
AND to_login = resolved_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||
)
|
||||
INTO existed_before;
|
||||
|
||||
IF NOT existed_before THEN
|
||||
IF NEW.msg_sub_type = 10 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
close_friends_count = user_stats_state.close_friends_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF NEW.msg_sub_type = 30 THEN
|
||||
IF NEW.to_block_number IS NULL THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF NEW.to_block_number = 0 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_users_count = user_stats_state.following_users_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSE
|
||||
SELECT cn.channel_type_code
|
||||
INTO target_channel_type
|
||||
FROM channel_names_state cn
|
||||
WHERE cn.owner_bch_name = NEW.to_bch_name
|
||||
AND cn.channel_root_block_number = NEW.to_block_number
|
||||
AND cn.channel_root_block_hash = NEW.to_block_hash
|
||||
LIMIT 1;
|
||||
|
||||
IF target_channel_type = 1 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
owner_login,
|
||||
channel_type_code,
|
||||
subscribers_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.to_bch_name,
|
||||
NEW.to_block_number,
|
||||
NEW.to_block_hash,
|
||||
resolved_login,
|
||||
target_channel_type,
|
||||
1,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
channel_type_code = EXCLUDED.channel_type_code,
|
||||
subscribers_count = channel_stats_state.subscribers_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF target_channel_type IS NULL THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
DELETE FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = NEW.msg_sub_type
|
||||
AND to_login = resolved_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
|
||||
|
||||
INSERT INTO connections_state (
|
||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
NEW.msg_sub_type,
|
||||
resolved_login,
|
||||
NEW.to_bch_name,
|
||||
COALESCE(NEW.to_block_number, 0),
|
||||
COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
positive_rel_type := CASE NEW.msg_sub_type
|
||||
WHEN 11 THEN 10
|
||||
WHEN 15 THEN 14
|
||||
WHEN 21 THEN 20
|
||||
WHEN 31 THEN 30
|
||||
WHEN 41 THEN 40
|
||||
WHEN 51 THEN 50
|
||||
WHEN 53 THEN 52
|
||||
WHEN 55 THEN 54
|
||||
WHEN 61 THEN 60
|
||||
WHEN 71 THEN 70
|
||||
WHEN 75 THEN 74
|
||||
WHEN 81 THEN 80
|
||||
ELSE NULL
|
||||
END;
|
||||
|
||||
IF positive_rel_type IS NULL OR resolved_login IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = positive_rel_type
|
||||
AND to_login = resolved_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||
)
|
||||
INTO existed_before;
|
||||
|
||||
IF NOT existed_before THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF positive_rel_type = 10 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF positive_rel_type = 30 THEN
|
||||
IF NEW.to_block_number IS NULL THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF NEW.to_block_number = 0 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSE
|
||||
SELECT cn.channel_type_code
|
||||
INTO target_channel_type
|
||||
FROM channel_names_state cn
|
||||
WHERE cn.owner_bch_name = NEW.to_bch_name
|
||||
AND cn.channel_root_block_number = NEW.to_block_number
|
||||
AND cn.channel_root_block_hash = NEW.to_block_hash
|
||||
LIMIT 1;
|
||||
|
||||
IF target_channel_type = 1 THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
INSERT INTO channel_stats_state (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
owner_login,
|
||||
channel_type_code,
|
||||
subscribers_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.to_bch_name,
|
||||
NEW.to_block_number,
|
||||
NEW.to_block_hash,
|
||||
resolved_login,
|
||||
target_channel_type,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
channel_type_code = EXCLUDED.channel_type_code,
|
||||
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
ELSIF target_channel_type IS NULL THEN
|
||||
INSERT INTO user_stats_state (
|
||||
login,
|
||||
owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
NEW.login,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
DELETE FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = positive_rel_type
|
||||
AND to_login = resolved_login
|
||||
AND to_bch_name = NEW.to_bch_name
|
||||
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 17, 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;
|
||||
@@ -0,0 +1,348 @@
|
||||
BEGIN;
|
||||
|
||||
-- v18: fast profile projection, effective social/voting statistics and qualified likes.
|
||||
-- Source of truth remains users_params / connections_state / reactions_state / channel_names_state.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profile_state (
|
||||
login TEXT PRIMARY KEY,
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
ava_ar TEXT NOT NULL DEFAULT '',
|
||||
account_role TEXT,
|
||||
shine_status TEXT,
|
||||
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profile_state_name
|
||||
ON user_profile_state (LOWER(last_name), LOWER(first_name), LOWER(login));
|
||||
|
||||
ALTER TABLE user_stats_state
|
||||
ADD COLUMN IF NOT EXISTS friends_count INTEGER NOT NULL DEFAULT 0 CHECK (friends_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS primary_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_received_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS primary_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_given_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS shine_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_received_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS shine_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_given_count >= 0);
|
||||
|
||||
ALTER TABLE message_stats
|
||||
ADD COLUMN IF NOT EXISTS primary_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_likes_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS shining_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (shining_likes_count >= 0);
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_user_profile(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_first_name TEXT := '';
|
||||
v_last_name TEXT := '';
|
||||
v_ava_ar TEXT := '';
|
||||
v_account_role TEXT := NULL;
|
||||
v_shine TEXT := NULL;
|
||||
v_updated BIGINT := 0;
|
||||
BEGIN
|
||||
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||
|
||||
SELECT COALESCE(MAX(value) FILTER (WHERE param = 'first_name'), ''),
|
||||
COALESCE(MAX(value) FILTER (WHERE param = 'last_name'), ''),
|
||||
COALESCE(MAX(value) FILTER (WHERE param = 'ava'), ''),
|
||||
MAX(value) FILTER (WHERE param = 'account_role'),
|
||||
MAX(value) FILTER (WHERE param = 'shine'),
|
||||
COALESCE(MAX(time_ms), 0)
|
||||
INTO v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated
|
||||
FROM users_params
|
||||
WHERE LOWER(login) = LOWER(p_login);
|
||||
|
||||
v_account_role := LOWER(BTRIM(COALESCE(v_account_role, '')));
|
||||
IF v_account_role NOT IN ('primary', 'non_voting') THEN v_account_role := NULL; END IF;
|
||||
|
||||
v_shine := LOWER(BTRIM(COALESCE(v_shine, '')));
|
||||
IF v_shine = 'yes' THEN v_shine := 'shining'; END IF; -- legacy compatibility
|
||||
IF v_shine NOT IN ('shining', 'unknown', 'not_interested') THEN v_shine := NULL; END IF;
|
||||
|
||||
INSERT INTO user_profile_state(login, first_name, last_name, ava_ar, account_role, shine_status, updated_at_ms)
|
||||
VALUES (p_login, v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
ava_ar = EXCLUDED.ava_ar,
|
||||
account_role = EXCLUDED.account_role,
|
||||
shine_status = EXCLUDED.shine_status,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_is_primary(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT account_role = 'primary' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_is_shining(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT shine_status = 'shining' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_target_accepts_primary_confirmation(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT account_role IS DISTINCT FROM 'non_voting' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_target_accepts_shine_confirmation(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT shine_status IS DISTINCT FROM 'not_interested' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
BEGIN
|
||||
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||
|
||||
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
|
||||
following_channels_count, close_friends_count, friends_count,
|
||||
primary_confirmations_received_count, primary_confirmations_given_count,
|
||||
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
|
||||
SELECT p_login,
|
||||
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
|
||||
0,
|
||||
(SELECT COUNT(*)::INT FROM connections_state cs JOIN channel_names_state cn
|
||||
ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
|
||||
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
|
||||
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
|
||||
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
|
||||
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
|
||||
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
|
||||
v_now
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
|
||||
following_users_count=0,
|
||||
following_channels_count=EXCLUDED.following_channels_count,
|
||||
close_friends_count=EXCLUDED.close_friends_count,
|
||||
friends_count=EXCLUDED.friends_count,
|
||||
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
|
||||
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
|
||||
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
|
||||
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
|
||||
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
PERFORM shine_refresh_user_stats(p_login);
|
||||
FOR r IN
|
||||
SELECT DISTINCT x.login FROM (
|
||||
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||
UNION
|
||||
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||
) x
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_stats(r.login);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_message_qualified_likes_for_actor(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT DISTINCT rs.to_login, rs.to_bch_name, rs.to_block_number, rs.to_block_hash
|
||||
FROM reactions_state rs
|
||||
WHERE LOWER(rs.from_login)=LOWER(p_login) AND rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
LOOP
|
||||
UPDATE message_stats ms SET
|
||||
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
|
||||
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
|
||||
AND shine_is_primary(x.from_login)),
|
||||
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
|
||||
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
|
||||
AND shine_is_primary(x.from_login) AND shine_is_shining(x.from_login))
|
||||
WHERE ms.to_login=r.to_login AND ms.to_bch_name=r.to_bch_name AND ms.to_block_number=r.to_block_number AND ms.to_block_hash=r.to_block_hash;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_channel_follow_stats(p_bch_name TEXT, p_block_number INTEGER, p_block_hash BYTEA)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_owner_login TEXT;
|
||||
v_channel_type INTEGER;
|
||||
v_count INTEGER := 0;
|
||||
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
BEGIN
|
||||
IF p_bch_name IS NULL OR p_block_number IS NULL OR p_block_hash IS NULL OR p_block_number <= 0 THEN RETURN; END IF;
|
||||
|
||||
SELECT cn.owner_login, cn.channel_type_code
|
||||
INTO v_owner_login, v_channel_type
|
||||
FROM channel_names_state cn
|
||||
WHERE cn.owner_bch_name=p_bch_name
|
||||
AND cn.channel_root_block_number=p_block_number
|
||||
AND cn.channel_root_block_hash=p_block_hash
|
||||
LIMIT 1;
|
||||
|
||||
IF v_channel_type IS NULL THEN RETURN; END IF;
|
||||
|
||||
SELECT COUNT(DISTINCT LOWER(cs.login))::INT
|
||||
INTO v_count
|
||||
FROM connections_state cs
|
||||
WHERE cs.rel_type=30
|
||||
AND cs.to_bch_name=p_bch_name
|
||||
AND cs.to_block_number=p_block_number
|
||||
AND cs.to_block_hash=p_block_hash;
|
||||
|
||||
INSERT INTO channel_stats_state(owner_bch_name,channel_root_block_number,channel_root_block_hash,owner_login,channel_type_code,subscribers_count,updated_at_ms)
|
||||
VALUES(p_bch_name,p_block_number,p_block_hash,COALESCE(v_owner_login,''),v_channel_type,v_count,v_now)
|
||||
ON CONFLICT(owner_bch_name,channel_root_block_number,channel_root_block_hash) DO UPDATE SET
|
||||
owner_login=EXCLUDED.owner_login,
|
||||
channel_type_code=EXCLUDED.channel_type_code,
|
||||
subscribers_count=EXCLUDED.subscribers_count,
|
||||
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Replace incremental channel-name counter with idempotent recomputation.
|
||||
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.channel_type_code = 1 THEN
|
||||
PERFORM shine_refresh_user_stats(NEW.owner_login);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Rebuild connection state without incremental user counters; then recompute affected users.
|
||||
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
resolved_login TEXT;
|
||||
positive_rel_type INTEGER;
|
||||
BEGIN
|
||||
IF NEW.msg_type <> 3 THEN RETURN NEW; END IF;
|
||||
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
|
||||
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN RETURN NEW; END IF;
|
||||
|
||||
IF NEW.msg_sub_type IN (10,14,20,30,40,50,52,54,60,70,74,80) THEN
|
||||
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=NEW.msg_sub_type AND to_login=resolved_login
|
||||
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
|
||||
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
|
||||
INSERT INTO connections_state(login,rel_type,to_login,to_bch_name,to_block_number,to_block_hash)
|
||||
VALUES(NEW.login,NEW.msg_sub_type,resolved_login,NEW.to_bch_name,COALESCE(NEW.to_block_number,0),COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex')));
|
||||
ELSE
|
||||
positive_rel_type := CASE NEW.msg_sub_type WHEN 11 THEN 10 WHEN 15 THEN 14 WHEN 21 THEN 20 WHEN 31 THEN 30 WHEN 41 THEN 40
|
||||
WHEN 51 THEN 50 WHEN 53 THEN 52 WHEN 55 THEN 54 WHEN 61 THEN 60 WHEN 71 THEN 70 WHEN 75 THEN 74 WHEN 81 THEN 80 ELSE NULL END;
|
||||
IF positive_rel_type IS NULL THEN RETURN NEW; END IF;
|
||||
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=positive_rel_type AND to_login=resolved_login
|
||||
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
|
||||
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
|
||||
END IF;
|
||||
|
||||
PERFORM shine_refresh_user_stats(NEW.login);
|
||||
PERFORM shine_refresh_user_stats(resolved_login);
|
||||
IF NEW.msg_sub_type IN (30,31) AND NEW.to_block_number IS NOT NULL AND NEW.to_block_number > 0 AND NEW.to_block_hash IS NOT NULL THEN
|
||||
PERFORM shine_refresh_channel_follow_stats(NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE previous_sub_type INTEGER;
|
||||
BEGIN
|
||||
IF NEW.msg_type<>2 OR NEW.msg_sub_type NOT IN (1,2) THEN RETURN NEW; END IF;
|
||||
IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
|
||||
|
||||
INSERT INTO message_stats(to_login,to_bch_name,to_block_number,to_block_hash,likes_count,primary_likes_count,shining_likes_count,replies_count,edits_count)
|
||||
VALUES(NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,0,0,0,0,0)
|
||||
ON CONFLICT(to_login,to_bch_name,to_block_number,to_block_hash) DO NOTHING;
|
||||
|
||||
SELECT last_sub_type INTO previous_sub_type FROM reactions_state WHERE from_login=NEW.login AND from_bch_name=NEW.bch_name AND reaction_type=1
|
||||
AND to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash LIMIT 1;
|
||||
|
||||
IF NEW.msg_sub_type=1 AND previous_sub_type IS DISTINCT FROM 1 THEN
|
||||
UPDATE message_stats SET likes_count=likes_count+1,
|
||||
primary_likes_count=primary_likes_count + CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END,
|
||||
shining_likes_count=shining_likes_count + CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END
|
||||
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
|
||||
ELSIF NEW.msg_sub_type=2 AND previous_sub_type=1 THEN
|
||||
UPDATE message_stats SET likes_count=GREATEST(0,likes_count-1),
|
||||
primary_likes_count=GREATEST(0,primary_likes_count - CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END),
|
||||
shining_likes_count=GREATEST(0,shining_likes_count - CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END)
|
||||
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
|
||||
END IF;
|
||||
|
||||
INSERT INTO reactions_state(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash,last_sub_type)
|
||||
VALUES(NEW.login,NEW.bch_name,1,NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,NEW.msg_sub_type)
|
||||
ON CONFLICT(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash) DO UPDATE SET last_sub_type=EXCLUDED.last_sub_type;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Initial projection/rebuild.
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT DISTINCT login FROM users_params LOOP PERFORM shine_refresh_user_profile(r.login); END LOOP;
|
||||
END $$;
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT login FROM solana_user_pda_current LOOP PERFORM shine_refresh_user_stats(r.login); END LOOP;
|
||||
END $$;
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT owner_bch_name, channel_root_block_number, channel_root_block_hash FROM channel_names_state WHERE channel_type_code=1 LOOP
|
||||
PERFORM shine_refresh_channel_follow_stats(r.owner_bch_name, r.channel_root_block_number, r.channel_root_block_hash);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
UPDATE message_stats ms SET
|
||||
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||
AND shine_is_primary(rs.from_login)),
|
||||
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login));
|
||||
|
||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||
VALUES(1,18,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;
|
||||
@@ -0,0 +1,83 @@
|
||||
BEGIN;
|
||||
|
||||
-- v19: skip user_stats refresh for logins that are absent from solana_user_pda_current.
|
||||
-- This keeps bootstrap resilient to legacy rows that are still present in blocks.
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
BEGIN
|
||||
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM solana_user_pda_current
|
||||
WHERE login = p_login
|
||||
LIMIT 1
|
||||
) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
|
||||
following_channels_count, close_friends_count, friends_count,
|
||||
primary_confirmations_received_count, primary_confirmations_given_count,
|
||||
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
|
||||
SELECT p_login,
|
||||
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
|
||||
0,
|
||||
(SELECT COUNT(*)::INT FROM connections_state cs JOIN channel_names_state cn
|
||||
ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
|
||||
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
|
||||
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
|
||||
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
|
||||
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
|
||||
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
|
||||
v_now
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
|
||||
following_users_count=0,
|
||||
following_channels_count=EXCLUDED.following_channels_count,
|
||||
close_friends_count=EXCLUDED.close_friends_count,
|
||||
friends_count=EXCLUDED.friends_count,
|
||||
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
|
||||
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
|
||||
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
|
||||
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
|
||||
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
PERFORM shine_refresh_user_stats(p_login);
|
||||
FOR r IN
|
||||
SELECT DISTINCT x.login FROM (
|
||||
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||
UNION
|
||||
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||
) x
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_stats(r.login);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Notifications v2: three categories + signed seen watermarks.
|
||||
|
||||
ALTER TABLE user_notifications_state
|
||||
DROP CONSTRAINT IF EXISTS user_notifications_state_notification_kind_check;
|
||||
ALTER TABLE user_notifications_state
|
||||
ADD CONSTRAINT user_notifications_state_notification_kind_check
|
||||
CHECK (notification_kind IN ('reply', 'connection', 'event'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||
owner_login VARCHAR(60) NOT NULL,
|
||||
category TEXT NOT NULL CHECK (category IN ('replies', 'connections', 'events')),
|
||||
seen_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
signed_blob BYTEA NOT NULL,
|
||||
signed_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, category)
|
||||
);
|
||||
|
||||
UPDATE db_schema_version SET schema_version = 20 WHERE id = 1;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Frame v1 intentionally has no backward compatibility with the old signed block format.
|
||||
-- Refuse to migrate a populated blockchain database: test/dev data must be reset first.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM blocks LIMIT 1)
|
||||
OR EXISTS (SELECT 1 FROM blockchain_state WHERE last_block_number >= 0 LIMIT 1) THEN
|
||||
RAISE EXCEPTION 'migration_v24 requires an empty blockchain database; reset test/dev blockchain data before upgrading';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Старый SHINE-ARCHIVE v1 больше не используется.
|
||||
DROP TABLE IF EXISTS archive_publish_job_chain CASCADE;
|
||||
DROP TABLE IF EXISTS archive_publish_job CASCADE;
|
||||
DROP TABLE IF EXISTS archive_chain_cursor CASCADE;
|
||||
DROP TABLE IF EXISTS archive_blockchain_location CASCADE;
|
||||
|
||||
-- SHiNE Frame v1 / ANS-104 DataItem storage. No legacy .bch files are required.
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS data_item_id BYTEA;
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS arweave_published_at_ms BIGINT;
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS arweave_root_tx_id TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
|
||||
ON blocks(data_item_id)
|
||||
WHERE data_item_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
|
||||
ON blocks(block_number)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
|
||||
id SMALLINT PRIMARY KEY CHECK (id = 1),
|
||||
last_block_height BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
|
||||
VALUES(1,0,0) ON CONFLICT(id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
|
||||
data_item_id BYTEA PRIMARY KEY,
|
||||
root_tx_id TEXT NOT NULL,
|
||||
block_height BIGINT NOT NULL,
|
||||
raw_data_item BYTEA NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
first_seen_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
|
||||
ON arweave_block_import_queue(block_height,first_seen_at_ms)
|
||||
WHERE status='PENDING';
|
||||
|
||||
UPDATE db_schema_version SET schema_version=24, updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id=1;
|
||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
||||
);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 16, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
VALUES (1, 24, 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;
|
||||
@@ -291,12 +291,6 @@ AFTER TRUNCATE ON solana_user_pda_current
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
|
||||
CREATE TRIGGER trg_user_stats_state_ai
|
||||
AFTER INSERT ON solana_user_pda_current
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_user_stats_state_ai();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS solana_user_pda_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tx_signature TEXT NOT NULL,
|
||||
@@ -425,6 +419,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,
|
||||
@@ -483,6 +497,10 @@ CREATE TABLE IF NOT EXISTS blocks (
|
||||
to_block_hash BYTEA,
|
||||
block_hash BYTEA NOT NULL,
|
||||
block_signature BYTEA NOT NULL,
|
||||
data_item_id BYTEA NOT NULL,
|
||||
arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
arweave_published_at_ms BIGINT,
|
||||
arweave_root_tx_id TEXT,
|
||||
edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0),
|
||||
line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0),
|
||||
prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0),
|
||||
@@ -494,12 +512,41 @@ CREATE TABLE IF NOT EXISTS blocks (
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number
|
||||
ON blocks (bch_name, block_number);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
|
||||
ON blocks(data_item_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
|
||||
ON blocks(block_number)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_to_target
|
||||
ON blocks (to_login, to_bch_name, to_block_number);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_line
|
||||
ON blocks (bch_name, line_code, this_line_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
|
||||
id SMALLINT PRIMARY KEY CHECK (id = 1),
|
||||
last_block_height BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO arweave_block_sync_state(id, last_block_height, updated_at_ms)
|
||||
VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
|
||||
data_item_id BYTEA PRIMARY KEY,
|
||||
root_tx_id TEXT NOT NULL,
|
||||
block_height BIGINT NOT NULL,
|
||||
raw_data_item BYTEA NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
first_seen_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
|
||||
ON arweave_block_import_queue(block_height,first_seen_at_ms)
|
||||
WHERE status='PENDING';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connections_state (
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
rel_type INTEGER NOT NULL,
|
||||
@@ -765,7 +812,7 @@ CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
||||
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
|
||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection', 'event')),
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
source_login TEXT NOT NULL,
|
||||
source_bch_name TEXT NOT NULL,
|
||||
@@ -1113,7 +1160,7 @@ BEGIN
|
||||
|
||||
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
|
||||
|
||||
IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN
|
||||
IF NEW.msg_sub_type IN (10, 14, 20, 30, 40, 50, 52, 54, 60, 70, 74, 80) THEN
|
||||
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
@@ -1288,6 +1335,7 @@ BEGIN
|
||||
|
||||
positive_rel_type := CASE NEW.msg_sub_type
|
||||
WHEN 11 THEN 10
|
||||
WHEN 15 THEN 14
|
||||
WHEN 21 THEN 20
|
||||
WHEN 31 THEN 30
|
||||
WHEN 41 THEN 40
|
||||
@@ -1297,6 +1345,7 @@ BEGIN
|
||||
WHEN 61 THEN 60
|
||||
WHEN 71 THEN 70
|
||||
WHEN 75 THEN 74
|
||||
WHEN 81 THEN 80
|
||||
ELSE NULL
|
||||
END;
|
||||
|
||||
@@ -1638,10 +1687,401 @@ AFTER INSERT ON blocks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_blocks_edit_apply_ai();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
|
||||
CREATE TRIGGER trg_user_stats_state_ai
|
||||
AFTER INSERT ON solana_user_pda_current
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_user_stats_state_ai();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
|
||||
CREATE TRIGGER trg_channel_names_state_stats_ai
|
||||
AFTER INSERT ON channel_names_state
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
|
||||
|
||||
|
||||
-- v18 integrated fresh-schema definitions
|
||||
-- v18: fast profile projection, effective social/voting statistics and qualified likes.
|
||||
-- Source of truth remains users_params / connections_state / reactions_state / channel_names_state.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profile_state (
|
||||
login TEXT PRIMARY KEY,
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
ava_ar TEXT NOT NULL DEFAULT '',
|
||||
account_role TEXT,
|
||||
shine_status TEXT,
|
||||
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_profile_state_name
|
||||
ON user_profile_state (LOWER(last_name), LOWER(first_name), LOWER(login));
|
||||
|
||||
ALTER TABLE user_stats_state
|
||||
ADD COLUMN IF NOT EXISTS friends_count INTEGER NOT NULL DEFAULT 0 CHECK (friends_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS primary_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_received_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS primary_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_given_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS shine_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_received_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS shine_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_given_count >= 0);
|
||||
|
||||
ALTER TABLE message_stats
|
||||
ADD COLUMN IF NOT EXISTS primary_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_likes_count >= 0),
|
||||
ADD COLUMN IF NOT EXISTS shining_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (shining_likes_count >= 0);
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_user_profile(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_first_name TEXT := '';
|
||||
v_last_name TEXT := '';
|
||||
v_ava_ar TEXT := '';
|
||||
v_account_role TEXT := NULL;
|
||||
v_shine TEXT := NULL;
|
||||
v_updated BIGINT := 0;
|
||||
BEGIN
|
||||
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||
|
||||
SELECT COALESCE(MAX(value) FILTER (WHERE param = 'first_name'), ''),
|
||||
COALESCE(MAX(value) FILTER (WHERE param = 'last_name'), ''),
|
||||
COALESCE(MAX(value) FILTER (WHERE param = 'ava'), ''),
|
||||
MAX(value) FILTER (WHERE param = 'account_role'),
|
||||
MAX(value) FILTER (WHERE param = 'shine'),
|
||||
COALESCE(MAX(time_ms), 0)
|
||||
INTO v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated
|
||||
FROM users_params
|
||||
WHERE LOWER(login) = LOWER(p_login);
|
||||
|
||||
v_account_role := LOWER(BTRIM(COALESCE(v_account_role, '')));
|
||||
IF v_account_role NOT IN ('primary', 'non_voting') THEN v_account_role := NULL; END IF;
|
||||
|
||||
v_shine := LOWER(BTRIM(COALESCE(v_shine, '')));
|
||||
IF v_shine = 'yes' THEN v_shine := 'shining'; END IF; -- legacy compatibility
|
||||
IF v_shine NOT IN ('shining', 'unknown', 'not_interested') THEN v_shine := NULL; END IF;
|
||||
|
||||
INSERT INTO user_profile_state(login, first_name, last_name, ava_ar, account_role, shine_status, updated_at_ms)
|
||||
VALUES (p_login, v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated)
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
ava_ar = EXCLUDED.ava_ar,
|
||||
account_role = EXCLUDED.account_role,
|
||||
shine_status = EXCLUDED.shine_status,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_is_primary(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT account_role = 'primary' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_is_shining(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT shine_status = 'shining' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_target_accepts_primary_confirmation(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT account_role IS DISTINCT FROM 'non_voting' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_target_accepts_shine_confirmation(p_login TEXT)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT shine_status IS DISTINCT FROM 'not_interested' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
BEGIN
|
||||
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM solana_user_pda_current
|
||||
WHERE login = p_login
|
||||
LIMIT 1
|
||||
) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
|
||||
following_channels_count, close_friends_count, friends_count,
|
||||
primary_confirmations_received_count, primary_confirmations_given_count,
|
||||
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
|
||||
SELECT p_login,
|
||||
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
|
||||
0,
|
||||
(SELECT COUNT(*)::INT FROM connections_state cs JOIN channel_names_state cn
|
||||
ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
|
||||
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
|
||||
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
|
||||
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
|
||||
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
|
||||
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
|
||||
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
|
||||
v_now
|
||||
ON CONFLICT (login) DO UPDATE SET
|
||||
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
|
||||
following_users_count=0,
|
||||
following_channels_count=EXCLUDED.following_channels_count,
|
||||
close_friends_count=EXCLUDED.close_friends_count,
|
||||
friends_count=EXCLUDED.friends_count,
|
||||
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
|
||||
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
|
||||
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
|
||||
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
|
||||
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
PERFORM shine_refresh_user_stats(p_login);
|
||||
FOR r IN
|
||||
SELECT DISTINCT x.login FROM (
|
||||
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||
UNION
|
||||
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||
) x
|
||||
LOOP
|
||||
PERFORM shine_refresh_user_stats(r.login);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_message_qualified_likes_for_actor(p_login TEXT)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT DISTINCT rs.to_login, rs.to_bch_name, rs.to_block_number, rs.to_block_hash
|
||||
FROM reactions_state rs
|
||||
WHERE LOWER(rs.from_login)=LOWER(p_login) AND rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
LOOP
|
||||
UPDATE message_stats ms SET
|
||||
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
|
||||
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
|
||||
AND shine_is_primary(x.from_login)),
|
||||
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
|
||||
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
|
||||
AND shine_is_primary(x.from_login) AND shine_is_shining(x.from_login))
|
||||
WHERE ms.to_login=r.to_login AND ms.to_bch_name=r.to_bch_name AND ms.to_block_number=r.to_block_number AND ms.to_block_hash=r.to_block_hash;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_refresh_channel_follow_stats(p_bch_name TEXT, p_block_number INTEGER, p_block_hash BYTEA)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_owner_login TEXT;
|
||||
v_channel_type INTEGER;
|
||||
v_count INTEGER := 0;
|
||||
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||
BEGIN
|
||||
IF p_bch_name IS NULL OR p_block_number IS NULL OR p_block_hash IS NULL OR p_block_number <= 0 THEN RETURN; END IF;
|
||||
|
||||
SELECT cn.owner_login, cn.channel_type_code
|
||||
INTO v_owner_login, v_channel_type
|
||||
FROM channel_names_state cn
|
||||
WHERE cn.owner_bch_name=p_bch_name
|
||||
AND cn.channel_root_block_number=p_block_number
|
||||
AND cn.channel_root_block_hash=p_block_hash
|
||||
LIMIT 1;
|
||||
|
||||
IF v_channel_type IS NULL THEN RETURN; END IF;
|
||||
|
||||
SELECT COUNT(DISTINCT LOWER(cs.login))::INT
|
||||
INTO v_count
|
||||
FROM connections_state cs
|
||||
WHERE cs.rel_type=30
|
||||
AND cs.to_bch_name=p_bch_name
|
||||
AND cs.to_block_number=p_block_number
|
||||
AND cs.to_block_hash=p_block_hash;
|
||||
|
||||
INSERT INTO channel_stats_state(owner_bch_name,channel_root_block_number,channel_root_block_hash,owner_login,channel_type_code,subscribers_count,updated_at_ms)
|
||||
VALUES(p_bch_name,p_block_number,p_block_hash,COALESCE(v_owner_login,''),v_channel_type,v_count,v_now)
|
||||
ON CONFLICT(owner_bch_name,channel_root_block_number,channel_root_block_hash) DO UPDATE SET
|
||||
owner_login=EXCLUDED.owner_login,
|
||||
channel_type_code=EXCLUDED.channel_type_code,
|
||||
subscribers_count=EXCLUDED.subscribers_count,
|
||||
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Replace incremental channel-name counter with idempotent recomputation.
|
||||
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.channel_type_code = 1 THEN
|
||||
PERFORM shine_refresh_user_stats(NEW.owner_login);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Rebuild connection state without incremental user counters; then recompute affected users.
|
||||
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
resolved_login TEXT;
|
||||
positive_rel_type INTEGER;
|
||||
BEGIN
|
||||
IF NEW.msg_type <> 3 THEN RETURN NEW; END IF;
|
||||
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
|
||||
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN RETURN NEW; END IF;
|
||||
|
||||
IF NEW.msg_sub_type IN (10,14,20,30,40,50,52,54,60,70,74,80) THEN
|
||||
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=NEW.msg_sub_type AND to_login=resolved_login
|
||||
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
|
||||
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
|
||||
INSERT INTO connections_state(login,rel_type,to_login,to_bch_name,to_block_number,to_block_hash)
|
||||
VALUES(NEW.login,NEW.msg_sub_type,resolved_login,NEW.to_bch_name,COALESCE(NEW.to_block_number,0),COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex')));
|
||||
ELSE
|
||||
positive_rel_type := CASE NEW.msg_sub_type WHEN 11 THEN 10 WHEN 15 THEN 14 WHEN 21 THEN 20 WHEN 31 THEN 30 WHEN 41 THEN 40
|
||||
WHEN 51 THEN 50 WHEN 53 THEN 52 WHEN 55 THEN 54 WHEN 61 THEN 60 WHEN 71 THEN 70 WHEN 75 THEN 74 WHEN 81 THEN 80 ELSE NULL END;
|
||||
IF positive_rel_type IS NULL THEN RETURN NEW; END IF;
|
||||
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=positive_rel_type AND to_login=resolved_login
|
||||
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
|
||||
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
|
||||
END IF;
|
||||
|
||||
PERFORM shine_refresh_user_stats(NEW.login);
|
||||
PERFORM shine_refresh_user_stats(resolved_login);
|
||||
IF NEW.msg_sub_type IN (30,31) AND NEW.to_block_number IS NOT NULL AND NEW.to_block_number > 0 AND NEW.to_block_hash IS NOT NULL THEN
|
||||
PERFORM shine_refresh_channel_follow_stats(NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE previous_sub_type INTEGER;
|
||||
BEGIN
|
||||
IF NEW.msg_type<>2 OR NEW.msg_sub_type NOT IN (1,2) THEN RETURN NEW; END IF;
|
||||
IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
|
||||
|
||||
INSERT INTO message_stats(to_login,to_bch_name,to_block_number,to_block_hash,likes_count,primary_likes_count,shining_likes_count,replies_count,edits_count)
|
||||
VALUES(NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,0,0,0,0,0)
|
||||
ON CONFLICT(to_login,to_bch_name,to_block_number,to_block_hash) DO NOTHING;
|
||||
|
||||
SELECT last_sub_type INTO previous_sub_type FROM reactions_state WHERE from_login=NEW.login AND from_bch_name=NEW.bch_name AND reaction_type=1
|
||||
AND to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash LIMIT 1;
|
||||
|
||||
IF NEW.msg_sub_type=1 AND previous_sub_type IS DISTINCT FROM 1 THEN
|
||||
UPDATE message_stats SET likes_count=likes_count+1,
|
||||
primary_likes_count=primary_likes_count + CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END,
|
||||
shining_likes_count=shining_likes_count + CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END
|
||||
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
|
||||
ELSIF NEW.msg_sub_type=2 AND previous_sub_type=1 THEN
|
||||
UPDATE message_stats SET likes_count=GREATEST(0,likes_count-1),
|
||||
primary_likes_count=GREATEST(0,primary_likes_count - CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END),
|
||||
shining_likes_count=GREATEST(0,shining_likes_count - CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END)
|
||||
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
|
||||
END IF;
|
||||
|
||||
INSERT INTO reactions_state(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash,last_sub_type)
|
||||
VALUES(NEW.login,NEW.bch_name,1,NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,NEW.msg_sub_type)
|
||||
ON CONFLICT(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash) DO UPDATE SET last_sub_type=EXCLUDED.last_sub_type;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Initial projection/rebuild.
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT DISTINCT login FROM users_params LOOP PERFORM shine_refresh_user_profile(r.login); END LOOP;
|
||||
END $$;
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT login FROM solana_user_pda_current LOOP PERFORM shine_refresh_user_stats(r.login); END LOOP;
|
||||
END $$;
|
||||
DO $$ DECLARE r RECORD; BEGIN
|
||||
FOR r IN SELECT owner_bch_name, channel_root_block_number, channel_root_block_hash FROM channel_names_state WHERE channel_type_code=1 LOOP
|
||||
PERFORM shine_refresh_channel_follow_stats(r.owner_bch_name, r.channel_root_block_number, r.channel_root_block_hash);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
UPDATE message_stats ms SET
|
||||
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||
AND shine_is_primary(rs.from_login)),
|
||||
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login));
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||
owner_login VARCHAR(60) NOT NULL,
|
||||
category TEXT NOT NULL CHECK (category IN ('replies', 'connections', 'events')),
|
||||
seen_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
signed_blob BYTEA NOT NULL,
|
||||
signed_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, category)
|
||||
);
|
||||
|
||||
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 <> '';
|
||||
|
||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||
VALUES(1,24,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;
|
||||
|
||||
+24
-8
@@ -46,11 +46,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_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,26 +71,38 @@ 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;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfileRelations_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfileChannels_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.Net_SetNotificationState_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_SetNotificationState_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||
@@ -153,8 +161,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()),
|
||||
@@ -202,13 +208,19 @@ 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()),
|
||||
Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()),
|
||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
||||
Map.entry("SetNotificationState", new Net_SetNotificationState_Handler()),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||
@@ -241,8 +253,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),
|
||||
@@ -290,13 +300,19 @@ 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),
|
||||
Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class),
|
||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
||||
Map.entry("SetNotificationState", Net_SetNotificationState_Request.class),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||
|
||||
+5
@@ -28,6 +28,7 @@ public final class SolanaUserPdaImportService {
|
||||
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||
private static final String MAGIC = "SHiNE";
|
||||
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 1;
|
||||
private static final int ARCHIVE_HEAD_PAYLOAD_BYTES = 64;
|
||||
|
||||
private SolanaUserPdaImportService() {}
|
||||
|
||||
@@ -266,6 +267,8 @@ public final class SolanaUserPdaImportService {
|
||||
}
|
||||
} else if (blockType == 70) {
|
||||
c += 1;
|
||||
} else if (blockType == 100) {
|
||||
c += ARCHIVE_HEAD_PAYLOAD_BYTES;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -374,6 +377,8 @@ public final class SolanaUserPdaImportService {
|
||||
}
|
||||
} else if (blockType == 70) {
|
||||
c += 1;
|
||||
} else if (blockType == 100) {
|
||||
c += ARCHIVE_HEAD_PAYLOAD_BYTES;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
+150
-12
@@ -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,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
blockchainName,
|
||||
req.getBlockNumber(), // старое поле, пока оставляем
|
||||
req.getPrevBlockHash(), // старое поле, пока оставляем
|
||||
req.getBlockBytesB64()
|
||||
req.getBlockBytesB64(),
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
// ✅ УСПЕХ: как раньше
|
||||
@@ -160,6 +164,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "limit_exceeded" -> "Превышен лимит размера блокчейна";
|
||||
case "limit_check_failed" -> "Ошибка проверки лимита размера";
|
||||
case "bad_block_format" -> "Некорректный формат блока";
|
||||
case "bad_app_tag" -> "Некорректный тестовый тег ANS-104";
|
||||
case "bad_channel_tag" -> "Некорректный тег канала ANS-104";
|
||||
case "bad_block_body" -> "Некорректное тело блока";
|
||||
case "bad_channel_name" -> "Некорректное название канала";
|
||||
case "bad_block_number" -> "Некорректный номер блока";
|
||||
@@ -186,7 +192,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
String blockchainName,
|
||||
int globalNumberFromReq,
|
||||
String prevGlobalHashHexFromReq,
|
||||
String blockBytesB64
|
||||
String blockBytesB64,
|
||||
boolean replicateAfterWrite,
|
||||
boolean publishToArweave
|
||||
) {
|
||||
if (blockchainName == null || blockchainName.isBlank()) {
|
||||
log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq);
|
||||
@@ -268,6 +276,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
if (!block.getDataItem().hasTag("App", "test5590")) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_app_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
// body.check()
|
||||
try {
|
||||
block.body.check();
|
||||
@@ -397,6 +409,22 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
|
||||
}
|
||||
|
||||
// Channel DataItems are indexed by a signed canonical channel slug tag: c=<slug>.
|
||||
try {
|
||||
String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry);
|
||||
String actualChannelSlug = block.getDataItem().tagValue("c");
|
||||
if (expectedChannelSlug != null) {
|
||||
if (!expectedChannelSlug.equals(actualChannelSlug)) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
} else if (actualChannelSlug != null) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("AddBlock: cannot validate channel tag blockchainName={} block={}", blockchainName, block.blockNumber, e);
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
if ((block.type & 0xFFFF) == 1
|
||||
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
try {
|
||||
@@ -454,7 +482,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
// 6) подпись по hash32(preimage)
|
||||
// 6) ANS-104 Ed25519 подпись и owner
|
||||
boolean sigOk;
|
||||
try {
|
||||
sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32);
|
||||
@@ -512,7 +540,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// 8) сформировать запись и записать (DB + state + файл)
|
||||
// 8) сформировать запись и атомарно записать в PostgreSQL вместе с новым state
|
||||
try {
|
||||
BlockEntry be = new BlockEntry();
|
||||
be.setLogin(login);
|
||||
@@ -525,6 +553,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
be.setBlockBytes(block.toBytes());
|
||||
be.setBlockHash(block.getHash32());
|
||||
be.setBlockSignature(block.getSignature64());
|
||||
be.setDataItemId(block.getDataItemId32());
|
||||
be.setArweavePublishPending(publishToArweave);
|
||||
|
||||
// line columns (optional)
|
||||
be.setLineCode(lineCode);
|
||||
@@ -563,6 +593,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
be.setEditedByBlockNumber(be.getToBlockNumber());
|
||||
}
|
||||
|
||||
if (block.body instanceof ConnectionBody connectionBody) {
|
||||
String votingError = validateConnectionVotingRights(login, block.subType & 0xFFFF, connectionBody.toLogin());
|
||||
if (votingError != null) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, votingError, serverLastNum, serverLastHashHex);
|
||||
}
|
||||
}
|
||||
|
||||
UserParamEntry upsertedParam = null;
|
||||
if (block.body instanceof UserParamBody upBody) {
|
||||
String effectiveLogin = (st.getLogin() != null && !st.getLogin().isBlank())
|
||||
@@ -579,9 +616,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);
|
||||
}
|
||||
@@ -600,15 +641,105 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}",
|
||||
login, blockchainName, block.blockNumber, newHashHex);
|
||||
|
||||
if (replicateAfterWrite) {
|
||||
addBlockSyncService.replicateAsync(blockchainName, block.blockNumber);
|
||||
}
|
||||
|
||||
return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Архивный импорт использует ту же проверку и запись, что обычный AddBlock,
|
||||
* но не запускает повторную межсерверную репликацию уже импортированного блока.
|
||||
*/
|
||||
public ArweaveImportResult addBlockFromArweave(String blockchainName, byte[] rawBlockBytes) {
|
||||
if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) {
|
||||
return new ArweaveImportResult(false, "chain_resync_in_progress", -1, "");
|
||||
}
|
||||
if (rawBlockBytes == null || rawBlockBytes.length == 0) {
|
||||
return new ArweaveImportResult(false, "empty_block", -1, "");
|
||||
}
|
||||
try {
|
||||
BchBlockEntry probe = new BchBlockEntry(rawBlockBytes);
|
||||
if (blocksDAO.existsByDataItemId(probe.getDataItemId32())) {
|
||||
return new ArweaveImportResult(true, "already_present", probe.blockNumber, toHex(probe.getHash32()));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Parsed again below to return the canonical bad_block_format result.
|
||||
}
|
||||
final BchBlockEntry parsed;
|
||||
try {
|
||||
parsed = new BchBlockEntry(rawBlockBytes);
|
||||
} catch (Exception e) {
|
||||
return new ArweaveImportResult(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,
|
||||
false
|
||||
);
|
||||
return new ArweaveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public record ArweaveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Helpers ====================================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
private String expectedChannelSlug(String blockchainName, BchBlockEntry block, ChannelNameStateEntry justCreated) throws Exception {
|
||||
if (block.body instanceof CreateChannelBody) {
|
||||
return justCreated == null ? null : justCreated.getSlug();
|
||||
}
|
||||
int type = block.type & 0xffff;
|
||||
int sub = block.subType & 0xffff;
|
||||
boolean channelBlock = type == 1 && (
|
||||
sub == (MsgSubType.TEXT_POST & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_REPOST & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_CHANNEL_META & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_ENTRYPOINT & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_EXERCISE & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_SERVICE & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_COURSE & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_EDIT_POST & 0xffff));
|
||||
if (!channelBlock || !(block.body instanceof BodyHasLine line) || line.lineCode() <= 0) return null;
|
||||
return channelNameStateDAO.getSlugByOwnerAndRoot(blockchainName, line.lineCode());
|
||||
}
|
||||
|
||||
private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) {
|
||||
if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
&& subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null;
|
||||
if (targetLogin == null || targetLogin.isBlank()) return "bad_connection_target";
|
||||
String sql = subType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
? "SELECT shine_is_primary(?), shine_target_accepts_primary_confirmation(?)"
|
||||
: "SELECT (shine_is_primary(?) AND shine_is_shining(?)), shine_target_accepts_shine_confirmation(?)";
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
if (subType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)) {
|
||||
ps.setString(1, actorLogin); ps.setString(2, targetLogin);
|
||||
} else {
|
||||
ps.setString(1, actorLogin); ps.setString(2, actorLogin); ps.setString(3, targetLogin);
|
||||
}
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next() || !rs.getBoolean(1)) return subType == 80 ? "primary_vote_not_allowed" : "shine_vote_not_allowed";
|
||||
if (!rs.getBoolean(2)) return subType == 80 ? "target_non_voting" : "target_shine_not_interested";
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.warn("Connection voting permission check failed actor={} target={} subtype={}", actorLogin, targetLogin, subType, e);
|
||||
return "voting_permission_check_failed";
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] decodeBase64(String b64) {
|
||||
if (b64 == null) throw new IllegalArgumentException("blockBytesB64 == null");
|
||||
return Base64Ws.decode(b64);
|
||||
@@ -884,17 +1015,24 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Connection notifications are intentionally modeled as a generic kind.
|
||||
// Today only CONNECTION_CLOSE_FRIEND is indexed; future incoming connection types
|
||||
// can reuse the same notification kind and expose their code via sourceMsgSubType.
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
&& block.body instanceof ConnectionBody) {
|
||||
if (msgType == 3 && block.body instanceof ConnectionBody) {
|
||||
boolean personalConnection = msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||
boolean channelEvent = msgSubType == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF);
|
||||
if (personalConnection || channelEvent) {
|
||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||
entry.setNotificationKind("connection");
|
||||
entry.setNotificationKind(channelEvent ? "event" : "connection");
|
||||
entry.setSourceText("");
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
+48
-115
@@ -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;
|
||||
@@ -11,40 +13,24 @@ import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
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 — запись блока в БД и формирование файловой версии цепочки.
|
||||
*
|
||||
* Текущая схема:
|
||||
* 1) собираем <blockchainName>.tmp_bch как готовый кандидат на замену основного файла;
|
||||
* 2) пишем маленький sidecar <blockchainName>.write_check с blockNumber/blockHash;
|
||||
* 3) создаём пустой marker <blockchainName>.write_pending;
|
||||
* 4) выполняем одну SQL-транзакцию;
|
||||
* 5) после commit атомарно заменяем основной .bch из tmp;
|
||||
* 6) убираем временные файлы.
|
||||
*
|
||||
* Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись:
|
||||
* - если marker отсутствует, а tmp/check остались, это мусор;
|
||||
* - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор.
|
||||
/** Stores the complete ANS-104 DataItem and all derived SHiNE state atomically in PostgreSQL.
|
||||
* Local .bch/tmp/marker files are intentionally not used.
|
||||
*/
|
||||
public final class BlockchainWriter {
|
||||
|
||||
private static final HexFormat HEX = HexFormat.of();
|
||||
|
||||
private final BlocksDAO blocksDAO;
|
||||
private final BlockchainStateDAO stateDAO;
|
||||
private final ChannelNameStateDAO channelNameStateDAO;
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
BlockchainStateDAO stateDAO,
|
||||
@@ -58,7 +44,11 @@ public final class BlockchainWriter {
|
||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||
}
|
||||
|
||||
public void appendBlockAndState(String blockchainName,
|
||||
/**
|
||||
* Stores the complete ANS-104 DataItem and all SQL projections in one DB transaction.
|
||||
* There is intentionally no .bch/tmp/marker file layer anymore.
|
||||
*/
|
||||
public List<String> appendBlockAndState(String blockchainName,
|
||||
BchBlockEntry block,
|
||||
BlockchainStateEntry st,
|
||||
BlockEntry be,
|
||||
@@ -68,121 +58,64 @@ public final class BlockchainWriter {
|
||||
UserNotificationEntry notificationEntry) throws SQLException {
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
byte[] blockBytes = block.toBytes();
|
||||
byte[] candidateBytes = buildCandidateBlockchainBytes(blockchainName, blockBytes);
|
||||
String blockHashHex = HEX.formatHex(block.getHash32());
|
||||
|
||||
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
|
||||
|
||||
boolean committed = false;
|
||||
byte[] rawDataItem = block.toBytes();
|
||||
List<String> counterChangedLogins = new ArrayList<>();
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
// 1) insert block
|
||||
blocksDAO.insert(c, be);
|
||||
|
||||
// 2) update state
|
||||
st.setLastBlockNumber(block.blockNumber);
|
||||
st.setLastBlockHash(block.getHash32());
|
||||
st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length);
|
||||
st.setFileSizeBytes(st.getFileSizeBytes() + rawDataItem.length);
|
||||
st.setUpdatedAtMs(nowMs);
|
||||
|
||||
stateDAO.upsert(c, st);
|
||||
|
||||
// 2.1) Если блок — USER_PARAM, синхронизируем снимок в users_params в той же транзакции.
|
||||
if (userParamEntry != null) {
|
||||
userParamsDAO.upsertIfNewer(c, userParamEntry);
|
||||
if (userParamEntry != null) userParamsDAO.upsertIfNewer(c, userParamEntry);
|
||||
if (channelNameStateEntry != null) channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||
if (channelMetaUpdateEntry != null) channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
if (notificationEntry != null) userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
if (channelNameStateEntry != null) {
|
||||
channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||
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());
|
||||
}
|
||||
|
||||
if (channelMetaUpdateEntry != null) {
|
||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
}
|
||||
|
||||
// Notification projection is part of the same SQL transaction as the block.
|
||||
// If notification indexing fails, the block/state write is rolled back as well.
|
||||
if (notificationEntry != null) {
|
||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
c.rollback();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
if (!committed) {
|
||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||
}
|
||||
|
||||
if (e instanceof SQLException se) {
|
||||
throw se;
|
||||
}
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
if (e instanceof SQLException se) throw se;
|
||||
throw new SQLException("appendBlockAndState failed", e);
|
||||
} finally {
|
||||
try {
|
||||
c.setAutoCommit(true);
|
||||
} catch (Exception ignored) {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
return counterChangedLogins;
|
||||
}
|
||||
|
||||
// 3) После commit — атомарно подменяем основной файл.
|
||||
try {
|
||||
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||
} catch (RuntimeException e) {
|
||||
// marker/check/tmp оставляем для startup-recovery
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 4) После успешной подмены — чистим временные артефакты.
|
||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||
}
|
||||
|
||||
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
||||
byte[] base;
|
||||
try {
|
||||
if (Files.exists(fs.resolveBlockchainPath(blockchainName))) {
|
||||
base = fs.readBlockchain(blockchainName);
|
||||
} else {
|
||||
base = new byte[0];
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
byte[] out = new byte[base.length + blockBytes.length];
|
||||
System.arraycopy(base, 0, out, 0, base.length);
|
||||
System.arraycopy(blockBytes, 0, out, base.length, blockBytes.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
private void prepareWriteArtifacts(String blockchainName, int blockNumber, String blockHashHex, byte[] candidateBytes) {
|
||||
fs.writeBlockchainTmp(blockchainName, candidateBytes);
|
||||
fs.writeBlockchainWriteCheck(blockchainName, blockNumber, blockHashHex);
|
||||
fs.writeBlockchainWritePendingMarker(blockchainName);
|
||||
}
|
||||
|
||||
private void cleanupWriteArtifactsBestEffort(String blockchainName) {
|
||||
deleteQuietly(() -> fs.deleteBlockchainWritePendingMarkerIfExists(blockchainName));
|
||||
deleteQuietly(() -> fs.deleteBlockchainWriteCheckIfExists(blockchainName));
|
||||
deleteQuietly(() -> fs.deleteBlockchainTmpFileIfExists(blockchainName));
|
||||
}
|
||||
|
||||
private static void deleteQuietly(DeleteAction action) {
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface DeleteAction {
|
||||
void run();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -138,6 +138,17 @@ public final class ChannelNamesStateBootstrapper {
|
||||
dao.insertAll(c, entries);
|
||||
|
||||
applyMetaUpdates(c, dao, entries, skipped);
|
||||
// v18: counters are a projection, so rebuild them from current state after a full channel-name bootstrap.
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_stats(login) FROM solana_user_pda_current")) {
|
||||
ps.execute();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT shine_refresh_channel_follow_stats(owner_bch_name, channel_root_block_number, channel_root_block_hash)
|
||||
FROM channel_names_state
|
||||
WHERE channel_type_code = 1
|
||||
""")) {
|
||||
ps.execute();
|
||||
}
|
||||
c.commit();
|
||||
log.info("channel_names_state bootstrapped: {}", entries.size());
|
||||
if (!conflicts.isEmpty()) {
|
||||
|
||||
+14
-35
@@ -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()) {
|
||||
@@ -342,8 +317,10 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
|
||||
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
|
||||
String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
||||
String sql = "SELECT likes_count,primary_likes_count,shining_likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
||||
int likesCount = 0;
|
||||
int primaryLikesCount = 0;
|
||||
int shiningLikesCount = 0;
|
||||
int repliesCount = 0;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bch);
|
||||
@@ -352,6 +329,8 @@ final class ChannelsReadSupport {
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
likesCount = rs.getInt("likes_count");
|
||||
primaryLikesCount = rs.getInt("primary_likes_count");
|
||||
shiningLikesCount = rs.getInt("shining_likes_count");
|
||||
repliesCount = rs.getInt("replies_count");
|
||||
}
|
||||
}
|
||||
@@ -378,7 +357,7 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
}
|
||||
}
|
||||
return new int[] {likesCount, repliesCount, ratingsCount};
|
||||
return new int[] {likesCount, repliesCount, ratingsCount, primaryLikesCount, shiningLikesCount};
|
||||
}
|
||||
|
||||
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||
|
||||
+8
-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));
|
||||
}
|
||||
}
|
||||
@@ -169,6 +171,8 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
|
||||
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
||||
item.setLikesCount(stats[0]);
|
||||
item.setPrimaryLikesCount(stats[3]);
|
||||
item.setShiningLikesCount(stats[4]);
|
||||
item.setRepliesCount(stats[1]);
|
||||
item.setRatingsCount(stats[2]);
|
||||
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
||||
|
||||
+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", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -242,6 +242,8 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
|
||||
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
||||
node.setLikesCount(stats[0]);
|
||||
node.setPrimaryLikesCount(stats[3]);
|
||||
node.setShiningLikesCount(stats[4]);
|
||||
node.setRepliesCount(stats[1]);
|
||||
node.setRatingsCount(stats[2]);
|
||||
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
||||
|
||||
+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", "Не удалось получить счётчики");
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -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,8 +82,15 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
||||
channelRef.setChannelRoot(rootRef);
|
||||
|
||||
row.setChannel(channelRef);
|
||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
||||
row.setUnreadCount(ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
|
||||
int messagesCount = ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber);
|
||||
row.setMessagesCount(messagesCount);
|
||||
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
|
||||
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);
|
||||
}
|
||||
}
|
||||
+6
@@ -139,6 +139,8 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
private long createdAtMs;
|
||||
private String text;
|
||||
private int likesCount;
|
||||
private int primaryLikesCount;
|
||||
private int shiningLikesCount;
|
||||
private boolean likedByMe;
|
||||
private int repliesCount;
|
||||
private int ratingsCount;
|
||||
@@ -191,6 +193,10 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
|
||||
public int getLikesCount() { return likesCount; }
|
||||
public void setLikesCount(int likesCount) { this.likesCount = likesCount; }
|
||||
public int getPrimaryLikesCount() { return primaryLikesCount; }
|
||||
public void setPrimaryLikesCount(int primaryLikesCount) { this.primaryLikesCount = primaryLikesCount; }
|
||||
public int getShiningLikesCount() { return shiningLikesCount; }
|
||||
public void setShiningLikesCount(int shiningLikesCount) { this.shiningLikesCount = shiningLikesCount; }
|
||||
|
||||
public boolean isLikedByMe() { return likedByMe; }
|
||||
public void setLikedByMe(boolean likedByMe) { this.likedByMe = likedByMe; }
|
||||
|
||||
+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; }
|
||||
}
|
||||
+112
-40
@@ -46,8 +46,10 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||
}
|
||||
|
||||
List<String> outFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||
List<String> inFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||
List<String> outFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
|
||||
List<String> inFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
|
||||
List<String> outCloseFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||
List<String> inCloseFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||
List<String> outContacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
|
||||
List<String> inContacts = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
|
||||
List<String> outFollows = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
|
||||
@@ -60,18 +62,21 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
List<String> inChildren = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD);
|
||||
List<String> outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
|
||||
List<String> inSiblings = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
|
||||
List<String> outKnownPersons = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_KNOWN_PERSON);
|
||||
List<String> inKnownPersons = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_KNOWN_PERSON);
|
||||
List<String> outShineConfirmed = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
|
||||
List<String> inShineConfirmed = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
|
||||
// Legacy 60/61 are accepted by the protocol but intentionally not surfaced by current UI.
|
||||
List<String> outKnownPersons = List.of();
|
||||
List<String> inKnownPersons = List.of();
|
||||
List<String> outOfficialAccounts = listEffectiveConfirmation(c, canonicalLogin, 80, true);
|
||||
List<String> inOfficialAccounts = listEffectiveConfirmation(c, canonicalLogin, 80, false);
|
||||
List<String> outShineConfirmed = listEffectiveConfirmation(c, canonicalLogin, 70, true);
|
||||
List<String> inShineConfirmed = listEffectiveConfirmation(c, canonicalLogin, 70, false);
|
||||
List<String> outShineSeen = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
|
||||
List<String> inShineSeen = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
|
||||
|
||||
LinkedHashSet<String> allLogins = new LinkedHashSet<>();
|
||||
allLogins.add(canonicalLogin);
|
||||
addAllLogins(allLogins, outFriends, inFriends, outContacts, inContacts, outFollows, inFollows,
|
||||
addAllLogins(allLogins, outFriends, inFriends, outCloseFriends, inCloseFriends, outContacts, inContacts, outFollows, inFollows,
|
||||
outSpouses, inSpouses, outParents, inParents, outChildren, inChildren, outSiblings, inSiblings,
|
||||
outKnownPersons, inKnownPersons, outShineConfirmed, inShineConfirmed, outShineSeen, inShineSeen);
|
||||
outKnownPersons, inKnownPersons, outOfficialAccounts, inOfficialAccounts, outShineConfirmed, inShineConfirmed, outShineSeen, inShineSeen);
|
||||
|
||||
Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins);
|
||||
List<String> spouseLogins = mergeUnique(outSpouses, inSpouses);
|
||||
@@ -86,6 +91,8 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
resp.setLogin(canonicalLogin);
|
||||
resp.setOutFriends(outFriends);
|
||||
resp.setInFriends(inFriends);
|
||||
resp.setOutCloseFriends(outCloseFriends);
|
||||
resp.setInCloseFriends(inCloseFriends);
|
||||
resp.setOutContacts(outContacts);
|
||||
resp.setInContacts(inContacts);
|
||||
resp.setOutFollows(outFollows);
|
||||
@@ -100,6 +107,8 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
resp.setInSiblings(inSiblings);
|
||||
resp.setOutKnownPersons(outKnownPersons);
|
||||
resp.setInKnownPersons(inKnownPersons);
|
||||
resp.setOutOfficialAccounts(outOfficialAccounts);
|
||||
resp.setInOfficialAccounts(inOfficialAccounts);
|
||||
resp.setOutShineConfirmed(outShineConfirmed);
|
||||
resp.setInShineConfirmed(inShineConfirmed);
|
||||
resp.setOutShineSeen(outShineSeen);
|
||||
@@ -108,11 +117,49 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
resp.setChildren(toRelativeItems(childLogins, metaByLogin));
|
||||
resp.setSiblings(toRelativeItems(siblingLogins, metaByLogin));
|
||||
resp.setSpouses(toRelativeItems(spouseLogins, metaByLogin));
|
||||
resp.setAllUsers(toUserMarkItems(allLogins, metaByLogin));
|
||||
Map<String, String> relationTypes = buildEffectiveRelationTypes(outContacts, outFriends, outCloseFriends);
|
||||
resp.setAllUsers(toUserMarkItems(allLogins, metaByLogin, relationTypes, outOfficialAccounts, outShineConfirmed));
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> listEffectiveConfirmation(Connection c, String login, int relType, boolean outgoing) throws Exception {
|
||||
String sql;
|
||||
if (relType == 80) {
|
||||
sql = outgoing ? """
|
||||
SELECT DISTINCT cs.to_login AS peer FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=80
|
||||
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)
|
||||
ORDER BY peer
|
||||
""" : """
|
||||
SELECT DISTINCT cs.login AS peer FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=80
|
||||
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)
|
||||
ORDER BY peer
|
||||
""";
|
||||
} else {
|
||||
sql = outgoing ? """
|
||||
SELECT DISTINCT cs.to_login AS peer FROM connections_state cs
|
||||
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=70
|
||||
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login)
|
||||
AND shine_target_accepts_shine_confirmation(cs.to_login)
|
||||
ORDER BY peer
|
||||
""" : """
|
||||
SELECT DISTINCT cs.login AS peer FROM connections_state cs
|
||||
WHERE LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=70
|
||||
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login)
|
||||
AND shine_target_accepts_shine_confirmation(cs.to_login)
|
||||
ORDER BY peer
|
||||
""";
|
||||
}
|
||||
List<String> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(rs.getString("peer")); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception {
|
||||
String sql = "SELECT login FROM " + CurrentUsersSql.usersSubquery("su")
|
||||
+ " WHERE LOWER(login) = LOWER(?) LIMIT 1";
|
||||
@@ -160,47 +207,54 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
private Map<String, UserMeta> loadUserMeta(Connection c, Set<String> logins) throws Exception {
|
||||
Map<String, UserMeta> out = new HashMap<>();
|
||||
if (logins == null || logins.isEmpty()) return out;
|
||||
|
||||
String[] placeholders = new String[logins.size()];
|
||||
for (int i = 0; i < placeholders.length; i += 1) placeholders[i] = "?";
|
||||
for (int i=0;i<placeholders.length;i++) placeholders[i]="?";
|
||||
String sql = """
|
||||
SELECT
|
||||
su.login AS login,
|
||||
MAX(CASE WHEN up.param = 'gender' THEN up.value END) AS gender_value,
|
||||
MAX(CASE WHEN up.param = 'official' THEN up.value END) AS official_value,
|
||||
MAX(CASE WHEN up.param = 'shine' THEN up.value END) AS shine_value,
|
||||
MAX(CASE WHEN up.param = 'ava' THEN up.value END) AS avatar_value
|
||||
SELECT su.login,
|
||||
COALESCE(ups.first_name,'') first_name,
|
||||
COALESCE(ups.last_name,'') last_name,
|
||||
ups.account_role, ups.shine_status, COALESCE(ups.ava_ar,'') avatar_value,
|
||||
COALESCE(ust.primary_confirmations_received_count,0) primary_confirmations_count,
|
||||
COALESCE(ust.shine_confirmations_received_count,0) shine_confirmations_count,
|
||||
MAX(CASE WHEN up.param='gender' THEN up.value END) gender_value
|
||||
FROM %s
|
||||
LEFT JOIN users_params up
|
||||
ON LOWER(up.login) = LOWER(su.login)
|
||||
AND up.param IN ('gender', 'official', 'shine', 'ava')
|
||||
LEFT JOIN user_profile_state ups ON LOWER(ups.login)=LOWER(su.login)
|
||||
LEFT JOIN user_stats_state ust ON LOWER(ust.login)=LOWER(su.login)
|
||||
LEFT JOIN users_params up ON LOWER(up.login)=LOWER(su.login) AND up.param='gender'
|
||||
WHERE LOWER(su.login) IN (%s)
|
||||
GROUP BY su.login
|
||||
GROUP BY su.login, ups.first_name, ups.last_name, ups.account_role, ups.shine_status, ups.ava_ar,
|
||||
ust.primary_confirmations_received_count, ust.shine_confirmations_received_count
|
||||
ORDER BY su.login
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"), String.join(", ", placeholders));
|
||||
|
||||
try(PreparedStatement ps=c.prepareStatement(sql)){
|
||||
int i = 1;
|
||||
for (String login : logins) {
|
||||
ps.setString(i, normKey(login));
|
||||
i += 1;
|
||||
}
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String login = rs.getString("login");
|
||||
if (login == null || login.isBlank()) continue;
|
||||
UserMeta meta = new UserMeta();
|
||||
meta.gender = normalizeGender(rs.getString("gender_value"));
|
||||
meta.official = parseToggle(rs.getString("official_value"));
|
||||
meta.shine = parseToggle(rs.getString("shine_value"));
|
||||
meta.avatarAr = extractArAvatarTxId(rs.getString("avatar_value"));
|
||||
out.put(normKey(login), meta);
|
||||
}
|
||||
}
|
||||
int i=1; for(String login:logins) ps.setString(i++,normKey(login));
|
||||
try(ResultSet rs=ps.executeQuery()){while(rs.next()){
|
||||
String login=rs.getString("login"); if(login==null||login.isBlank()) continue;
|
||||
UserMeta meta=new UserMeta(); meta.firstName=rs.getString("first_name"); meta.lastName=rs.getString("last_name");
|
||||
meta.accountRole=rs.getString("account_role"); meta.shineStatus=rs.getString("shine_status");
|
||||
meta.gender=normalizeGender(rs.getString("gender_value")); meta.official="primary".equals(meta.accountRole); meta.shine="shining".equals(meta.shineStatus);
|
||||
meta.primaryConfirmationsCount=rs.getInt("primary_confirmations_count"); meta.shineConfirmationsCount=rs.getInt("shine_confirmations_count");
|
||||
meta.avatarAr=extractArAvatarTxId(rs.getString("avatar_value")); out.put(normKey(login),meta);
|
||||
}}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String,String> buildEffectiveRelationTypes(List<String> contacts,List<String> friends,List<String> closeFriends){
|
||||
Map<String,String> out=new HashMap<>();
|
||||
if(contacts!=null) for(String v:contacts) out.put(normKey(v),"contact");
|
||||
if(friends!=null) for(String v:friends) out.put(normKey(v),"friend");
|
||||
if(closeFriends!=null) for(String v:closeFriends) out.put(normKey(v),"close_friend");
|
||||
return out;
|
||||
}
|
||||
|
||||
private boolean listContainsLogin(List<String> list, String login) {
|
||||
if (list == null || login == null) return false;
|
||||
String key = normKey(login);
|
||||
for (String value : list) if (key.equals(normKey(value))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean parseToggle(String rawValue) {
|
||||
String v = String.valueOf(rawValue == null ? "" : rawValue).trim().toLowerCase();
|
||||
return "1".equals(v) || "yes".equals(v) || "true".equals(v) || "on".equals(v);
|
||||
@@ -269,7 +323,10 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
|
||||
private List<Net_GetUserConnectionsGraph_Response.UserMarkItem> toUserMarkItems(
|
||||
Set<String> logins,
|
||||
Map<String, UserMeta> metaByLogin
|
||||
Map<String, UserMeta> metaByLogin,
|
||||
Map<String, String> relationTypes,
|
||||
List<String> primaryConfirmedLogins,
|
||||
List<String> shineConfirmedLogins
|
||||
) {
|
||||
List<Net_GetUserConnectionsGraph_Response.UserMarkItem> items = new ArrayList<>();
|
||||
if (logins == null) return items;
|
||||
@@ -284,6 +341,15 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
|
||||
Net_GetUserConnectionsGraph_Response.UserMarkItem it = new Net_GetUserConnectionsGraph_Response.UserMarkItem();
|
||||
it.setLogin(clean);
|
||||
it.setFirstName(meta == null ? "" : meta.firstName);
|
||||
it.setLastName(meta == null ? "" : meta.lastName);
|
||||
it.setAccountRole(meta == null ? null : meta.accountRole);
|
||||
it.setShineStatus(meta == null ? null : meta.shineStatus);
|
||||
it.setRelationType(relationTypes == null ? "none" : relationTypes.getOrDefault(normKey(clean), "none"));
|
||||
it.setPrimaryConfirmed(listContainsLogin(primaryConfirmedLogins, clean));
|
||||
it.setShineConfirmed(listContainsLogin(shineConfirmedLogins, clean));
|
||||
it.setPrimaryConfirmationsCount(meta == null ? 0 : meta.primaryConfirmationsCount);
|
||||
it.setShineConfirmationsCount(meta == null ? 0 : meta.shineConfirmationsCount);
|
||||
it.setOfficial(official);
|
||||
it.setShine(shine);
|
||||
it.setOfficialLabel(official ? "официальный" : "неофициальный");
|
||||
@@ -296,6 +362,12 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||
|
||||
private static final class UserMeta {
|
||||
private String gender = "unknown";
|
||||
private String firstName = "";
|
||||
private String lastName = "";
|
||||
private String accountRole = null;
|
||||
private String shineStatus = null;
|
||||
private int primaryConfirmationsCount = 0;
|
||||
private int shineConfirmationsCount = 0;
|
||||
private boolean official = false;
|
||||
private boolean shine = false;
|
||||
private String avatarAr = null;
|
||||
|
||||
+20
-2
@@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListConta
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.DmDialogStateDAO;
|
||||
import shine.db.dao.UserProfileStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
@@ -29,18 +30,25 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(ctx.getLogin());
|
||||
resp.setDialogs(toDialogItems(dialogs));
|
||||
resp.setDialogs(toDialogItems(c, dialogs));
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
private List<Net_ListContacts_Response.DialogItem> toDialogItems(List<DmDialogStateDAO.DialogSummary> dialogs) {
|
||||
private List<Net_ListContacts_Response.DialogItem> toDialogItems(Connection c, List<DmDialogStateDAO.DialogSummary> dialogs) throws Exception {
|
||||
List<Net_ListContacts_Response.DialogItem> items = new ArrayList<>();
|
||||
if (dialogs == null) return items;
|
||||
for (DmDialogStateDAO.DialogSummary dialog : dialogs) {
|
||||
Net_ListContacts_Response.DialogItem item = new Net_ListContacts_Response.DialogItem();
|
||||
item.setPeerLogin(dialog.peerLogin());
|
||||
item.setRelationFlag(dialog.relationFlag());
|
||||
UserProfileStateDAO.ProfileCard card = UserProfileStateDAO.getInstance().get(c, dialog.peerLogin());
|
||||
item.setFirstName(card.firstName());
|
||||
item.setLastName(card.lastName());
|
||||
item.setAvatarAr(card.avatarAr());
|
||||
item.setAvatar(parseAvatar(card.avatarAr()));
|
||||
item.setAccountRole(card.accountRole());
|
||||
item.setShineStatus(card.shineStatus());
|
||||
item.setLastMessageBlobB64(dialog.lastMessageBlobB64());
|
||||
item.setLastMessageTimeMs(dialog.lastMessageTimeMs());
|
||||
item.setUnreadCount(dialog.unreadCount());
|
||||
@@ -49,4 +57,14 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
|
||||
}
|
||||
return items;
|
||||
}
|
||||
private static Net_ListContacts_Response.Avatar parseAvatar(String value) {
|
||||
Net_ListContacts_Response.Avatar out = new Net_ListContacts_Response.Avatar();
|
||||
String raw = value == null ? "" : value.trim();
|
||||
java.util.regex.Matcher ar = java.util.regex.Pattern.compile("AR:([A-Za-z0-9_-]{43})").matcher(raw);
|
||||
if (ar.find()) out.setAr(ar.group(1));
|
||||
java.util.regex.Matcher sha = java.util.regex.Pattern.compile("SHA256:([A-Fa-f0-9]{64})").matcher(raw);
|
||||
if (sha.find()) out.setSha256Hex(sha.group(1).toLowerCase());
|
||||
return out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+39
@@ -9,6 +9,8 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<String> outFriends = new ArrayList<>();
|
||||
private List<String> inFriends = new ArrayList<>();
|
||||
private List<String> outCloseFriends = new ArrayList<>();
|
||||
private List<String> inCloseFriends = new ArrayList<>();
|
||||
private List<String> outContacts = new ArrayList<>();
|
||||
private List<String> inContacts = new ArrayList<>();
|
||||
private List<String> outFollows = new ArrayList<>();
|
||||
@@ -23,6 +25,8 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
private List<String> inSiblings = new ArrayList<>();
|
||||
private List<String> outKnownPersons = new ArrayList<>();
|
||||
private List<String> inKnownPersons = new ArrayList<>();
|
||||
private List<String> outOfficialAccounts = new ArrayList<>();
|
||||
private List<String> inOfficialAccounts = new ArrayList<>();
|
||||
private List<String> outShineConfirmed = new ArrayList<>();
|
||||
private List<String> inShineConfirmed = new ArrayList<>();
|
||||
private List<String> outShineSeen = new ArrayList<>();
|
||||
@@ -58,6 +62,15 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
|
||||
public static class UserMarkItem {
|
||||
private String login;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String accountRole;
|
||||
private String shineStatus;
|
||||
private String relationType;
|
||||
private boolean primaryConfirmed;
|
||||
private boolean shineConfirmed;
|
||||
private int primaryConfirmationsCount;
|
||||
private int shineConfirmationsCount;
|
||||
private boolean official;
|
||||
private boolean shine;
|
||||
private String officialLabel;
|
||||
@@ -66,6 +79,24 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public String getFirstName() { return firstName; }
|
||||
public void setFirstName(String v) { this.firstName = v; }
|
||||
public String getLastName() { return lastName; }
|
||||
public void setLastName(String v) { this.lastName = v; }
|
||||
public String getAccountRole() { return accountRole; }
|
||||
public void setAccountRole(String v) { this.accountRole = v; }
|
||||
public String getShineStatus() { return shineStatus; }
|
||||
public void setShineStatus(String v) { this.shineStatus = v; }
|
||||
public String getRelationType() { return relationType; }
|
||||
public void setRelationType(String v) { this.relationType = v; }
|
||||
public boolean isPrimaryConfirmed() { return primaryConfirmed; }
|
||||
public void setPrimaryConfirmed(boolean v) { this.primaryConfirmed = v; }
|
||||
public boolean isShineConfirmed() { return shineConfirmed; }
|
||||
public void setShineConfirmed(boolean v) { this.shineConfirmed = v; }
|
||||
public int getPrimaryConfirmationsCount() { return primaryConfirmationsCount; }
|
||||
public void setPrimaryConfirmationsCount(int v) { this.primaryConfirmationsCount = v; }
|
||||
public int getShineConfirmationsCount() { return shineConfirmationsCount; }
|
||||
public void setShineConfirmationsCount(int v) { this.shineConfirmationsCount = v; }
|
||||
public boolean isOfficial() { return official; }
|
||||
public void setOfficial(boolean official) { this.official = official; }
|
||||
public boolean isShine() { return shine; }
|
||||
@@ -84,6 +115,10 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
public void setOutFriends(List<String> outFriends) { this.outFriends = outFriends; }
|
||||
public List<String> getInFriends() { return inFriends; }
|
||||
public void setInFriends(List<String> inFriends) { this.inFriends = inFriends; }
|
||||
public List<String> getOutCloseFriends() { return outCloseFriends; }
|
||||
public void setOutCloseFriends(List<String> outCloseFriends) { this.outCloseFriends = outCloseFriends; }
|
||||
public List<String> getInCloseFriends() { return inCloseFriends; }
|
||||
public void setInCloseFriends(List<String> inCloseFriends) { this.inCloseFriends = inCloseFriends; }
|
||||
public List<String> getOutContacts() { return outContacts; }
|
||||
public void setOutContacts(List<String> outContacts) { this.outContacts = outContacts; }
|
||||
public List<String> getInContacts() { return inContacts; }
|
||||
@@ -112,6 +147,10 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
||||
public void setOutKnownPersons(List<String> outKnownPersons) { this.outKnownPersons = outKnownPersons; }
|
||||
public List<String> getInKnownPersons() { return inKnownPersons; }
|
||||
public void setInKnownPersons(List<String> inKnownPersons) { this.inKnownPersons = inKnownPersons; }
|
||||
public List<String> getOutOfficialAccounts() { return outOfficialAccounts; }
|
||||
public void setOutOfficialAccounts(List<String> outOfficialAccounts) { this.outOfficialAccounts = outOfficialAccounts; }
|
||||
public List<String> getInOfficialAccounts() { return inOfficialAccounts; }
|
||||
public void setInOfficialAccounts(List<String> inOfficialAccounts) { this.inOfficialAccounts = inOfficialAccounts; }
|
||||
public List<String> getOutShineConfirmed() { return outShineConfirmed; }
|
||||
public void setOutShineConfirmed(List<String> outShineConfirmed) { this.outShineConfirmed = outShineConfirmed; }
|
||||
public List<String> getInShineConfirmed() { return inShineConfirmed; }
|
||||
|
||||
+27
@@ -17,6 +17,12 @@ public class Net_ListContacts_Response extends Net_Response {
|
||||
public static class DialogItem {
|
||||
private String peerLogin;
|
||||
private String relationFlag;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String avatarAr;
|
||||
private Avatar avatar;
|
||||
private String accountRole;
|
||||
private String shineStatus;
|
||||
private String lastMessageBlobB64;
|
||||
private long lastMessageTimeMs;
|
||||
private int unreadCount;
|
||||
@@ -26,6 +32,18 @@ public class Net_ListContacts_Response extends Net_Response {
|
||||
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
|
||||
public String getRelationFlag() { return relationFlag; }
|
||||
public void setRelationFlag(String relationFlag) { this.relationFlag = relationFlag; }
|
||||
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; }
|
||||
public Avatar getAvatar() { return avatar; }
|
||||
public void setAvatar(Avatar avatar) { this.avatar = avatar; }
|
||||
public String getAccountRole() { return accountRole; }
|
||||
public void setAccountRole(String accountRole) { this.accountRole = accountRole; }
|
||||
public String getShineStatus() { return shineStatus; }
|
||||
public void setShineStatus(String shineStatus) { this.shineStatus = shineStatus; }
|
||||
public String getLastMessageBlobB64() { return lastMessageBlobB64; }
|
||||
public void setLastMessageBlobB64(String lastMessageBlobB64) { this.lastMessageBlobB64 = lastMessageBlobB64; }
|
||||
public long getLastMessageTimeMs() { return lastMessageTimeMs; }
|
||||
@@ -35,4 +53,13 @@ public class Net_ListContacts_Response extends Net_Response {
|
||||
public boolean isHasDialog() { return hasDialog; }
|
||||
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
|
||||
}
|
||||
|
||||
public static class Avatar {
|
||||
private String ar;
|
||||
private String sha256Hex;
|
||||
public String getAr() { return ar; }
|
||||
public void setAr(String ar) { this.ar = ar; }
|
||||
public String getSha256Hex() { return sha256Hex; }
|
||||
public void setSha256Hex(String sha256Hex) { this.sha256Hex = sha256Hex; }
|
||||
}
|
||||
}
|
||||
|
||||
+19
-86
@@ -1,94 +1,27 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||
|
||||
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.notifications.entyties.Net_GetNotifications_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger; import org.slf4j.LoggerFactory;
|
||||
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.notifications.entyties.*; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController; import shine.db.dao.*; import shine.db.entities.UserNotificationEntry;
|
||||
import java.sql.Connection; import java.util.*;
|
||||
|
||||
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
WireCodes.Status.UNVERIFIED,
|
||||
"NOT_AUTHENTICATED",
|
||||
"Операция доступна только для авторизованных пользователей"
|
||||
);
|
||||
}
|
||||
|
||||
String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim();
|
||||
if (login.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit()));
|
||||
|
||||
private static final Logger log=LoggerFactory.getLogger(Net_GetNotifications_Handler.class); private static final long HISTORY_MS=60L*24*60*60*1000;
|
||||
public Net_Response handle(Net_Request base, ConnectionContext ctx){
|
||||
Net_GetNotifications_Request req=(Net_GetNotifications_Request)base; if(ctx==null||!ctx.isAuthenticatedUser()||ctx.getCurrentUser()==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"NOT_AUTHENTICATED","Операция доступна только для авторизованных пользователей");
|
||||
String login=String.valueOf(ctx.getCurrentUser().getLogin()).trim();
|
||||
try(Connection c=DbController.getInstance().getConnection()){
|
||||
List<UserNotificationEntry> replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit);
|
||||
List<UserNotificationEntry> eventRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "connection", limit);
|
||||
|
||||
Net_GetNotifications_Response resp = new Net_GetNotifications_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(login);
|
||||
resp.setReplies(mapRows(replyRows));
|
||||
resp.setEvents(mapRows(eventRows));
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetNotifications failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
UserNotificationSeenStateDAO sd=UserNotificationSeenStateDAO.getInstance(); UserNotificationsStateDAO nd=UserNotificationsStateDAO.getInstance(); long cutoff=System.currentTimeMillis()-HISTORY_MS;
|
||||
long rs=sd.getSeenAt(c,login,"replies"), cs=sd.getSeenAt(c,login,"connections"), es=sd.getSeenAt(c,login,"events");
|
||||
Net_GetNotifications_Response r=new Net_GetNotifications_Response(); r.setOp(req.getOp());r.setRequestId(req.getRequestId());r.setStatus(WireCodes.Status.OK);r.setLogin(login);
|
||||
if (!Boolean.TRUE.equals(req.getCountsOnly())) {
|
||||
r.setReplies(map(nd.listVisible(c,login,"reply",rs,cutoff))); r.setConnections(map(nd.listVisible(c,login,"connection",cs,cutoff))); r.setEvents(map(nd.listVisible(c,login,"event",es,cutoff)));
|
||||
}
|
||||
r.setRepliesSeenAtMs(rs);r.setConnectionsSeenAtMs(cs);r.setEventsSeenAtMs(es); r.setRepliesUnseenCount(nd.countUnseen(c,login,"reply",rs)); r.setConnectionsUnseenCount(nd.countUnseen(c,login,"connection",cs)); r.setEventsUnseenCount(nd.countUnseen(c,login,"event",es));
|
||||
return r;
|
||||
}catch(Exception e){log.error("GetNotifications failed",e);return NetExceptionResponseFactory.error(req,WireCodes.Status.INTERNAL_ERROR,"internal_error","Внутренняя ошибка сервера");}
|
||||
}
|
||||
|
||||
private List<Net_GetNotifications_Response.NotificationItem> mapRows(List<UserNotificationEntry> rows) {
|
||||
List<Net_GetNotifications_Response.NotificationItem> out = new ArrayList<>();
|
||||
for (UserNotificationEntry row : rows) {
|
||||
Net_GetNotifications_Response.NotificationItem item = new Net_GetNotifications_Response.NotificationItem();
|
||||
item.setKind(row.getNotificationKind());
|
||||
item.setCreatedAtMs(row.getCreatedAtMs());
|
||||
item.setSourceLogin(row.getSourceLogin());
|
||||
item.setSourceBlockchainName(row.getSourceBchName());
|
||||
item.setSourceBlockNumber(row.getSourceBlockNumber());
|
||||
item.setSourceBlockHash(bytesToHex(row.getSourceBlockHash()));
|
||||
item.setSourceMsgSubType(row.getSourceMsgSubType());
|
||||
item.setConnectionTypeCode("connection".equals(row.getNotificationKind()) ? row.getSourceMsgSubType() : null);
|
||||
item.setSourceText(row.getSourceText());
|
||||
item.setTargetLogin(row.getTargetLogin());
|
||||
item.setTargetBlockchainName(row.getTargetBchName());
|
||||
item.setTargetBlockNumber(row.getTargetBlockNumber());
|
||||
item.setTargetBlockHash(bytesToHex(row.getTargetBlockHash()));
|
||||
out.add(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
if (bytes == null) return null;
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
char[] out = new char[bytes.length * 2];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
int v = bytes[i] & 0xff;
|
||||
out[i * 2] = HEX[v >>> 4];
|
||||
out[i * 2 + 1] = HEX[v & 0x0f];
|
||||
}
|
||||
return new String(out);
|
||||
}
|
||||
private List<Net_GetNotifications_Response.NotificationItem> map(List<UserNotificationEntry> rows){ List<Net_GetNotifications_Response.NotificationItem> out=new ArrayList<>(); for(UserNotificationEntry x:rows){ Net_GetNotifications_Response.NotificationItem i=new Net_GetNotifications_Response.NotificationItem(); i.setKind(x.getNotificationKind());i.setCreatedAtMs(x.getCreatedAtMs());i.setSourceLogin(x.getSourceLogin());i.setSourceBlockchainName(x.getSourceBchName());i.setSourceBlockNumber(x.getSourceBlockNumber());i.setSourceBlockHash(hex(x.getSourceBlockHash()));i.setSourceMsgSubType(x.getSourceMsgSubType());i.setConnectionTypeCode("connection".equals(x.getNotificationKind())?x.getSourceMsgSubType():null);i.setSourceText(x.getSourceText());i.setTargetLogin(x.getTargetLogin());i.setTargetBlockchainName(x.getTargetBchName());i.setTargetBlockNumber(x.getTargetBlockNumber());i.setTargetBlockHash(hex(x.getTargetBlockHash()));out.add(i);} return out; }
|
||||
private static String hex(byte[] b){if(b==null)return null;StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||
|
||||
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.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;
|
||||
import shine.db.dao.UserNotificationSeenStateDAO;
|
||||
import utils.crypto.Ed25519Util;
|
||||
import java.sql.Connection;
|
||||
import java.util.Base64;
|
||||
|
||||
public final class Net_SetNotificationState_Handler implements JsonMessageHandler {
|
||||
public Net_Response handle(Net_Request base, ConnectionContext ctx){
|
||||
Net_SetNotificationState_Request req=(Net_SetNotificationState_Request)base;
|
||||
if(ctx==null||!ctx.isAuthenticatedUser()||ctx.getCurrentUser()==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"NOT_AUTHENTICATED","Требуется авторизация");
|
||||
try{
|
||||
byte[] raw=Base64.getDecoder().decode(String.valueOf(req.getBlobB64()).trim()); NotificationStatePacket p=NotificationStatePacket.parse(raw);
|
||||
String login=String.valueOf(ctx.getCurrentUser().getLogin()).trim(); if(!login.equalsIgnoreCase(p.login)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"LOGIN_MISMATCH","Подпись принадлежит другому пользователю");
|
||||
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); UserCountersSupport.pushChanged(login); return r;
|
||||
}catch(Exception e){ return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_NOTIFICATION_STATE",e.getMessage()==null?"Некорректное состояние уведомлений":e.getMessage()); }
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
final class NotificationStatePacket {
|
||||
static final byte[] PREFIX = "SHiNE_NTF".getBytes(StandardCharsets.US_ASCII);
|
||||
static final int STATE_SEEN_WATERMARK = 1;
|
||||
static final int CATEGORY_REPLIES = 1;
|
||||
static final int CATEGORY_CONNECTIONS = 2;
|
||||
static final int CATEGORY_EVENTS = 3;
|
||||
|
||||
final String login; final long timeMs; final long nonce; final int stateType; final int category; final long seenAtMs;
|
||||
final byte[] signedBody; final byte[] signature64; final byte[] rawPacket;
|
||||
private NotificationStatePacket(String login,long timeMs,long nonce,int stateType,int category,long seenAtMs,byte[] signedBody,byte[] signature64,byte[] rawPacket){
|
||||
this.login=login;this.timeMs=timeMs;this.nonce=nonce;this.stateType=stateType;this.category=category;this.seenAtMs=seenAtMs;this.signedBody=signedBody;this.signature64=signature64;this.rawPacket=rawPacket;
|
||||
}
|
||||
static NotificationStatePacket parse(byte[] raw) {
|
||||
if(raw==null||raw.length<PREFIX.length+2+1+1+8+4+1+1+8+64) throw new IllegalArgumentException("BAD_LEN");
|
||||
for(int i=0;i<PREFIX.length;i++) if(raw[i]!=PREFIX[i]) throw new IllegalArgumentException("BAD_PREFIX");
|
||||
ByteBuffer bb=ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); bb.position(PREFIX.length);
|
||||
int major=Byte.toUnsignedInt(bb.get()), minor=Byte.toUnsignedInt(bb.get());
|
||||
if(major!=1||minor!=0) throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
||||
int len=Byte.toUnsignedInt(bb.get()); if(len<1||len>60||bb.remaining()<len+8+4+1+1+8+64) throw new IllegalArgumentException("BAD_LOGIN");
|
||||
byte[] lb=new byte[len]; bb.get(lb); for(byte b:lb) if(b<0x20||b>0x7e) throw new IllegalArgumentException("BAD_LOGIN");
|
||||
String login=new String(lb,StandardCharsets.US_ASCII); long timeMs=bb.getLong(); if(timeMs<0) throw new IllegalArgumentException("BAD_TIME");
|
||||
long nonce=Integer.toUnsignedLong(bb.getInt()); int stateType=Byte.toUnsignedInt(bb.get()); if(stateType!=STATE_SEEN_WATERMARK) throw new IllegalArgumentException("BAD_STATE_TYPE");
|
||||
int category=Byte.toUnsignedInt(bb.get()); if(category<1||category>3) throw new IllegalArgumentException("BAD_CATEGORY");
|
||||
long seenAtMs=bb.getLong(); if(seenAtMs<0||bb.remaining()!=64) throw new IllegalArgumentException("BAD_SEEN_TIME");
|
||||
byte[] sig=new byte[64]; bb.get(sig); return new NotificationStatePacket(login,timeMs,nonce,stateType,category,seenAtMs,Arrays.copyOf(raw,raw.length-64),sig,raw);
|
||||
}
|
||||
String categoryName(){ return category==CATEGORY_REPLIES?"replies":category==CATEGORY_CONNECTIONS?"connections":"events"; }
|
||||
}
|
||||
+4
-2
@@ -3,8 +3,10 @@ package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetNotifications_Request extends Net_Request {
|
||||
private Integer limit;
|
||||
|
||||
private Integer limit; // legacy: поле принимается для совместимости, но в v2 не ограничивает выдачу
|
||||
private Boolean countsOnly;
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
public Boolean getCountsOnly() { return countsOnly; }
|
||||
public void setCountsOnly(Boolean countsOnly) { this.countsOnly = countsOnly; }
|
||||
}
|
||||
|
||||
+11
@@ -8,12 +8,23 @@ import java.util.List;
|
||||
public class Net_GetNotifications_Response extends Net_Response {
|
||||
private String login;
|
||||
private List<NotificationItem> replies = new ArrayList<>();
|
||||
private List<NotificationItem> connections = new ArrayList<>();
|
||||
private List<NotificationItem> events = new ArrayList<>();
|
||||
private long repliesSeenAtMs, connectionsSeenAtMs, eventsSeenAtMs;
|
||||
private long repliesUnseenCount, connectionsUnseenCount, eventsUnseenCount;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public List<NotificationItem> getReplies() { return replies; }
|
||||
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
||||
public List<NotificationItem> getConnections() { return connections; }
|
||||
public void setConnections(List<NotificationItem> v) { connections = v; }
|
||||
public long getRepliesSeenAtMs(){return repliesSeenAtMs;} public void setRepliesSeenAtMs(long v){repliesSeenAtMs=v;}
|
||||
public long getConnectionsSeenAtMs(){return connectionsSeenAtMs;} public void setConnectionsSeenAtMs(long v){connectionsSeenAtMs=v;}
|
||||
public long getEventsSeenAtMs(){return eventsSeenAtMs;} public void setEventsSeenAtMs(long v){eventsSeenAtMs=v;}
|
||||
public long getRepliesUnseenCount(){return repliesUnseenCount;} public void setRepliesUnseenCount(long v){repliesUnseenCount=v;}
|
||||
public long getConnectionsUnseenCount(){return connectionsUnseenCount;} public void setConnectionsUnseenCount(long v){connectionsUnseenCount=v;}
|
||||
public long getEventsUnseenCount(){return eventsUnseenCount;} public void setEventsUnseenCount(long v){eventsUnseenCount=v;}
|
||||
public List<NotificationItem> getEvents() { return events; }
|
||||
public void setEvents(List<NotificationItem> events) { this.events = events; }
|
||||
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
public class Net_SetNotificationState_Request extends Net_Request { private String blobB64; public String getBlobB64(){return blobB64;} public void setBlobB64(String v){blobB64=v;} }
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
public class Net_SetNotificationState_Response extends Net_Response { private String category; private long seenAtMs; public String getCategory(){return category;} public void setCategory(String v){category=v;} public long getSeenAtMs(){return seenAtMs;} public void setSeenAtMs(long v){seenAtMs=v;} }
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.profile;
|
||||
import server.logic.ws_protocol.JSON.*;import server.logic.ws_protocol.JSON.entyties.*;import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;import server.logic.ws_protocol.JSON.handlers.profile.entyties.*;import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;import server.logic.ws_protocol.WireCodes;import shine.db.dao.UserProfileStateDAO;import java.sql.*;import java.util.*;
|
||||
public class Net_ListUserProfileChannels_Handler implements JsonMessageHandler {public Net_Response handle(Net_Request base,ConnectionContext ctx)throws Exception{var req=(Net_ListUserProfileChannels_Request)base;if(req.getLogin()==null||req.getLogin().isBlank())return NetExceptionResponseFactory.error(req,400,"BAD_FIELDS","login required");try(Connection c=shine.db.DbController.getInstance().getConnection()){var rows=UserProfileStateDAO.getInstance().listPublicChannels(c,req.getLogin(),req.getMode(),req.getLimit()==null?100:req.getLimit(),req.getOffset()==null?0:req.getOffset());var resp=new Net_ListUserProfileChannels_Response();resp.setOp(req.getOp());resp.setRequestId(req.getRequestId());resp.setStatus(WireCodes.Status.OK);resp.setLogin(req.getLogin());resp.setMode(req.getMode());List<Net_ListUserProfileChannels_Response.ChannelItem> items=new ArrayList<>();for(var r:rows){var i=new Net_ListUserProfileChannels_Response.ChannelItem();i.setOwnerLogin(r.ownerLogin());i.setSlug(r.slug());i.setDisplayName(r.displayName());i.setAvatarAr(r.avatarAr());i.setOwnerBlockchainName(r.ownerBlockchainName());i.setRootBlockNumber(r.rootBlockNumber());i.setRootBlockHashHex(r.rootBlockHashHex());items.add(i);}resp.setChannels(items);return resp;}}}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.profile;
|
||||
import server.logic.ws_protocol.JSON.*; import server.logic.ws_protocol.JSON.entyties.*; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.entyties.*; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.UserProfileStateDAO; import java.sql.*; import java.util.*;
|
||||
public class Net_ListUserProfileRelations_Handler implements JsonMessageHandler {
|
||||
public Net_Response handle(Net_Request base, ConnectionContext ctx)throws Exception{
|
||||
var req=(Net_ListUserProfileRelations_Request)base; if(req.getLogin()==null||req.getLogin().isBlank()) return NetExceptionResponseFactory.error(req,400,"BAD_FIELDS","login required");
|
||||
try(Connection c=shine.db.DbController.getInstance().getConnection()){
|
||||
var rows=UserProfileStateDAO.getInstance().listRelations(c,req.getLogin(),req.getListType(),req.getLimit()==null?100:req.getLimit(),req.getOffset()==null?0:req.getOffset());
|
||||
var resp=new Net_ListUserProfileRelations_Response(); resp.setOp(req.getOp());resp.setRequestId(req.getRequestId());resp.setStatus(WireCodes.Status.OK);resp.setLogin(req.getLogin());resp.setListType(req.getListType());
|
||||
List<Net_ListUserProfileRelations_Response.UserItem> items=new ArrayList<>(); for(var r:rows){var i=new Net_ListUserProfileRelations_Response.UserItem();i.setLogin(r.login());i.setFirstName(r.firstName());i.setLastName(r.lastName());i.setAvatarAr(r.avatarAr());i.setAccountRole(r.accountRole());i.setShineStatus(r.shineStatus());i.setRelationType(r.relationType());i.setPrimaryConfirmed(r.primaryConfirmed());i.setShineConfirmed(r.shineConfirmed());i.setPrimaryConfirmationsCount(r.primaryConfirmationsCount());i.setShineConfirmationsCount(r.shineConfirmationsCount());items.add(i);} resp.setUsers(items);return resp;
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.profile.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
public class Net_ListUserProfileChannels_Request extends Net_Request {private String login,mode;private Integer limit,offset;public String getLogin(){return login;}public void setLogin(String v){login=v;}public String getMode(){return mode;}public void setMode(String v){mode=v;}public Integer getLimit(){return limit;}public void setLimit(Integer v){limit=v;}public Integer getOffset(){return offset;}public void setOffset(Integer v){offset=v;}}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.profile.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;import java.util.*;
|
||||
public class Net_ListUserProfileChannels_Response extends Net_Response {private String login,mode;private List<ChannelItem> channels=new ArrayList<>();public String getLogin(){return login;}public void setLogin(String v){login=v;}public String getMode(){return mode;}public void setMode(String v){mode=v;}public List<ChannelItem> getChannels(){return channels;}public void setChannels(List<ChannelItem> v){channels=v;}public static class ChannelItem{private String ownerLogin,slug,displayName,avatarAr,ownerBlockchainName,rootBlockHashHex;private int rootBlockNumber;public String getOwnerLogin(){return ownerLogin;}public void setOwnerLogin(String v){ownerLogin=v;}public String getSlug(){return slug;}public void setSlug(String v){slug=v;}public String getDisplayName(){return displayName;}public void setDisplayName(String v){displayName=v;}public String getAvatarAr(){return avatarAr;}public void setAvatarAr(String v){avatarAr=v;}public String getOwnerBlockchainName(){return ownerBlockchainName;}public void setOwnerBlockchainName(String v){ownerBlockchainName=v;}public int getRootBlockNumber(){return rootBlockNumber;}public void setRootBlockNumber(int v){rootBlockNumber=v;}public String getRootBlockHashHex(){return rootBlockHashHex;}public void setRootBlockHashHex(String v){rootBlockHashHex=v;}}}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.profile.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
public class Net_ListUserProfileRelations_Request extends Net_Request {
|
||||
private String login; private String listType; private Integer limit; private Integer offset;
|
||||
public String getLogin(){return login;} public void setLogin(String v){login=v;}
|
||||
public String getListType(){return listType;} public void setListType(String v){listType=v;}
|
||||
public Integer getLimit(){return limit;} public void setLimit(Integer v){limit=v;}
|
||||
public Integer getOffset(){return offset;} public void setOffset(Integer v){offset=v;}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.profile.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import java.util.*;
|
||||
public class Net_ListUserProfileRelations_Response extends Net_Response {
|
||||
private String login; private String listType; private List<UserItem> users=new ArrayList<>();
|
||||
public String getLogin(){return login;} public void setLogin(String v){login=v;}
|
||||
public String getListType(){return listType;} public void setListType(String v){listType=v;}
|
||||
public List<UserItem> getUsers(){return users;} public void setUsers(List<UserItem> v){users=v;}
|
||||
public static class UserItem {
|
||||
private String login,firstName,lastName,avatarAr,accountRole,shineStatus,relationType;
|
||||
private boolean primaryConfirmed,shineConfirmed; private int primaryConfirmationsCount,shineConfirmationsCount;
|
||||
public String getLogin(){return login;} public void setLogin(String v){login=v;}
|
||||
public String getFirstName(){return firstName;} public void setFirstName(String v){firstName=v;}
|
||||
public String getLastName(){return lastName;} public void setLastName(String v){lastName=v;}
|
||||
public String getAvatarAr(){return avatarAr;} public void setAvatarAr(String v){avatarAr=v;}
|
||||
public String getAccountRole(){return accountRole;} public void setAccountRole(String v){accountRole=v;}
|
||||
public String getShineStatus(){return shineStatus;} public void setShineStatus(String v){shineStatus=v;}
|
||||
public String getRelationType(){return relationType;} public void setRelationType(String v){relationType=v;}
|
||||
public boolean isPrimaryConfirmed(){return primaryConfirmed;} public void setPrimaryConfirmed(boolean v){primaryConfirmed=v;}
|
||||
public boolean isShineConfirmed(){return shineConfirmed;} public void setShineConfirmed(boolean v){shineConfirmed=v;}
|
||||
public int getPrimaryConfirmationsCount(){return primaryConfirmationsCount;} public void setPrimaryConfirmationsCount(int v){primaryConfirmationsCount=v;}
|
||||
public int getShineConfirmationsCount(){return shineConfirmationsCount;} public void setShineConfirmationsCount(int v){shineConfirmationsCount=v;}
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -136,11 +136,21 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
|
||||
resp.setFollowingUsersCount(0);
|
||||
resp.setFollowingChannelsCount(0);
|
||||
resp.setCloseFriendsCount(0);
|
||||
resp.setFriendsCount(0);
|
||||
resp.setPrimaryConfirmationsReceivedCount(0);
|
||||
resp.setPrimaryConfirmationsGivenCount(0);
|
||||
resp.setShineConfirmationsReceivedCount(0);
|
||||
resp.setShineConfirmationsGivenCount(0);
|
||||
String sql = """
|
||||
SELECT owned_public_channels_count,
|
||||
following_users_count,
|
||||
following_channels_count,
|
||||
close_friends_count
|
||||
close_friends_count,
|
||||
friends_count,
|
||||
primary_confirmations_received_count,
|
||||
primary_confirmations_given_count,
|
||||
shine_confirmations_received_count,
|
||||
shine_confirmations_given_count
|
||||
FROM user_stats_state
|
||||
WHERE login = ?
|
||||
LIMIT 1
|
||||
@@ -156,6 +166,11 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
|
||||
resp.setFollowingUsersCount(rs.getInt("following_users_count"));
|
||||
resp.setFollowingChannelsCount(rs.getInt("following_channels_count"));
|
||||
resp.setCloseFriendsCount(rs.getInt("close_friends_count"));
|
||||
resp.setFriendsCount(rs.getInt("friends_count"));
|
||||
resp.setPrimaryConfirmationsReceivedCount(rs.getInt("primary_confirmations_received_count"));
|
||||
resp.setPrimaryConfirmationsGivenCount(rs.getInt("primary_confirmations_given_count"));
|
||||
resp.setShineConfirmationsReceivedCount(rs.getInt("shine_confirmations_received_count"));
|
||||
resp.setShineConfirmationsGivenCount(rs.getInt("shine_confirmations_given_count"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("GetUser: не удалось загрузить статистику для login={}", login, e);
|
||||
|
||||
+15
@@ -47,6 +47,11 @@ public class Net_GetUser_Response extends Net_Response {
|
||||
private Integer followingUsersCount;
|
||||
private Integer followingChannelsCount;
|
||||
private Integer closeFriendsCount;
|
||||
private Integer friendsCount;
|
||||
private Integer primaryConfirmationsReceivedCount;
|
||||
private Integer primaryConfirmationsGivenCount;
|
||||
private Integer shineConfirmationsReceivedCount;
|
||||
private Integer shineConfirmationsGivenCount;
|
||||
|
||||
public Boolean getExists() { return exists; }
|
||||
public void setExists(Boolean exists) { this.exists = exists; }
|
||||
@@ -89,5 +94,15 @@ public class Net_GetUser_Response extends Net_Response {
|
||||
|
||||
public Integer getCloseFriendsCount() { return closeFriendsCount; }
|
||||
public void setCloseFriendsCount(Integer closeFriendsCount) { this.closeFriendsCount = closeFriendsCount; }
|
||||
public Integer getFriendsCount() { return friendsCount; }
|
||||
public void setFriendsCount(Integer friendsCount) { this.friendsCount = friendsCount; }
|
||||
public Integer getPrimaryConfirmationsReceivedCount() { return primaryConfirmationsReceivedCount; }
|
||||
public void setPrimaryConfirmationsReceivedCount(Integer v) { this.primaryConfirmationsReceivedCount = v; }
|
||||
public Integer getPrimaryConfirmationsGivenCount() { return primaryConfirmationsGivenCount; }
|
||||
public void setPrimaryConfirmationsGivenCount(Integer v) { this.primaryConfirmationsGivenCount = v; }
|
||||
public Integer getShineConfirmationsReceivedCount() { return shineConfirmationsReceivedCount; }
|
||||
public void setShineConfirmationsReceivedCount(Integer v) { this.shineConfirmationsReceivedCount = v; }
|
||||
public Integer getShineConfirmationsGivenCount() { return shineConfirmationsGivenCount; }
|
||||
public void setShineConfirmationsGivenCount(Integer v) { this.shineConfirmationsGivenCount = v; }
|
||||
|
||||
}
|
||||
|
||||
+36
@@ -11,6 +11,10 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Res
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.SignedMessagesDAO;
|
||||
import shine.db.dao.UserProfileStateDAO;
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import shine.db.dao.DmDeliveryStateDAO;
|
||||
import shine.db.entities.DmDeliveryStateEntry;
|
||||
import shine.db.entities.SignedMessageEntry;
|
||||
@@ -74,6 +78,21 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
|
||||
resp.setLimit(limit);
|
||||
resp.setHasMore(hasMore);
|
||||
|
||||
// Return a normalized peer card together with the dialog. This makes the
|
||||
// chat header self-contained even when /chat/<login> is opened directly.
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
UserProfileStateDAO.ProfileCard card = UserProfileStateDAO.getInstance().get(c, peerLogin);
|
||||
Net_GetDirectMessages_Response.PeerCard peer = new Net_GetDirectMessages_Response.PeerCard();
|
||||
peer.setLogin(peerLogin);
|
||||
peer.setFirstName(card.firstName());
|
||||
peer.setLastName(card.lastName());
|
||||
peer.setRelationType(UserProfileStateDAO.getInstance().getEffectiveRelationType(c, login, peerLogin));
|
||||
peer.setAccountRole(card.accountRole());
|
||||
peer.setShineStatus(card.shineStatus());
|
||||
peer.setAvatar(parseAvatar(card.avatarAr()));
|
||||
resp.setPeer(peer);
|
||||
}
|
||||
|
||||
List<Net_GetDirectMessages_Response.MessageItem> items = new ArrayList<>();
|
||||
for (SignedMessageEntry entry : page) {
|
||||
Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem();
|
||||
@@ -108,4 +127,21 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
private static Net_GetDirectMessages_Response.Avatar parseAvatar(String value) {
|
||||
Net_GetDirectMessages_Response.Avatar out = new Net_GetDirectMessages_Response.Avatar();
|
||||
String raw = value == null ? "" : value.trim();
|
||||
|
||||
// Do not call Matcher.find() twice on the same matcher: the first successful
|
||||
// call advances it and the second one can make a perfectly valid avatar vanish.
|
||||
java.util.regex.Matcher ar = java.util.regex.Pattern.compile("AR:([A-Za-z0-9_-]{43})").matcher(raw);
|
||||
if (ar.find()) {
|
||||
out.setAr(ar.group(1));
|
||||
}
|
||||
java.util.regex.Matcher sha = java.util.regex.Pattern.compile("SHA256:([A-Fa-f0-9]{64})").matcher(raw);
|
||||
if (sha.find()) {
|
||||
out.setSha256Hex(sha.group(1).toLowerCase());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+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(),
|
||||
|
||||
+38
@@ -12,6 +12,7 @@ public class Net_GetDirectMessages_Response extends Net_Response {
|
||||
private boolean hasMore;
|
||||
private Long nextBeforeTimeMs;
|
||||
private String nextBeforeMessageKey;
|
||||
private PeerCard peer;
|
||||
private List<MessageItem> messages = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
@@ -26,9 +27,46 @@ public class Net_GetDirectMessages_Response extends Net_Response {
|
||||
public void setNextBeforeTimeMs(Long nextBeforeTimeMs) { this.nextBeforeTimeMs = nextBeforeTimeMs; }
|
||||
public String getNextBeforeMessageKey() { return nextBeforeMessageKey; }
|
||||
public void setNextBeforeMessageKey(String nextBeforeMessageKey) { this.nextBeforeMessageKey = nextBeforeMessageKey; }
|
||||
public PeerCard getPeer() { return peer; }
|
||||
public void setPeer(PeerCard peer) { this.peer = peer; }
|
||||
public List<MessageItem> getMessages() { return messages; }
|
||||
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
||||
|
||||
|
||||
public static class PeerCard {
|
||||
private String login;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private Avatar avatar;
|
||||
private String relationType;
|
||||
private String accountRole;
|
||||
private String shineStatus;
|
||||
|
||||
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 Avatar getAvatar() { return avatar; }
|
||||
public void setAvatar(Avatar avatar) { this.avatar = avatar; }
|
||||
public String getRelationType() { return relationType; }
|
||||
public void setRelationType(String relationType) { this.relationType = relationType; }
|
||||
public String getAccountRole() { return accountRole; }
|
||||
public void setAccountRole(String accountRole) { this.accountRole = accountRole; }
|
||||
public String getShineStatus() { return shineStatus; }
|
||||
public void setShineStatus(String shineStatus) { this.shineStatus = shineStatus; }
|
||||
}
|
||||
|
||||
public static class Avatar {
|
||||
private String ar;
|
||||
private String sha256Hex;
|
||||
public String getAr() { return ar; }
|
||||
public void setAr(String ar) { this.ar = ar; }
|
||||
public String getSha256Hex() { return sha256Hex; }
|
||||
public void setSha256Hex(String sha256Hex) { this.sha256Hex = sha256Hex; }
|
||||
}
|
||||
|
||||
public static class MessageItem {
|
||||
private String messageKey;
|
||||
private String baseKey;
|
||||
|
||||
+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;
|
||||
|
||||
+1
-5
@@ -4,7 +4,6 @@ import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
/**
|
||||
* In-memory guard для цепочек, которые сейчас находятся в полном resync.
|
||||
@@ -39,10 +38,7 @@ public final class BlockchainResyncGuard {
|
||||
if (isBypassed(key)) {
|
||||
return false;
|
||||
}
|
||||
if (ACTIVE.contains(key)) {
|
||||
return true;
|
||||
}
|
||||
return FileStoreUtil.getInstance().existsBlockchainResyncMarker(key);
|
||||
return ACTIVE.contains(key);
|
||||
}
|
||||
|
||||
public static <T> T withBypass(String blockchainName, ThrowingSupplier<T> supplier) throws Exception {
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package server.sync;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.entities.SyncServerEntry;
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Startup-recovery для цепочек, которые были помечены как resync-pending.
|
||||
*
|
||||
* Правило простое:
|
||||
* - marker-file означает, что chain должен быть пересобран заново;
|
||||
* - мы не пытаемся продолжать с середины;
|
||||
* - если recovery не завершён, обычная работа сервера не стартует.
|
||||
*/
|
||||
public final class BlockchainResyncRecoveryOnStartup {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BlockchainResyncRecoveryOnStartup.class);
|
||||
private static final RemoteBlockchainSyncClient REMOTE = new RemoteBlockchainSyncClient();
|
||||
|
||||
private BlockchainResyncRecoveryOnStartup() {}
|
||||
|
||||
public static void runRecoveryOrThrow() {
|
||||
Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME);
|
||||
ensureDirExists(dataDir);
|
||||
|
||||
List<Path> markers = listMarkerFiles(dataDir);
|
||||
if (markers.isEmpty()) {
|
||||
log.info("🟢 BlockchainResyncRecovery: resync marker-файлы не найдены.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.warn("🟡 BlockchainResyncRecovery: найдено marker-файлов: {}", markers.size());
|
||||
for (Path marker : markers) {
|
||||
recoverSingleMarkerOrThrow(marker);
|
||||
}
|
||||
log.info("✅ BlockchainResyncRecovery: все marker-файлы обработаны.");
|
||||
}
|
||||
|
||||
private static void recoverSingleMarkerOrThrow(Path markerPath) {
|
||||
String fileName = markerPath.getFileName().toString();
|
||||
String blockchainName = extractBlockchainName(fileName);
|
||||
if (blockchainName == null || blockchainName.isBlank()) {
|
||||
throw new IllegalStateException("Bad resync marker name: " + fileName);
|
||||
}
|
||||
|
||||
Map<String, String> meta = parseMarker(markerPath);
|
||||
String partnerLogin = normalize(meta.get("partnerLogin"));
|
||||
String partnerAddress = normalize(meta.get("partnerAddress"));
|
||||
if (partnerAddress == null) {
|
||||
throw new IllegalStateException("Resync marker has no partnerAddress for blockchainName=" + blockchainName);
|
||||
}
|
||||
|
||||
log.warn("🔁 BlockchainResyncRecovery: processing marker blockchainName={} partnerLogin={} partnerAddress={}",
|
||||
blockchainName, partnerLogin, partnerAddress);
|
||||
|
||||
try {
|
||||
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads =
|
||||
REMOTE.listBlockchainHeads(partnerLogin, partnerAddress);
|
||||
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream()
|
||||
.filter(h -> h != null && blockchainName.equals(h.blockchainName()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Partner does not expose blockchainName=" + blockchainName + " address=" + partnerAddress));
|
||||
|
||||
SyncServerEntry partner = new SyncServerEntry(
|
||||
partnerLogin == null ? "" : partnerLogin,
|
||||
partnerAddress,
|
||||
System.currentTimeMillis()
|
||||
);
|
||||
|
||||
PeriodicBlockchainSyncService.resyncFromScratch(partner, remoteHead);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(
|
||||
"Не удалось восстановить resync-помеченную цепочку blockchainName=" + blockchainName,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Path> listMarkerFiles(Path dataDir) {
|
||||
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) {
|
||||
return stream(ds).toList();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot list resync markers in: " + dataDir, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.stream.Stream<Path> stream(DirectoryStream<Path> ds) {
|
||||
return java.util.stream.StreamSupport.stream(ds.spliterator(), false)
|
||||
.filter(Files::isRegularFile);
|
||||
}
|
||||
|
||||
private static Map<String, String> parseMarker(Path path) {
|
||||
try {
|
||||
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
|
||||
Map<String, String> result = new HashMap<>();
|
||||
for (String line : lines) {
|
||||
if (line == null) continue;
|
||||
String s = line.trim();
|
||||
if (s.isEmpty() || s.startsWith("#")) continue;
|
||||
int idx = s.indexOf('=');
|
||||
if (idx <= 0) continue;
|
||||
String key = s.substring(0, idx).trim();
|
||||
String value = s.substring(idx + 1).trim();
|
||||
result.put(key, value);
|
||||
}
|
||||
return result;
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot read resync marker: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureDirExists(Path dir) {
|
||||
try {
|
||||
if (!Files.exists(dir)) {
|
||||
Files.createDirectories(dir);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Cannot create data dir: " + dir, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractBlockchainName(String fileName) {
|
||||
if (fileName == null) return null;
|
||||
String s = fileName.trim();
|
||||
if (!s.endsWith(FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) {
|
||||
return null;
|
||||
}
|
||||
return s.substring(0, s.length() - FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION.length());
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
if (value == null) return null;
|
||||
String s = value.trim();
|
||||
return s.isEmpty() ? null : s.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user