SHA256
refactor: перенос серверных модулей в папку SHiNE-server
This commit is contained in:
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
#Mon Oct 20 16:15:09 MSK 2025
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
group = 'shine'
|
||||
version = '1.0.0'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// модуль блокчейна использует крипту
|
||||
implementation project(':shine-server-crypto')
|
||||
|
||||
// JSON (BchInfoManager)
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.1'
|
||||
|
||||
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
|
||||
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
|
||||
|
||||
implementation project(':shine-server-config') // модуль с настройками
|
||||
implementation project(":shine-server-log") // модуль логирования и уведомления админов
|
||||
implementation project(':shine-server-db') // модуль для работы с БД содержит и сущности из БД и саму работу с БД
|
||||
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTFILE="all_files.txt"
|
||||
|
||||
# очищаем или создаём файл
|
||||
: > "$OUTFILE"
|
||||
|
||||
# собрать только *.java файлы и вывести их содержимое в файл
|
||||
find . -type f -name "*.java" | sort | while read -r f; do
|
||||
cat "$f" >> "$OUTFILE"
|
||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
||||
done
|
||||
|
||||
# скопировать весь файл в буфер обмена (Wayland)
|
||||
wl-copy < "$OUTFILE"
|
||||
|
||||
echo "Готово!"
|
||||
echo "Все .java файлы собраны в $OUTFILE"
|
||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
||||
@@ -0,0 +1,372 @@
|
||||
package blockchain;
|
||||
|
||||
import blockchain.body.BodyRecord;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* BchBlockEntry — универсальный блок формата SHiNE (Frame v0).
|
||||
*
|
||||
* =========================================================================
|
||||
* FRAME v0 — ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА)
|
||||
* =========================================================================
|
||||
*
|
||||
* Все числа BigEndian.
|
||||
*
|
||||
* 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".
|
||||
* =========================================================================
|
||||
*/
|
||||
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 long MAX_FUTURE_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Размер фиксированной части 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 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
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
// [2] frameCode
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
// [32] prevHash32
|
||||
this.prevHash32 = new byte[32];
|
||||
bb.get(this.prevHash32);
|
||||
|
||||
// [4] blockSize
|
||||
this.blockSize = bb.getInt();
|
||||
if (blockSize < PREIMAGE_HEADER_SIZE) {
|
||||
throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE);
|
||||
}
|
||||
|
||||
// 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
|
||||
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);
|
||||
}
|
||||
|
||||
// [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
|
||||
));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
private static String toHex(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 vv = bytes[i] & 0xFF;
|
||||
out[i * 2] = HEX[vv >>> 4];
|
||||
out[i * 2 + 1] = HEX[vv & 0x0F];
|
||||
}
|
||||
return new String(out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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)
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package blockchain;
|
||||
|
||||
import blockchain.body.*;
|
||||
|
||||
/**
|
||||
* Parser for body record by header type/subType/version.
|
||||
*/
|
||||
public final class BodyRecordParser {
|
||||
|
||||
private BodyRecordParser() {}
|
||||
|
||||
public static BodyRecord parse(short type, short subType, short version, byte[] bodyBytes) {
|
||||
if (bodyBytes == null) throw new IllegalArgumentException("bodyBytes == null");
|
||||
|
||||
int t = type & 0xFFFF;
|
||||
int v = version & 0xFFFF;
|
||||
int st = subType & 0xFFFF;
|
||||
|
||||
// TECH supports Header v1 and CreateChannel current format (ver=1).
|
||||
if (t == (CreateChannelBody.TYPE & 0xFFFF)) {
|
||||
if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF) && v == (HeaderBody.VER & 0xFFFF)) {
|
||||
return new HeaderBody(subType, version, bodyBytes).check();
|
||||
}
|
||||
if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)
|
||||
&& (v == (CreateChannelBody.VER & 0xFFFF))) {
|
||||
return new CreateChannelBody(subType, version, bodyBytes).check();
|
||||
}
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
|
||||
);
|
||||
}
|
||||
|
||||
int key = (t << 16) | v;
|
||||
|
||||
BodyRecord r = switch (key) {
|
||||
case TextBody.KEY -> {
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
yield new TextLineBody(subType, version, bodyBytes);
|
||||
}
|
||||
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||
yield new TextReplyBody(subType, version, bodyBytes);
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
|
||||
}
|
||||
|
||||
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
|
||||
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
|
||||
case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
|
||||
|
||||
default -> throw new IllegalArgumentException(String.format(
|
||||
"Unknown body type/version from header: type=%d ver=%d subType=%d",
|
||||
t, v, st
|
||||
));
|
||||
};
|
||||
|
||||
return r.check();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//package blockchain;
|
||||
//
|
||||
///**
|
||||
// * LineIndex — канонические номера линий блокчейна.
|
||||
// *
|
||||
// * Линия = независимая последовательность блоков внутри одного блокчейна.
|
||||
// */
|
||||
//public final class LineIndex {
|
||||
//
|
||||
// private LineIndex() {}
|
||||
//
|
||||
// public static final short HEADER = 0; // genesis / идентификация
|
||||
// public static final short TEXT = 1; // сообщения да надо
|
||||
// public static final short REACTION = 2; // реакции не надо
|
||||
// public static final short CONNECTION = 3; // связи (friend/contact/follow) да надо
|
||||
// public static final short USER_PARAM = 4; // параметры профиля да надо
|
||||
//}
|
||||
@@ -0,0 +1,141 @@
|
||||
package blockchain;
|
||||
|
||||
/**
|
||||
* MsgSubType — единое место для ВСЕХ subType сообщений (msg_sub_type).
|
||||
*
|
||||
* Правило:
|
||||
* - НИКАКИХ "магических чисел" subType по проекту.
|
||||
* - В тестах, в body-классах и в SQL-триггерах используем только эти константы.
|
||||
*
|
||||
* Важно:
|
||||
* - Значения менять после релиза нельзя (иначе сломается совместимость).
|
||||
*
|
||||
* =========================================================================
|
||||
* Про EDIT-типы (важные правила, чтобы не было “двойных правок”):
|
||||
*
|
||||
* 1) EDIT разрешён ТОЛЬКО автору (в своём блокчейне).
|
||||
* Никаких “я отредачу чужое” — нельзя.
|
||||
*
|
||||
* 2) EDIT всегда ссылается ТОЛЬКО на ОРИГИНАЛ:
|
||||
* - EDIT_POST -> на исходный POST
|
||||
* - EDIT_REPLY -> на исходный REPLY
|
||||
* НЕЛЬЗЯ ссылаться на предыдущий EDIT (цепочка edit-ов запрещена).
|
||||
*
|
||||
* 3) REPLY может ссылаться на блоки в чужих линиях / чужих каналах,
|
||||
* и существование цели на уровне check() не проверяется
|
||||
* (check() БД не видит). Если цели нет — “никто не увидит” и ок.
|
||||
* =========================================================================
|
||||
*/
|
||||
public final class MsgSubType {
|
||||
|
||||
private MsgSubType() {}
|
||||
|
||||
/* ===================== HEADER (msg_type=0) ===================== */
|
||||
|
||||
/** HeaderBody: subType всегда 0 (compat). */
|
||||
public static final short HEADER_COMPAT = 0;
|
||||
public static final short TECH_CREATE_CHANNEL = 1;
|
||||
|
||||
/* ===================== TEXT (msg_type=1) ===================== */
|
||||
|
||||
/**
|
||||
* POST — обычный пост в канале (в линии канала).
|
||||
* Имеет hasLine (prevLineNumber/prevLineHash32/thisLineNumber).
|
||||
*/
|
||||
public static final short TEXT_POST = 10;
|
||||
|
||||
/**
|
||||
* EDIT_POST — редактирование ПОСТА.
|
||||
* Имеет hasLine (принадлежит линии канала)
|
||||
* И имеет target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName).
|
||||
*/
|
||||
public static final short TEXT_EDIT_POST = 11;
|
||||
|
||||
/**
|
||||
* REPLY — ответ на сообщение.
|
||||
* НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32).
|
||||
* Может указывать на чужой блокчейн/чужую линию/чужой канал.
|
||||
*/
|
||||
public static final short TEXT_REPLY = 20;
|
||||
|
||||
/**
|
||||
* EDIT_REPLY — редактирование ОТВЕТА.
|
||||
* НЕ в линии. Имеет target на ОРИГИНАЛЬНЫЙ REPLY (без toBlockchainName).
|
||||
*/
|
||||
public static final short TEXT_EDIT_REPLY = 21;
|
||||
|
||||
/**
|
||||
* REPOST — репост сообщения в линии канала.
|
||||
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
||||
*/
|
||||
public static final short TEXT_REPOST = 30;
|
||||
|
||||
/* ===================== REACTION (msg_type=2) ===================== */
|
||||
|
||||
/** Лайк (LIKE). */
|
||||
public static final short REACTION_LIKE = 1;
|
||||
/** Снятие лайка (UNLIKE). */
|
||||
public static final short REACTION_UNLIKE = 2;
|
||||
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
|
||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
/** Удалить из контактов. */
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
|
||||
/** Подписаться (follow). */
|
||||
public static final short CONNECTION_FOLLOW = 30;
|
||||
/** Отписаться (unfollow). */
|
||||
public static final short CONNECTION_UNFOLLOW = 31;
|
||||
|
||||
/** Добавить связь "жена/муж". */
|
||||
public static final short CONNECTION_SPOUSE = 40;
|
||||
/** Удалить связь "жена/муж". */
|
||||
public static final short CONNECTION_UNSPOUSE = 41;
|
||||
|
||||
/** Добавить связь "родитель". */
|
||||
public static final short CONNECTION_PARENT = 50;
|
||||
/** Удалить связь "родитель". */
|
||||
public static final short CONNECTION_UNPARENT = 51;
|
||||
|
||||
/** Добавить связь "ребёнок". */
|
||||
public static final short CONNECTION_CHILD = 52;
|
||||
/** Удалить связь "ребёнок". */
|
||||
public static final short CONNECTION_UNCHILD = 53;
|
||||
|
||||
/** Добавить связь "брат/сестра". */
|
||||
public static final short CONNECTION_SIBLING = 54;
|
||||
/** Удалить связь "брат/сестра". */
|
||||
public static final short CONNECTION_UNSIBLING = 55;
|
||||
|
||||
/** Просто знаю этого человека. */
|
||||
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;
|
||||
|
||||
/** Мало знаком, но видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||
/** Не отмечаю, что видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||
|
||||
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||
|
||||
/** Параметр профиля key/value (обе строки). */
|
||||
public static final short USER_PARAM_TEXT_TEXT = 1;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package blockchain.body;
|
||||
|
||||
/**
|
||||
* BodyHasLine — для типов, которые имеют линейные поля в body.
|
||||
*
|
||||
* Line-prefix (BigEndian) в НАЧАЛЕ bodyBytes:
|
||||
* [4] lineCode код линии (root-идентификатор):
|
||||
* - 0 для дефолтной линии/канала "0" (root = HEADER, blockNumber=0)
|
||||
* - для канала "X": blockNumber root-блока канала (CREATE_CHANNEL)
|
||||
*
|
||||
* [4] prevLineBlockGlobalNumber глобальный номер предыдущего блока в этой линии
|
||||
* [32] prevLineBlockHash32 hash32 предыдущего блока в этой линии
|
||||
*
|
||||
* [4] lineSeq порядковый номер сообщения внутри линии (1..N)
|
||||
*
|
||||
* Важно:
|
||||
* - Проверка связности линии (prevLineBlockGlobalNumber ↔ prevLineBlockHash32) и корректности lineSeq
|
||||
* выполняется на сервере/в БД при вставке (а не в body.check()).
|
||||
*/
|
||||
public interface BodyHasLine {
|
||||
|
||||
int lineCode();
|
||||
|
||||
int prevLineBlockGlobalNumber();
|
||||
|
||||
byte[] prevLineBlockHash32();
|
||||
|
||||
int lineSeq();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package blockchain.body;
|
||||
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
/**
|
||||
* BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля).
|
||||
*
|
||||
* Новое правило:
|
||||
* - toLogin НЕ храним в байтах блока.
|
||||
* - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN".
|
||||
*
|
||||
* Все методы могут возвращать null.
|
||||
*/
|
||||
public interface BodyHasTarget {
|
||||
|
||||
/** login цели (nullable). Вычисляется из toBchName(). */
|
||||
default String toLogin() {
|
||||
String bch = toBchName();
|
||||
if (bch == null) return null;
|
||||
return BlockchainNameUtil.loginFromBlockchainName(bch);
|
||||
}
|
||||
|
||||
/** blockchainName цели (nullable). */
|
||||
String toBchName();
|
||||
|
||||
/** globalNumber цели (nullable). */
|
||||
Integer toBlockGlobalNumber();
|
||||
|
||||
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
|
||||
byte[] toBlockHashBytes();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package blockchain.body;
|
||||
|
||||
/**
|
||||
* BodyRecord — общий контракт для всех типов body (тела блока).
|
||||
*
|
||||
* ВАЖНО (новый формат):
|
||||
* - type/subType/version НЕ лежат в bodyBytes.
|
||||
* - type/subType/version читаются из заголовка блока (BchBlockEntry).
|
||||
*
|
||||
* Поэтому из интерфейса УБРАНЫ:
|
||||
* - type()
|
||||
* - subType()
|
||||
* - version()
|
||||
* - expectedLineIndex()
|
||||
*/
|
||||
public interface BodyRecord {
|
||||
|
||||
/** Проверить корректность содержимого и вернуть этот объект (или кинуть исключение). */
|
||||
BodyRecord check();
|
||||
|
||||
/**
|
||||
* Сериализовать тело записи в байты (ровно то, что кладётся в block.bodyBytes).
|
||||
* Важно: НЕ включает type/subType/version.
|
||||
*/
|
||||
byte[] toBytes();
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
package blockchain.body;
|
||||
|
||||
import blockchain.MsgSubType;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* ConnectionBody — type=3, ver=1 (в заголовке блока).
|
||||
*
|
||||
* subType (в заголовке блока) как MsgSubType:
|
||||
* FRIEND=10, UNFRIEND=11
|
||||
* CONTACT=20, UNCONTACT=21
|
||||
* FOLLOW=30, UNFOLLOW=31
|
||||
* SPOUSE=40, UNSPOUSE=41
|
||||
* PARENT=50, UNPARENT=51
|
||||
* CHILD=52, UNCHILD=53
|
||||
* SIBLING=54, UNSIBLING=55
|
||||
* KNOWN_PERSON=60, UNKNOWN_PERSON=61
|
||||
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
|
||||
* SHINE_SEEN=74, SHINE_UNSEEN=75
|
||||
*
|
||||
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
*
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [32] toBlockHash32 (raw 32 bytes)
|
||||
*
|
||||
* toLogin вычисляется автоматически из toBlockchainName:
|
||||
* toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName)
|
||||
*/
|
||||
|
||||
/**
|
||||
* =========================================================================
|
||||
* ПРАВИЛО TARGET/ROOT ДЛЯ КАНАЛОВ И СВЯЗЕЙ (важно для подписок/друзей/контактов)
|
||||
* =========================================================================
|
||||
*
|
||||
* Термины:
|
||||
* - ROOT линии/канала = блок, который "начинает" линию:
|
||||
* * для канала "0" root = HEADER (blockNumber=0)
|
||||
* * для канала "X" root = CREATE_CHANNEL (blockNumber этого блока)
|
||||
*
|
||||
* 1) СВЯЗИ МЕЖДУ ПОЛЬЗОВАТЕЛЯМИ (CONNECTION_*):
|
||||
* FRIEND / CONTACT -> цель ВСЕГДА HEADER пользователя:
|
||||
* toBlockNumber = 0
|
||||
* toBlockHash32 = hash32(HEADER цели)
|
||||
*
|
||||
* 2) ПОДПИСКИ НА КОНТЕНТ (FOLLOW/SUBSCRIBE):
|
||||
* FOLLOW пользователя (в целом) -> цель = ROOT дефолтного канала "0" (то есть HEADER):
|
||||
* toBlockNumber = 0
|
||||
* toBlockHash32 = hash32(HEADER цели)
|
||||
*
|
||||
* FOLLOW/подписка на конкретный канал пользователя ->
|
||||
* цель = ROOT этого канала:
|
||||
* - канал "0": toBlockNumber=0, toBlockHash32=hash32(HEADER)
|
||||
* - канал "X": toBlockNumber=blockNumber(CREATE_CHANNEL),
|
||||
* toBlockHash32=hash32(CREATE_CHANNEL)
|
||||
*
|
||||
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
|
||||
* - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
|
||||
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
|
||||
*
|
||||
* Зачем так:
|
||||
* - связи и подписки всегда стабильны и не ломаются при новых постах,
|
||||
* - один понятный инвариант: "подписка всегда указывает на root линии".
|
||||
* =========================================================================
|
||||
*/
|
||||
|
||||
public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {
|
||||
|
||||
public static final short TYPE = 3;
|
||||
public static final short VER = 1;
|
||||
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public final short subType; // из header
|
||||
public final short version; // из header
|
||||
|
||||
// line
|
||||
public final int lineCode;
|
||||
public final int prevLineNumber;
|
||||
public final byte[] prevLineHash32;
|
||||
public final int thisLineNumber;
|
||||
|
||||
// payload
|
||||
public final String toBlockchainName;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32;
|
||||
|
||||
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("ConnectionBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||
}
|
||||
if (!isValidSubType(this.subType)) {
|
||||
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
|
||||
}
|
||||
|
||||
// минимум:
|
||||
// lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32]
|
||||
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) {
|
||||
throw new IllegalArgumentException("ConnectionBody too short");
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
this.lineCode = bb.getInt();
|
||||
|
||||
this.prevLineNumber = bb.getInt();
|
||||
|
||||
this.prevLineHash32 = new byte[32];
|
||||
bb.get(this.prevLineHash32);
|
||||
|
||||
this.thisLineNumber = bb.getInt();
|
||||
|
||||
int bchLen = Byte.toUnsignedInt(bb.get());
|
||||
if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
||||
if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
|
||||
|
||||
byte[] bchBytes = new byte[bchLen];
|
||||
bb.get(bchBytes);
|
||||
this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8);
|
||||
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
|
||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||
}
|
||||
|
||||
public ConnectionBody(int lineCode,
|
||||
int prevLineNumber,
|
||||
byte[] prevLineHash32,
|
||||
int thisLineNumber,
|
||||
short subType,
|
||||
String toBlockchainName,
|
||||
int toBlockGlobalNumber,
|
||||
byte[] toBlockHash32) {
|
||||
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
||||
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
// Железное правило формата: bchName -> login + "-NNN"
|
||||
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) {
|
||||
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
|
||||
}
|
||||
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.lineCode = lineCode;
|
||||
|
||||
this.prevLineNumber = prevLineNumber;
|
||||
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
this.thisLineNumber = thisLineNumber;
|
||||
|
||||
this.subType = subType;
|
||||
this.version = VER;
|
||||
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
}
|
||||
|
||||
private static boolean isValidSubType(short st) {
|
||||
int v = st & 0xFFFF;
|
||||
return 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)
|
||||
|| v == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SPOUSE & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNSPOUSE & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_PARENT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNPARENT & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_CHILD & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNCHILD & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SIBLING & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNSIBLING & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_KNOWN_PERSON & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNKNOWN_PERSON & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionBody check() {
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
||||
|
||||
// line rule (как было)
|
||||
if (prevLineNumber == -1) {
|
||||
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
|
||||
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
|
||||
} else {
|
||||
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||
}
|
||||
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
|
||||
// гарантируем вычислимый toLogin (иначе target “битый” по стандарту)
|
||||
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null)
|
||||
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
|
||||
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (bchBytes.length == 0 || bchBytes.length > 255)
|
||||
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
||||
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
int cap = 4 + (4 + 32 + 4)
|
||||
+ 1 + bchBytes.length
|
||||
+ 4 + 32;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
bb.putInt(lineCode);
|
||||
|
||||
bb.putInt(prevLineNumber);
|
||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
bb.putInt(thisLineNumber);
|
||||
|
||||
bb.put((byte) bchBytes.length);
|
||||
bb.put(bchBytes);
|
||||
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
private static boolean isAllZero32(byte[] b) {
|
||||
if (b == null || b.length != 32) return true;
|
||||
for (int i = 0; i < 32; i++) if (b[i] != 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ====================== BodyHasLine ====================== */
|
||||
@Override public int lineCode() { return lineCode; }
|
||||
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
|
||||
@Override public int lineSeq() { return thisLineNumber; }
|
||||
|
||||
/* ====================== BodyHasTarget ===================== */
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
package blockchain.body;
|
||||
|
||||
import blockchain.MsgSubType;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* TECH body for create channel.
|
||||
*
|
||||
* body bytes:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
* [1] channelNameLen
|
||||
* [N] channelName UTF-8
|
||||
* [2] channelDescriptionLen
|
||||
* [M] channelDescription UTF-8 (0..200 bytes)
|
||||
* [2] channelTypeCode (uint16)
|
||||
* [2] channelTypeVersion (uint16)
|
||||
*/
|
||||
public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
|
||||
public static final short TYPE = 0;
|
||||
public static final short VER = 1;
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL;
|
||||
|
||||
public static final short CHANNEL_TYPE_STORIES = 0;
|
||||
public static final short CHANNEL_TYPE_PUBLIC = 1;
|
||||
public static final short CHANNEL_TYPE_PERSONAL = 100;
|
||||
public static final short CHANNEL_TYPE_GROUP = 200;
|
||||
public static final short CHANNEL_TYPE_VERSION_DEFAULT = 1;
|
||||
|
||||
private static final byte[] ZERO32 = new byte[32];
|
||||
private static final int MAX_NAME_LENGTH = 32;
|
||||
private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
|
||||
|
||||
public final short subType;
|
||||
public final short version;
|
||||
|
||||
public final int lineCode;
|
||||
public final int prevLineNumber;
|
||||
public final byte[] prevLineHash32;
|
||||
public final int thisLineNumber;
|
||||
|
||||
public final String channelName;
|
||||
public final String channelDescription;
|
||||
public final short channelTypeCode;
|
||||
public final short channelTypeVersion;
|
||||
|
||||
public CreateChannelBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
int ver = this.version & 0xFFFF;
|
||||
if (ver != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("CreateChannelBody version must be 1, got=" + ver);
|
||||
}
|
||||
if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF));
|
||||
}
|
||||
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 2 + 4) {
|
||||
throw new IllegalArgumentException("CreateChannelBody too short");
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
this.lineCode = bb.getInt();
|
||||
this.prevLineNumber = bb.getInt();
|
||||
this.prevLineHash32 = new byte[32];
|
||||
bb.get(this.prevLineHash32);
|
||||
this.thisLineNumber = bb.getInt();
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0");
|
||||
if (bb.remaining() < nameLen + 2 + 4) {
|
||||
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " nameLen=" + nameLen);
|
||||
}
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.channelName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
|
||||
int descriptionLen = Short.toUnsignedInt(bb.getShort());
|
||||
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||
}
|
||||
if (bb.remaining() != descriptionLen + 4) {
|
||||
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
|
||||
}
|
||||
if (descriptionLen == 0) {
|
||||
this.channelDescription = "";
|
||||
} else {
|
||||
byte[] descriptionBytes = new byte[descriptionLen];
|
||||
bb.get(descriptionBytes);
|
||||
this.channelDescription = normalizeDescription(new String(descriptionBytes, StandardCharsets.UTF_8));
|
||||
}
|
||||
this.channelTypeCode = bb.getShort();
|
||||
this.channelTypeVersion = bb.getShort();
|
||||
if (bb.remaining() != 0) {
|
||||
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||
}
|
||||
}
|
||||
|
||||
public CreateChannelBody(int lineCode,
|
||||
int prevLineNumber,
|
||||
byte[] prevLineHash32,
|
||||
int thisLineNumber,
|
||||
String channelName,
|
||||
String channelDescription,
|
||||
short channelTypeCode,
|
||||
short channelTypeVersion) {
|
||||
Objects.requireNonNull(channelName, "channelName == null");
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
|
||||
this.subType = SUBTYPE;
|
||||
this.version = VER;
|
||||
this.lineCode = lineCode;
|
||||
this.prevLineNumber = prevLineNumber;
|
||||
this.prevLineHash32 = (prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32));
|
||||
this.thisLineNumber = thisLineNumber;
|
||||
this.channelName = channelName;
|
||||
this.channelDescription = channelDescription == null ? "" : channelDescription;
|
||||
this.channelTypeCode = channelTypeCode;
|
||||
this.channelTypeVersion = channelTypeVersion;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CreateChannelBody check() {
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)");
|
||||
}
|
||||
|
||||
String normalizedName = normalizeDisplayName(channelName);
|
||||
if (normalizedName.isEmpty()) throw new IllegalArgumentException("channelName is blank");
|
||||
int cpLen = normalizedName.codePointCount(0, normalizedName.length());
|
||||
if (cpLen > MAX_NAME_LENGTH) throw new IllegalArgumentException("channelName length must be <=32");
|
||||
|
||||
String normalizedDescription = normalizeDescription(channelDescription);
|
||||
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
|
||||
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||
}
|
||||
|
||||
int typeCode = Short.toUnsignedInt(channelTypeCode);
|
||||
int typeVer = Short.toUnsignedInt(channelTypeVersion);
|
||||
if (typeCode < 0 || typeCode > 0xFFFF) throw new IllegalArgumentException("channelTypeCode invalid");
|
||||
if (typeVer < 0 || typeVer > 0xFFFF) throw new IllegalArgumentException("channelTypeVersion invalid");
|
||||
|
||||
if (prevLineNumber < 0) throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody");
|
||||
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||
if (thisLineNumber <= 0) throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody");
|
||||
return this;
|
||||
}
|
||||
|
||||
private static String normalizeDisplayName(String value) {
|
||||
if (value == null) return "";
|
||||
return value.trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private static String normalizeDescription(String value) {
|
||||
if (value == null) return "";
|
||||
return value.trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] nameUtf8 = normalizeDisplayName(channelName).getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||
throw new IllegalArgumentException("channelName utf8 len must be 1..255");
|
||||
}
|
||||
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
|
||||
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||
}
|
||||
|
||||
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + 2 + descriptionUtf8.length + 4;
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.putInt(lineCode);
|
||||
bb.putInt(prevLineNumber);
|
||||
bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32));
|
||||
bb.putInt(thisLineNumber);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
bb.putShort((short) (descriptionUtf8.length & 0xFFFF));
|
||||
if (descriptionUtf8.length > 0) bb.put(descriptionUtf8);
|
||||
bb.putShort(channelTypeCode);
|
||||
bb.putShort(channelTypeVersion);
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int lineCode() { return lineCode; }
|
||||
@Override
|
||||
public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||
@Override
|
||||
public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
|
||||
@Override
|
||||
public int lineSeq() { return thisLineNumber; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package blockchain.body;
|
||||
|
||||
import utils.config.ShineSignatureConstants;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* HeaderBody — type=0, version=1.
|
||||
*
|
||||
* В новом формате type/subType/version живут в HEADER блока,
|
||||
* поэтому bodyBytes для HeaderBody содержат только payload:
|
||||
*
|
||||
* bodyBytes (BigEndian):
|
||||
* [TAG_LEN] tag ASCII "SHiNE"
|
||||
* [1] loginLength=N (uint8)
|
||||
* [N] login UTF-8
|
||||
*/
|
||||
public final class HeaderBody implements BodyRecord {
|
||||
|
||||
public static final short TYPE = 0;
|
||||
public static final short VER = 1;
|
||||
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
/** Для header subType всегда 0 (служебная совместимость). */
|
||||
public static final short SUBTYPE_COMPAT = 0;
|
||||
|
||||
/** TAG формата (ASCII). */
|
||||
public static final String TAG = ShineSignatureConstants.BLOCKCHAIN_HEADER_TAG;
|
||||
|
||||
private static final byte[] TAG_ASCII = TAG.getBytes(StandardCharsets.US_ASCII);
|
||||
private static final int TAG_LEN = TAG_ASCII.length;
|
||||
|
||||
public final short subType; // всегда 0 (из заголовка блока)
|
||||
public final short version; // из заголовка блока
|
||||
public final String tag; // "SHiNE"
|
||||
public final String login;
|
||||
|
||||
/** Десериализация из payload bodyBytes (без type/subType/version). */
|
||||
public HeaderBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
if ((this.subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("HeaderBody subType must be 0, got=" + (this.subType & 0xFFFF));
|
||||
}
|
||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("HeaderBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||
}
|
||||
|
||||
// минимум: tag[TAG_LEN] + loginLen[1]
|
||||
if (bodyBytes.length < TAG_LEN + 1) throw new IllegalArgumentException("HeaderBody too short");
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
byte[] tagBytes = new byte[TAG_LEN];
|
||||
bb.get(tagBytes);
|
||||
String t = new String(tagBytes, StandardCharsets.US_ASCII);
|
||||
if (!TAG.equals(t)) throw new IllegalArgumentException("Bad tag: " + t);
|
||||
this.tag = t;
|
||||
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0 || bb.remaining() < loginLen)
|
||||
throw new IllegalArgumentException("Bad login length");
|
||||
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.login = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
|
||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||
}
|
||||
|
||||
/** Создание “вручную”. */
|
||||
public HeaderBody(String login) {
|
||||
Objects.requireNonNull(login, "login == null");
|
||||
this.subType = SUBTYPE_COMPAT;
|
||||
this.version = VER;
|
||||
this.tag = TAG;
|
||||
this.login = login;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HeaderBody check() {
|
||||
if ((subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF))
|
||||
throw new IllegalArgumentException("HeaderBody subType must be 0");
|
||||
|
||||
if (login == null || login.isBlank())
|
||||
throw new IllegalArgumentException("Login is blank");
|
||||
if (!login.matches("^[A-Za-z0-9_]+$"))
|
||||
throw new IllegalArgumentException("Login must match ^[A-Za-z0-9_]+$");
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] loginUtf8 = login.getBytes(StandardCharsets.UTF_8);
|
||||
if (loginUtf8.length == 0 || loginUtf8.length > 255)
|
||||
throw new IllegalArgumentException("Login utf8 len must be 1..255");
|
||||
|
||||
int cap = TAG_LEN + 1 + loginUtf8.length;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put(TAG_ASCII);
|
||||
bb.put((byte) loginUtf8.length);
|
||||
bb.put(loginUtf8);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return """
|
||||
HeaderBody {
|
||||
тип записи : HEADER (type=0, ver=1) [в заголовке блока]
|
||||
subType : 0 (compat)
|
||||
тег формата : "%s"
|
||||
login владельца : "%s"
|
||||
}
|
||||
""".formatted(tag, login);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package blockchain.body;
|
||||
|
||||
import blockchain.MsgSubType;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* ReactionBody — type=2, version=1 (в заголовке блока).
|
||||
*
|
||||
* subType (в заголовке блока):
|
||||
* 1 = LIKE
|
||||
* 2 = UNLIKE
|
||||
*
|
||||
* bodyBytes (BigEndian), новый формат:
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [32] toBlockHash32 (raw 32 bytes)
|
||||
*
|
||||
* ЛИНИИ НЕТ.
|
||||
*/
|
||||
public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
public static final short TYPE = 2;
|
||||
public static final short VER = 1;
|
||||
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public final short subType; // из header
|
||||
public final short version; // из header
|
||||
|
||||
public final String toBlockchainName;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32;
|
||||
|
||||
public ReactionBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("ReactionBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||
}
|
||||
if (!isSupportedSubType(this.subType)) {
|
||||
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
|
||||
}
|
||||
|
||||
// минимум: nameLen[1]+name[1]+global[4]+hash[32]
|
||||
if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
||||
if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
|
||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||
}
|
||||
|
||||
public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
|
||||
this.subType = MsgSubType.REACTION_LIKE;
|
||||
this.version = VER;
|
||||
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReactionBody check() {
|
||||
if (!isSupportedSubType(subType))
|
||||
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
||||
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
if (toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameBytes.length == 0 || nameBytes.length > 255)
|
||||
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
||||
|
||||
int cap = 1 + nameBytes.length + 4 + 32;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) nameBytes.length);
|
||||
bb.put(nameBytes);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
/* ====================== BodyHasTarget ====================== */
|
||||
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
private static boolean isSupportedSubType(short subType) {
|
||||
int st = subType & 0xFFFF;
|
||||
return st == (MsgSubType.REACTION_LIKE & 0xFFFF)
|
||||
|| st == (MsgSubType.REACTION_UNLIKE & 0xFFFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
package blockchain.body;
|
||||
|
||||
import blockchain.MsgSubType;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* TextBody — type=1, ver=1 (в заголовке блока).
|
||||
*
|
||||
* subType (в заголовке блока):
|
||||
* 10 = POST
|
||||
* 11 = EDIT_POST
|
||||
* 20 = REPLY
|
||||
* 21 = EDIT_REPLY
|
||||
*
|
||||
* =========================================================================
|
||||
* КОНЦЕПЦИЯ ЛИНИЙ ДЛЯ ТЕКСТОВЫХ СООБЩЕНИЙ:
|
||||
*
|
||||
* POST и EDIT_POST принадлежат ЛИНИИ КАНАЛА и имеют hasLine.
|
||||
* В новом формате добавлен lineCode:
|
||||
* lineCode = 0 для канала "0"
|
||||
* lineCode = blockNumber "заглавия линии/канала" (например CREATE_CHANNEL)
|
||||
*
|
||||
* REPLY и EDIT_REPLY НЕ имеют линии (нет hasLine в байтах).
|
||||
*
|
||||
* =========================================================================
|
||||
* ФОРМАТЫ bodyBytes (BigEndian):
|
||||
*
|
||||
* 1) POST (subType=10):
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
*
|
||||
* 2) EDIT_POST (subType=11):
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
*
|
||||
* hasTarget (на ОРИГИНАЛЬНЫЙ POST, toBchName НЕ хранить):
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
*
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
*
|
||||
* 3) REPLY (subType=20) — НЕ в линии:
|
||||
* hasTarget:
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
*
|
||||
* [2] textLenBytes (uint16)
|
||||
* [M] text UTF-8
|
||||
*
|
||||
* 4) EDIT_REPLY (subType=21) — НЕ в линии:
|
||||
* hasTarget (на ОРИГИНАЛЬНЫЙ REPLY, toBchName НЕ хранить):
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
*
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
*/
|
||||
public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine {
|
||||
|
||||
public static final short TYPE = 1;
|
||||
public static final short VER = 1;
|
||||
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public final short subType; // из header
|
||||
public final short version; // из header
|
||||
|
||||
// ===== line fields (только для POST/EDIT_POST) =====
|
||||
// Для REPLY/EDIT_REPLY эти поля НЕ сериализуются; значения держим как "пустые".
|
||||
public final int lineCode; // только для line-message; иначе -1
|
||||
public final int prevLineNumber;
|
||||
public final byte[] prevLineHash32; // 32 or null
|
||||
public final int thisLineNumber;
|
||||
|
||||
// ===== message text =====
|
||||
public final String message;
|
||||
|
||||
// ===== target fields =====
|
||||
// REPLY: toBlockchainName + globalNumber + hash32
|
||||
// EDIT_POST / EDIT_REPLY: только globalNumber + hash32 (без toBlockchainName)
|
||||
public final String toBlockchainName; // nullable
|
||||
public final Integer toBlockGlobalNumber; // nullable
|
||||
public final byte[] toBlockHash32; // nullable (но если target есть -> 32)
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Конструктор из байт ========================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
public TextBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||
}
|
||||
if (!isValidSubType(this.subType)) {
|
||||
throw new IllegalArgumentException("Bad Text subType: " + (this.subType & 0xFFFF));
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
int st = this.subType & 0xFFFF;
|
||||
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
||||
// POST: hasLine(lineCode+line) + text
|
||||
ensureMin(bb, (4 + 4 + 32 + 4) + 2, "POST too short");
|
||||
|
||||
this.lineCode = bb.getInt();
|
||||
this.prevLineNumber = bb.getInt();
|
||||
this.prevLineHash32 = new byte[32];
|
||||
bb.get(this.prevLineHash32);
|
||||
this.thisLineNumber = bb.getInt();
|
||||
|
||||
this.message = readStrictUtf8Len16(bb, "POST text");
|
||||
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = null;
|
||||
this.toBlockHash32 = null;
|
||||
|
||||
ensureNoTail(bb, "POST");
|
||||
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
// EDIT_POST: hasLine(lineCode+line) + target(no bch) + text
|
||||
ensureMin(bb, (4 + 4 + 32 + 4) + (4 + 32) + 2, "EDIT_POST too short");
|
||||
|
||||
this.lineCode = bb.getInt();
|
||||
this.prevLineNumber = bb.getInt();
|
||||
this.prevLineHash32 = new byte[32];
|
||||
bb.get(this.prevLineHash32);
|
||||
this.thisLineNumber = bb.getInt();
|
||||
|
||||
int tgtNum = bb.getInt();
|
||||
byte[] tgtHash = new byte[32];
|
||||
bb.get(tgtHash);
|
||||
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = tgtNum;
|
||||
this.toBlockHash32 = tgtHash;
|
||||
|
||||
this.message = readStrictUtf8Len16(bb, "EDIT_POST text");
|
||||
|
||||
ensureNoTail(bb, "EDIT_POST");
|
||||
|
||||
} else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
// REPLY: target(with bch) + text (без line)
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
|
||||
this.message = readStrictUtf8Len16(bb, "REPLY text");
|
||||
|
||||
// line fields отсутствуют в байтах
|
||||
this.lineCode = -1;
|
||||
this.prevLineNumber = -1;
|
||||
this.prevLineHash32 = null;
|
||||
this.thisLineNumber = -1;
|
||||
|
||||
ensureNoTail(bb, "REPLY");
|
||||
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||
// EDIT_REPLY: target(no bch) + text (без line)
|
||||
ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");
|
||||
|
||||
int tgtNum = bb.getInt();
|
||||
byte[] tgtHash = new byte[32];
|
||||
bb.get(tgtHash);
|
||||
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = tgtNum;
|
||||
this.toBlockHash32 = tgtHash;
|
||||
|
||||
this.message = readStrictUtf8Len16(bb, "EDIT_REPLY text");
|
||||
|
||||
// line fields отсутствуют в байтах
|
||||
this.lineCode = -1;
|
||||
this.prevLineNumber = -1;
|
||||
this.prevLineHash32 = null;
|
||||
this.thisLineNumber = -1;
|
||||
|
||||
ensureNoTail(bb, "EDIT_REPLY");
|
||||
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported Text subType: " + st);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Фабрики (удобно) ============================= */
|
||||
/* ===================================================================== */
|
||||
|
||||
public static TextBody newPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, String message) {
|
||||
return new TextBody(MsgSubType.TEXT_POST, lineCode, prevLineNumber, prevLineHash32, thisLineNumber,
|
||||
message, null, null, null);
|
||||
}
|
||||
|
||||
public static TextBody newEditPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber,
|
||||
int targetBlockNumber, byte[] targetHash32,
|
||||
String message) {
|
||||
return new TextBody(MsgSubType.TEXT_EDIT_POST, lineCode, prevLineNumber, prevLineHash32, thisLineNumber,
|
||||
message, null, targetBlockNumber, targetHash32);
|
||||
}
|
||||
|
||||
public static TextBody newReply(String toBlockchainName, int targetBlockNumber, byte[] targetHash32, String message) {
|
||||
return new TextBody(MsgSubType.TEXT_REPLY, -1, -1, null, -1,
|
||||
message, toBlockchainName, targetBlockNumber, targetHash32);
|
||||
}
|
||||
|
||||
public static TextBody newEditReply(int targetBlockNumber, byte[] targetHash32, String message) {
|
||||
return new TextBody(MsgSubType.TEXT_EDIT_REPLY, -1, -1, null, -1,
|
||||
message, null, targetBlockNumber, targetHash32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Универсальный конструктор “вручную”.
|
||||
* Для REPLY/EDIT_REPLY line поля игнорируются при сериализации (их в формате нет).
|
||||
*/
|
||||
public TextBody(short subType,
|
||||
int lineCode,
|
||||
int prevLineNumber,
|
||||
byte[] prevLineHash32,
|
||||
int thisLineNumber,
|
||||
String message,
|
||||
String toBlockchainName,
|
||||
Integer toBlockGlobalNumber,
|
||||
byte[] toBlockHash32) {
|
||||
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
|
||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF));
|
||||
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = VER;
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
|
||||
// line применима только к POST/EDIT_POST
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message");
|
||||
this.lineCode = lineCode;
|
||||
this.prevLineNumber = prevLineNumber;
|
||||
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
this.thisLineNumber = thisLineNumber;
|
||||
} else {
|
||||
this.lineCode = -1;
|
||||
this.prevLineNumber = -1;
|
||||
this.prevLineHash32 = null;
|
||||
this.thisLineNumber = -1;
|
||||
}
|
||||
|
||||
this.message = message;
|
||||
|
||||
// target правила
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = null;
|
||||
this.toBlockHash32 = null;
|
||||
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = null; // по ТЗ: не хранить
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
|
||||
} else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = null; // по ТЗ: не хранить
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
|
||||
} else {
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = null;
|
||||
this.toBlockHash32 = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isValidSubType(short st) {
|
||||
int v = st & 0xFFFF;
|
||||
return v == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| v == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
|| v == (MsgSubType.TEXT_REPLY & 0xFFFF)
|
||||
|| v == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TextBody check() {
|
||||
if (!isValidSubType(subType))
|
||||
throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF));
|
||||
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
|
||||
// локальные проверки line (БД не трогаем)
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message");
|
||||
if (prevLineHash32 == null || prevLineHash32.length != 32)
|
||||
throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||
} else {
|
||||
// reply/edit_reply: line отсутствует
|
||||
if (prevLineHash32 != null)
|
||||
throw new IllegalArgumentException("REPLY/EDIT_REPLY must not contain line hash");
|
||||
}
|
||||
|
||||
// target rules
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
||||
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||
throw new IllegalArgumentException("POST must not contain target fields");
|
||||
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
if (toBlockchainName != null)
|
||||
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName in target");
|
||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
||||
|
||||
} else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
|
||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("REPLY toBlockGlobalNumber invalid");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("REPLY toBlockHash32 invalid");
|
||||
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||
if (toBlockchainName != null)
|
||||
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName in target");
|
||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("EDIT_REPLY toBlockGlobalNumber invalid");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 invalid");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
||||
// hasLine(lineCode+line) + text
|
||||
int cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.putInt(lineCode);
|
||||
bb.putInt(prevLineNumber);
|
||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
bb.putInt(thisLineNumber);
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
bb.put(msgUtf8);
|
||||
return bb.array();
|
||||
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
// hasLine(lineCode+line) + target(no bch) + text
|
||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
|
||||
|
||||
int cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.putInt(lineCode);
|
||||
bb.putInt(prevLineNumber);
|
||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
bb.putInt(thisLineNumber);
|
||||
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
bb.put(msgUtf8);
|
||||
return bb.array();
|
||||
|
||||
} else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
// target(with bch) + text
|
||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPLY missing toBlockGlobalNumber");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPLY toBlockHash32 != 32");
|
||||
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255)
|
||||
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");
|
||||
|
||||
int cap = 1 + nameUtf8.length + 4 + 32
|
||||
+ 2 + msgUtf8.length;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
bb.put(msgUtf8);
|
||||
return bb.array();
|
||||
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||
// target(no bch) + text
|
||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_REPLY missing toBlockGlobalNumber");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 != 32");
|
||||
|
||||
int cap = (4 + 32) + 2 + msgUtf8.length;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
bb.put(msgUtf8);
|
||||
return bb.array();
|
||||
|
||||
} else {
|
||||
throw new IllegalStateException("Unsupported Text subType: " + st);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ========================== Helpers ================================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
|
||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||
|
||||
byte[] bytes = new byte[len];
|
||||
bb.get(bytes);
|
||||
|
||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||
return s;
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
||||
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
||||
}
|
||||
|
||||
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
||||
}
|
||||
|
||||
/* ====================== BodyHasLine ====================== */
|
||||
@Override public int lineCode() { return lineCode; }
|
||||
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||
@Override public byte[] prevLineBlockHash32() {
|
||||
if (prevLineHash32 == null) return null;
|
||||
return Arrays.copyOf(prevLineHash32, 32);
|
||||
}
|
||||
@Override public int lineSeq() { return thisLineNumber; }
|
||||
|
||||
/* ====================== BodyHasTarget ===================== */
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ===================== Удобные хелперы (для ChainState) =============== */
|
||||
/* ===================================================================== */
|
||||
|
||||
/** true только для POST / EDIT_POST (т.е. это сообщение в линии канала). */
|
||||
public boolean isLineMessage() {
|
||||
int st = subType & 0xFFFF;
|
||||
return st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
||||
}
|
||||
|
||||
/** true только для EDIT_POST / EDIT_REPLY. */
|
||||
public boolean isEditMessage() {
|
||||
int st = subType & 0xFFFF;
|
||||
return st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
||||
}
|
||||
|
||||
/** true только для REPLY / EDIT_REPLY (т.е. “не в линии”). */
|
||||
public boolean isReplyFamily() {
|
||||
int st = subType & 0xFFFF;
|
||||
return st == (MsgSubType.TEXT_REPLY & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package blockchain.body;
|
||||
|
||||
import blockchain.MsgSubType;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* TextLineBody — type=1, ver=1.
|
||||
*
|
||||
* subType:
|
||||
* - POST (10)
|
||||
* - EDIT_POST (11)
|
||||
* - REPOST (30)
|
||||
*
|
||||
* Формат bodyBytes (BigEndian):
|
||||
*
|
||||
* POST:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
*
|
||||
* EDIT_POST:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
*
|
||||
* REPOST:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
*/
|
||||
public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarget {
|
||||
|
||||
public static final short TYPE = 1;
|
||||
public static final short VER = 1;
|
||||
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public final short subType; // из header
|
||||
public final short version; // из header (=1)
|
||||
|
||||
// line
|
||||
public final int lineCode;
|
||||
public final int prevLineNumber;
|
||||
public final byte[] prevLineHash32; // 32 (может быть нули)
|
||||
public final int thisLineNumber;
|
||||
|
||||
// target (для EDIT_POST / REPOST)
|
||||
public final String toBlockchainName; // nullable для POST/EDIT_POST
|
||||
public final Integer toBlockGlobalNumber; // nullable для POST
|
||||
public final byte[] toBlockHash32; // nullable для POST
|
||||
|
||||
// text
|
||||
public final String message;
|
||||
|
||||
/* ====================== parse from bytes ====================== */
|
||||
|
||||
public TextLineBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||
}
|
||||
|
||||
int st = this.subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
// минимум line + textLen(2)
|
||||
ensureMin(bb, (4 + 4 + 32 + 4) + 2, "TextLineBody too short");
|
||||
|
||||
this.lineCode = bb.getInt();
|
||||
this.prevLineNumber = bb.getInt();
|
||||
|
||||
this.prevLineHash32 = new byte[32];
|
||||
bb.get(this.prevLineHash32);
|
||||
|
||||
this.thisLineNumber = bb.getInt();
|
||||
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
// нужен target
|
||||
ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target");
|
||||
int tgtNum = bb.getInt();
|
||||
byte[] tgtHash = new byte[32];
|
||||
bb.get(tgtHash);
|
||||
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = tgtNum;
|
||||
this.toBlockHash32 = tgtHash;
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPOST missing target");
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("REPOST toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPOST payload too short");
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
} else {
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = null;
|
||||
this.toBlockHash32 = null;
|
||||
}
|
||||
|
||||
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF));
|
||||
|
||||
ensureNoTail(bb, "TextLineBody");
|
||||
}
|
||||
|
||||
/* ====================== manual ctor ====================== */
|
||||
|
||||
public TextLineBody(int lineCode,
|
||||
int prevLineNumber,
|
||||
byte[] prevLineHash32,
|
||||
int thisLineNumber,
|
||||
short subType,
|
||||
Integer toBlockGlobalNumber,
|
||||
byte[] toBlockHash32,
|
||||
String toBlockchainName,
|
||||
String message) {
|
||||
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST");
|
||||
}
|
||||
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) {
|
||||
throw new IllegalArgumentException("message is blank");
|
||||
}
|
||||
|
||||
this.subType = subType;
|
||||
this.version = VER;
|
||||
|
||||
this.lineCode = lineCode;
|
||||
this.prevLineNumber = prevLineNumber;
|
||||
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
this.thisLineNumber = thisLineNumber;
|
||||
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
} else {
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = null;
|
||||
this.toBlockHash32 = null;
|
||||
}
|
||||
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TextLineBody check() {
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF))
|
||||
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
|
||||
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
if (prevLineHash32 == null || prevLineHash32.length != 32)
|
||||
throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
if (message == null) throw new IllegalArgumentException("EDIT_POST message is null");
|
||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
||||
if (toBlockchainName != null)
|
||||
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName");
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("REPOST message is blank");
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
throw new IllegalArgumentException("REPOST toBlockchainName is blank");
|
||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
|
||||
} else {
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||
throw new IllegalArgumentException("POST must not contain target fields");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) {
|
||||
throw new IllegalArgumentException("Text payload is empty");
|
||||
}
|
||||
|
||||
int cap;
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
||||
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
// EDIT_POST
|
||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
|
||||
cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
|
||||
} else {
|
||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName");
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||
throw new IllegalArgumentException("REPOST toBlockchainName utf8 len must be 1..255");
|
||||
}
|
||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPOST missing toBlockGlobalNumber");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32");
|
||||
cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length;
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
bb.putInt(lineCode);
|
||||
bb.putInt(prevLineNumber);
|
||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
bb.putInt(thisLineNumber);
|
||||
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
}
|
||||
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
bb.put(msgUtf8);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
/* ====================== BodyHasLine ====================== */
|
||||
@Override public int lineCode() { return lineCode; }
|
||||
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||
@Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); }
|
||||
@Override public int lineSeq() { return thisLineNumber; }
|
||||
|
||||
/* ====================== BodyHasTarget ===================== */
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
/* ====================== helpers ====================== */
|
||||
|
||||
public boolean isEditPost() {
|
||||
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
||||
}
|
||||
|
||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len == 0) {
|
||||
if (allowEmpty) return "";
|
||||
throw new IllegalArgumentException(fieldName + " is empty");
|
||||
}
|
||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||
|
||||
byte[] bytes = new byte[len];
|
||||
bb.get(bytes);
|
||||
|
||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||
return s;
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
||||
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
||||
}
|
||||
|
||||
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
||||
}
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package blockchain.body;
|
||||
|
||||
import blockchain.MsgSubType;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* TextReplyBody — type=1, ver=1.
|
||||
*
|
||||
* subType:
|
||||
* - REPLY (20)
|
||||
* - EDIT_REPLY (21)
|
||||
*
|
||||
* Форматы bodyBytes (BigEndian):
|
||||
*
|
||||
* REPLY:
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [M] text UTF-8
|
||||
*
|
||||
* EDIT_REPLY:
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
*/
|
||||
public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
public static final short TYPE = 1;
|
||||
public static final short VER = 1;
|
||||
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public final short subType; // из header
|
||||
public final short version; // (=1)
|
||||
|
||||
// target
|
||||
public final String toBlockchainName; // nullable для EDIT_REPLY
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32; // 32
|
||||
|
||||
// text
|
||||
public final String message;
|
||||
|
||||
public TextReplyBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||
}
|
||||
|
||||
int st = this.subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
// минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2]
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
|
||||
} else {
|
||||
// EDIT_REPLY: target без имени
|
||||
ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");
|
||||
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
}
|
||||
|
||||
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
||||
ensureNoTail(bb, "TextReplyBody");
|
||||
}
|
||||
|
||||
public TextReplyBody(short subType,
|
||||
int toBlockGlobalNumber,
|
||||
byte[] toBlockHash32,
|
||||
String toBlockchainName,
|
||||
String message) {
|
||||
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY");
|
||||
}
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
|
||||
throw new IllegalArgumentException("message is blank");
|
||||
}
|
||||
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
} else {
|
||||
// EDIT_REPLY: имя не хранить
|
||||
this.toBlockchainName = null;
|
||||
}
|
||||
|
||||
this.subType = subType;
|
||||
this.version = VER;
|
||||
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TextReplyBody check() {
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
|
||||
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
||||
|
||||
if (toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
|
||||
} else {
|
||||
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
|
||||
if (toBlockchainName != null)
|
||||
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
|
||||
throw new IllegalArgumentException("Text payload is empty");
|
||||
}
|
||||
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
||||
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255)
|
||||
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");
|
||||
|
||||
int cap = 1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
bb.put(msgUtf8);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
// EDIT_REPLY
|
||||
int cap = (4 + 32) + 2 + msgUtf8.length;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
bb.put(msgUtf8);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
/* ====================== BodyHasTarget ====================== */
|
||||
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
public boolean isEditReply() {
|
||||
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
||||
}
|
||||
|
||||
/* ====================== helpers ====================== */
|
||||
|
||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len == 0) {
|
||||
if (allowEmpty) return "";
|
||||
throw new IllegalArgumentException(fieldName + " is empty");
|
||||
}
|
||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||
|
||||
byte[] bytes = new byte[len];
|
||||
bb.get(bytes);
|
||||
|
||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||
return s;
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
||||
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
||||
}
|
||||
|
||||
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package blockchain.body;
|
||||
|
||||
import blockchain.MsgSubType;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* UserParamBody — type=4, ver=1 (в заголовке блока).
|
||||
*
|
||||
* subType (в заголовке блока):
|
||||
* 1 = TEXT_TEXT
|
||||
*
|
||||
* bodyBytes (BigEndian), новый формат:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
*
|
||||
* [2] keyLenBytes (uint16)
|
||||
* [N] keyUtf8
|
||||
*
|
||||
* [2] valueLenBytes (uint16)
|
||||
* [M] valueUtf8
|
||||
*/
|
||||
public final class UserParamBody implements BodyRecord, BodyHasLine {
|
||||
|
||||
public static final short TYPE = 4;
|
||||
public static final short VER = 1;
|
||||
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public final short subType; // из header
|
||||
public final short version; // из header
|
||||
|
||||
// line
|
||||
public final int lineCode;
|
||||
public final int prevLineNumber;
|
||||
public final byte[] prevLineHash32;
|
||||
public final int thisLineNumber;
|
||||
|
||||
public final String paramKey;
|
||||
public final String paramValue;
|
||||
|
||||
public UserParamBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("UserParamBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||
}
|
||||
if ((this.subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("Bad UserParam subType: " + (this.subType & 0xFFFF));
|
||||
}
|
||||
|
||||
// минимум: lineCode(4)+line(4+32+4) + keyLen(2)+key(1) + valLen(2)+val(1)
|
||||
if (bodyBytes.length < 4 + (4 + 32 + 4) + 2 + 1 + 2 + 1) {
|
||||
throw new IllegalArgumentException("UserParamBody too short");
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
this.lineCode = bb.getInt();
|
||||
|
||||
this.prevLineNumber = bb.getInt();
|
||||
|
||||
this.prevLineHash32 = new byte[32];
|
||||
bb.get(this.prevLineHash32);
|
||||
|
||||
this.thisLineNumber = bb.getInt();
|
||||
|
||||
int keyLen = Short.toUnsignedInt(bb.getShort());
|
||||
if (keyLen <= 0) throw new IllegalArgumentException("paramKeyLen is 0");
|
||||
if (bb.remaining() < keyLen + 2) throw new IllegalArgumentException("UserParam key payload too short");
|
||||
|
||||
byte[] keyBytes = new byte[keyLen];
|
||||
bb.get(keyBytes);
|
||||
|
||||
int valLen = Short.toUnsignedInt(bb.getShort());
|
||||
if (valLen <= 0) throw new IllegalArgumentException("paramValueLen is 0");
|
||||
if (bb.remaining() < valLen) throw new IllegalArgumentException("UserParam value payload too short");
|
||||
|
||||
byte[] valBytes = new byte[valLen];
|
||||
bb.get(valBytes);
|
||||
|
||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||
|
||||
this.paramKey = strictUtf8(keyBytes, "paramKey");
|
||||
this.paramValue = strictUtf8(valBytes, "paramValue");
|
||||
|
||||
if (this.paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank");
|
||||
if (this.paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank");
|
||||
}
|
||||
|
||||
public UserParamBody(int lineCode,
|
||||
int prevLineNumber,
|
||||
byte[] prevLineHash32,
|
||||
int thisLineNumber,
|
||||
String paramKey,
|
||||
String paramValue) {
|
||||
|
||||
Objects.requireNonNull(paramKey, "paramKey == null");
|
||||
Objects.requireNonNull(paramValue, "paramValue == null");
|
||||
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
|
||||
this.subType = MsgSubType.USER_PARAM_TEXT_TEXT;
|
||||
this.version = VER;
|
||||
|
||||
this.lineCode = lineCode;
|
||||
this.prevLineNumber = prevLineNumber;
|
||||
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
this.thisLineNumber = thisLineNumber;
|
||||
|
||||
if (paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank");
|
||||
if (paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank");
|
||||
|
||||
this.paramKey = paramKey;
|
||||
this.paramValue = paramValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserParamBody check() {
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
|
||||
if ((subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF))
|
||||
throw new IllegalArgumentException("Bad UserParam subType: " + (subType & 0xFFFF));
|
||||
|
||||
if (prevLineNumber == -1) {
|
||||
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
|
||||
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
|
||||
} else {
|
||||
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||
}
|
||||
|
||||
if (paramKey == null || paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank");
|
||||
if (paramValue == null || paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank");
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] keyUtf8 = paramKey.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] valUtf8 = paramValue.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
if (keyUtf8.length == 0 || keyUtf8.length > 65535) throw new IllegalArgumentException("paramKey utf8 len must be 1..65535");
|
||||
if (valUtf8.length == 0 || valUtf8.length > 65535) throw new IllegalArgumentException("paramValue utf8 len must be 1..65535");
|
||||
|
||||
int cap = 4 + (4 + 32 + 4)
|
||||
+ 2 + keyUtf8.length
|
||||
+ 2 + valUtf8.length;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
bb.putInt(lineCode);
|
||||
|
||||
bb.putInt(prevLineNumber);
|
||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
bb.putInt(thisLineNumber);
|
||||
|
||||
bb.putShort((short) keyUtf8.length);
|
||||
bb.put(keyUtf8);
|
||||
|
||||
bb.putShort((short) valUtf8.length);
|
||||
bb.put(valUtf8);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
private static String strictUtf8(byte[] bytes, String fieldName) {
|
||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isAllZero32(byte[] b) {
|
||||
if (b == null || b.length != 32) return true;
|
||||
for (int i = 0; i < 32; i++) if (b[i] != 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ====================== BodyHasLine ====================== */
|
||||
@Override public int lineCode() { return lineCode; }
|
||||
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
|
||||
@Override public int lineSeq() { return thisLineNumber; }
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package utils.blockchain;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public final class BlockchainNameUtil {
|
||||
|
||||
/**
|
||||
* Теперь новое правило:
|
||||
* blockchainName = login + "-"+ 3 цифры
|
||||
* Пример: "Dima-001" -> "Dima"
|
||||
*
|
||||
* Сколько символов отрезаем с конца blockchainName, чтобы получить login: "-001" = 4
|
||||
*/
|
||||
public static final int BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN = 4;
|
||||
|
||||
private BlockchainNameUtil() {}
|
||||
|
||||
/**
|
||||
* Извлечь login из blockchainName: отрезаем последние 4 символа ("-NNN").
|
||||
* Пример: "Dima-001" -> "Dima"
|
||||
*/
|
||||
public static String loginFromBlockchainName(String blockchainName) {
|
||||
if (blockchainName == null) return null;
|
||||
|
||||
String s = blockchainName.trim();
|
||||
if (!hasDashAnd3DigitsSuffix(s)) return null;
|
||||
|
||||
return s.substring(0, s.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка правила:
|
||||
* - blockchainName должен оканчиваться на "-"+3 цифры
|
||||
* - blockchainName без суффикса "-NNN" должен равняться login
|
||||
*
|
||||
* ВАЖНО:
|
||||
* - сравнение строгое (case-sensitive)
|
||||
* - null/blank считаем невалидным
|
||||
*/
|
||||
public static boolean isBlockchainNameMatchesLogin(String blockchainName, String login) {
|
||||
if (blockchainName == null || login == null) return false;
|
||||
|
||||
String bn = blockchainName.trim();
|
||||
String lg = login.trim();
|
||||
|
||||
if (bn.isEmpty() || lg.isEmpty()) return false;
|
||||
if (!hasDashAnd3DigitsSuffix(bn)) return false;
|
||||
|
||||
String extracted = bn.substring(0, bn.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN);
|
||||
return Objects.equals(extracted, lg);
|
||||
}
|
||||
|
||||
private static boolean hasDashAnd3DigitsSuffix(String s) {
|
||||
if (s == null) return false;
|
||||
int len = s.length();
|
||||
if (len <= BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN) return false;
|
||||
|
||||
int dashPos = len - 4;
|
||||
if (s.charAt(dashPos) != '-') return false;
|
||||
|
||||
char c1 = s.charAt(len - 3);
|
||||
char c2 = s.charAt(len - 2);
|
||||
char c3 = s.charAt(len - 1);
|
||||
|
||||
return isDigit(c1) && isDigit(c2) && isDigit(c3);
|
||||
}
|
||||
|
||||
private static boolean isDigit(char c) {
|
||||
return c >= '0' && c <= '9';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package utils.files;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* ===============================================================
|
||||
* FileStoreUtil — утилита работы с файлами в папке data/.
|
||||
*
|
||||
* Теперь поддерживает:
|
||||
* - основной файл блокчейна: <blockchainName>.bch
|
||||
* - временный файл блокчейна: <blockchainName>.tmp_bch
|
||||
*
|
||||
* Важное:
|
||||
* - 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";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Атомарно заменить основной файл блокчейна временным:
|
||||
* <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,27 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
group = 'shine'
|
||||
version = '1.0.0'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
|
||||
|
||||
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
|
||||
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package utils.config;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
|
||||
public final class AppConfig {
|
||||
|
||||
private static volatile AppConfig instance;
|
||||
private final Properties properties = new Properties();
|
||||
|
||||
private AppConfig() {
|
||||
load();
|
||||
}
|
||||
|
||||
public static AppConfig getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (AppConfig.class) {
|
||||
if (instance == null) {
|
||||
instance = new AppConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private void load() {
|
||||
try (InputStream in = getClass().getClassLoader()
|
||||
.getResourceAsStream("application.properties")) {
|
||||
|
||||
if (in == null) {
|
||||
throw new RuntimeException("Config file application.properties not found");
|
||||
}
|
||||
|
||||
properties.load(in);
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to load application.properties", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Вернёт значение строки или null, если параметр не найден */
|
||||
public String getParam(String name) {
|
||||
String fromSystem = System.getProperty(name);
|
||||
if (fromSystem != null) return fromSystem;
|
||||
return properties.getProperty(name);
|
||||
}
|
||||
|
||||
/** Вернёт строку или пустую строку, если параметр не найден. */
|
||||
public String getStringOrEmpty(String name) {
|
||||
String value = properties.getProperty(name);
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
/** Можно добавить методы для удобства */
|
||||
public int getInt(String name, int defaultValue) {
|
||||
String v = properties.getProperty(name);
|
||||
return v == null ? defaultValue : Integer.parseInt(v);
|
||||
}
|
||||
|
||||
public boolean getBoolean(String name, boolean defaultValue) {
|
||||
String v = properties.getProperty(name);
|
||||
return v == null ? defaultValue : Boolean.parseBoolean(v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package utils.config;
|
||||
|
||||
/**
|
||||
* CryptoSizes — единые размеры крипто-полей и ключей.
|
||||
* Никаких "32/64" по коду руками — только отсюда.
|
||||
*/
|
||||
public final class CryptoSizes {
|
||||
|
||||
private CryptoSizes() {}
|
||||
|
||||
/** Длина SHA-256 хэша, который хранится в блоке. */
|
||||
public static final int HASH32_LEN = 32;
|
||||
|
||||
/** Длина подписи Ed25519, которая хранится в блоке. */
|
||||
public static final int SIGNATURE64_LEN = 64;
|
||||
|
||||
/** Длина публичного ключа Ed25519. */
|
||||
public static final int ED25519_PUBLIC_KEY32_LEN = 32;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package utils.config;
|
||||
|
||||
/**
|
||||
* ShineSignatureConstants — строковые префиксы/домены, входящие в подписываемые сообщения.
|
||||
*
|
||||
* ВАЖНО:
|
||||
* - префикс добавляется в начало "чтобы подпись нельзя было переиспользовать" между разными типами сообщений.
|
||||
* - менять значения после релиза нельзя, иначе старые подписи перестанут проверяться.
|
||||
*/
|
||||
public final class ShineSignatureConstants {
|
||||
|
||||
private ShineSignatureConstants() {}
|
||||
|
||||
/** Подписываемые данные параметра пользователя: prefix + login + param + time_ms + value */
|
||||
public static final String USER_PARAMETER_PREFIX = "SHiNe/UserParameter:";
|
||||
|
||||
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
||||
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
||||
|
||||
/** DOMAIN для preimage при расчёте hash32 блока. ASCII "SHiNe". */
|
||||
public static final String BLOCK_HASH_DOMAIN = "SHiNe";
|
||||
|
||||
|
||||
// ===================== Фиксированные размеры =====================
|
||||
|
||||
/** Длина SHA-256 хэша, который хранится в блоке. */
|
||||
public static final int HASH32_LEN = 32;
|
||||
|
||||
/** Длина подписи Ed25519, которая хранится в блоке. */
|
||||
public static final int SIGNATURE64_LEN = 64;
|
||||
|
||||
/** Длина публичного ключа Ed25519. */
|
||||
public static final int ED25519_PUBLIC_KEY32_LEN = 32;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package utils.config;
|
||||
|
||||
/**
|
||||
* Публичные адреса Solana-программ SHiNE (жёстко зафиксированы для devnet).
|
||||
* Секреты на сервере не хранятся.
|
||||
*/
|
||||
public final class SolanaProgramsConfig {
|
||||
|
||||
private SolanaProgramsConfig() {}
|
||||
|
||||
public static final String SOLANA_CLUSTER = "devnet";
|
||||
public static final String SOLANA_RPC_URL = "https://api.devnet.solana.com";
|
||||
|
||||
// Программа регистрации пользователей (shine_users), задеплоена в devnet.
|
||||
public static final String SHINE_USERS_PROGRAM_ID = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm";
|
||||
|
||||
// Отдельно фиксируем адреса связанной инфраструктуры, чтобы UI/сервер ссылались одинаково.
|
||||
public static final String SHINE_LOGIN_GUARD_PROGRAM_ID = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
|
||||
public static final String SHINE_PAYMENTS_PROGRAM_ID = "m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
group = 'shine' // можешь поставить свой group
|
||||
version = '1.0.0'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// BouncyCastle для Ed25519 и SHA-256
|
||||
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()
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTFILE="all_files.txt"
|
||||
|
||||
# очищаем или создаём файл
|
||||
: > "$OUTFILE"
|
||||
|
||||
# собрать только *.java файлы и вывести их содержимое в файл
|
||||
find . -type f -name "*.java" | sort | while read -r f; do
|
||||
cat "$f" >> "$OUTFILE"
|
||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
||||
done
|
||||
|
||||
# скопировать весь файл в буфер обмена (Wayland)
|
||||
wl-copy < "$OUTFILE"
|
||||
|
||||
echo "Готово!"
|
||||
echo "Все .java файлы собраны в $OUTFILE"
|
||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTFILE="all_files.txt"
|
||||
|
||||
# очищаем или создаём файл
|
||||
: > "$OUTFILE"
|
||||
|
||||
# собрать только *.java файлы и вывести их содержимое в файл
|
||||
find . -type f -name "*.java" | sort | while read -r f; do
|
||||
cat "$f" >> "$OUTFILE"
|
||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
||||
done
|
||||
|
||||
# скопировать весь файл в буфер обмена (Wayland)
|
||||
wl-copy < "$OUTFILE"
|
||||
|
||||
echo "Готово!"
|
||||
echo "Все .java файлы собраны в $OUTFILE"
|
||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
||||
@@ -0,0 +1,91 @@
|
||||
package utils.crypto;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* BchCryptoVerifier — проверка хэша и подписи Ed25519 для .bch сущностей.
|
||||
*.
|
||||
* Канонический пре-имидж:
|
||||
* [N] userLogin UTF-8 (без длины! строго как байты строки)
|
||||
* [8] blockchainId (big-endian long)
|
||||
* [32] prevHash32
|
||||
* [*] rawBytes (без подписи и без хэша)
|
||||
*.
|
||||
* Проверяем:
|
||||
* • hash32 == SHA-256(preimage)
|
||||
* • signature64 валидна как Ed25519(preimage, publicKey32)
|
||||
*/
|
||||
public final class BchCryptoVerifier {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BchCryptoVerifier.class);
|
||||
|
||||
private BchCryptoVerifier() {}
|
||||
|
||||
public static boolean verifyAll(String userLogin,
|
||||
long blockchainId,
|
||||
byte[] prevHash32,
|
||||
byte[] rawBytes,
|
||||
byte[] signature64,
|
||||
byte[] hash32,
|
||||
byte[] publicKey32) {
|
||||
try {
|
||||
Objects.requireNonNull(userLogin, "userLogin");
|
||||
requireLen(prevHash32, 32, "prevHash32");
|
||||
requireLen(signature64, 64, "signature64");
|
||||
requireLen(hash32, 32, "hash32");
|
||||
requireLen(publicKey32, 32, "publicKey32");
|
||||
Objects.requireNonNull(rawBytes, "rawBytes");
|
||||
|
||||
byte[] preimage = buildPreimage(userLogin, blockchainId, prevHash32, rawBytes);
|
||||
|
||||
// 1) Проверка хэша (BC)
|
||||
byte[] calcHash = HashSHA256Util.sha256(preimage);
|
||||
boolean hashOk = Arrays.equals(calcHash, hash32);
|
||||
|
||||
// 2) Проверка подписи Ed25519
|
||||
boolean sigOk = Ed25519Util.verify(preimage, signature64, publicKey32);
|
||||
|
||||
if (!hashOk) log.warn("Hash mismatch: hash32 != SHA-256(preimage)");
|
||||
if (!sigOk) log.warn("Signature mismatch: Ed25519 verify failed");
|
||||
return hashOk && sigOk;
|
||||
} catch (IllegalArgumentException ex) {
|
||||
log.error("verifyAll: bad arguments", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Собрать канонический пре-имидж без длины логина. */
|
||||
public static byte[] buildPreimage(String userLogin,
|
||||
long blockchainId,
|
||||
byte[] prevHash32,
|
||||
byte[] rawBytes) {
|
||||
Objects.requireNonNull(userLogin, "userLogin");
|
||||
Objects.requireNonNull(prevHash32, "prevHash32");
|
||||
Objects.requireNonNull(rawBytes, "rawBytes");
|
||||
|
||||
byte[] loginUtf8 = userLogin.getBytes(StandardCharsets.UTF_8);
|
||||
requireLen(prevHash32, 32, "prevHash32");
|
||||
|
||||
int capacity = loginUtf8.length + 8 + 32 + rawBytes.length;
|
||||
ByteBuffer buf = ByteBuffer.allocate(capacity).order(ByteOrder.BIG_ENDIAN);
|
||||
buf.put(loginUtf8);
|
||||
buf.putLong(blockchainId);
|
||||
buf.put(prevHash32);
|
||||
buf.put(rawBytes);
|
||||
return buf.array();
|
||||
}
|
||||
|
||||
private static void requireLen(byte[] arr, int len, String name) {
|
||||
if (arr == null) throw new IllegalArgumentException(name + " is null");
|
||||
if (arr.length != len) {
|
||||
throw new IllegalArgumentException(name + " length != " + len + " (got " + arr.length + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package utils.crypto;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
public final class CryptoSelfTest {
|
||||
|
||||
private CryptoSelfTest() {}
|
||||
|
||||
/**
|
||||
* Простой запуск: убедиться, что всё собрано и работает.
|
||||
* Выводит ключи в Base64, знак/проверка подписи — OK/FAIL.
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
System.out.println("=== Ed25519 self-check ===");
|
||||
|
||||
// 1) Генерация ключей
|
||||
byte[] priv = Ed25519Util.generatePrivateKey();
|
||||
byte[] pub = Ed25519Util.derivePublicKey(priv);
|
||||
|
||||
// 2) Конвертация в/из Base64 (чисто для демонстрации)
|
||||
String privB64 = Ed25519Util.keyToBase64(priv);
|
||||
String pubB64 = Ed25519Util.keyToBase64(pub);
|
||||
System.out.println("Private (seed) Base64: " + privB64);
|
||||
System.out.println("Public Base64 : " + pubB64);
|
||||
|
||||
byte[] priv2 = Ed25519Util.keyFromBase64(privB64);
|
||||
byte[] pub2 = Ed25519Util.keyFromBase64(pubB64);
|
||||
if (!Arrays.equals(priv, priv2) || !Arrays.equals(pub, pub2)) {
|
||||
throw new IllegalStateException("Base64 ⇆ bytes дала несовпадение (не должно случаться).");
|
||||
}
|
||||
|
||||
// 3) Подпись и проверка
|
||||
byte[] data = "Привет, мир Ed25519!".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] sig = Ed25519Util.sign(data, priv);
|
||||
|
||||
boolean ok = Ed25519Util.verify(data, sig, pub);
|
||||
System.out.println("Verify OK? " + ok);
|
||||
|
||||
// 4) Негативный тест: портим данные
|
||||
byte[] bad = "Привет, мир Ed25519?".getBytes(StandardCharsets.UTF_8);
|
||||
boolean shouldFail = Ed25519Util.verify(bad, sig, pub);
|
||||
System.out.println("Verify on changed data (should be false): " + shouldFail);
|
||||
|
||||
if (!ok || shouldFail) {
|
||||
throw new IllegalStateException("Self-test failed.");
|
||||
}
|
||||
System.out.println("Self-test passed ✅");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package utils.crypto;
|
||||
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters;
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters;
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* ===============================================================
|
||||
* Ed25519Util — статическая утилита для работы с подписями Ed25519
|
||||
* на базе Bouncy Castle (bcprov). Совместимо с Java 17.
|
||||
* ---------------------------------------------------------------
|
||||
* Возможности:
|
||||
* • generatePrivateKey() — приватный ключ 32 байта (seed) из SecureRandom.
|
||||
* • generatePrivateKeyFromString(String) — приватный ключ 32 байта из строки через SHA-256.
|
||||
* • derivePublicKey(byte[32]) — публичный ключ 32 байта из приватного.
|
||||
* • sign(byte[], byte[32]) — подпись 64 байта.
|
||||
* • verify(byte[], byte[64], byte[32]) — проверка подписи (true/false).
|
||||
* • keyToBase64(byte[32]) / keyFromBase64(String) — Base64 ⇆ ключ (ровно 32 байта).
|
||||
*.
|
||||
* Форматы:
|
||||
* • Приватный ключ — 32-байтный seed Ed25519.
|
||||
* • Публичный ключ — 32-байтный public key.
|
||||
* • Подпись — 64 байта.
|
||||
*.
|
||||
* Важно:
|
||||
* • Здесь используется «классический» Ed25519 (подпись сырых данных).
|
||||
* Если нужен режим Ed25519ph (prehash), делай отдельный класс.
|
||||
*.
|
||||
* Зависимость (Gradle/Groovy):
|
||||
* implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
|
||||
* ===============================================================
|
||||
*/
|
||||
public final class Ed25519Util {
|
||||
|
||||
/** Длина приватного ключа (seed) в байтах. */
|
||||
public static final int PRIVATE_KEY_LEN = 32;
|
||||
/** Длина публичного ключа в байтах. */
|
||||
public static final int PUBLIC_KEY_LEN = 32;
|
||||
/** Длина подписи в байтах. */
|
||||
public static final int SIGNATURE_LEN = 64;
|
||||
|
||||
// Запрещаем инстанцирование: только статические методы
|
||||
private Ed25519Util() {}
|
||||
|
||||
// ===== Надёжный генератор случайных чисел (ленивая инициализация) =====
|
||||
private static final SecureRandom SECURE_RANDOM = createSecureRandom();
|
||||
|
||||
private static SecureRandom createSecureRandom() {
|
||||
try {
|
||||
return SecureRandom.getInstanceStrong();
|
||||
} catch (Exception ignore) {
|
||||
return new SecureRandom();
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// API
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* Сгенерировать приватный ключ (seed) Ed25519: 32 случайных байта.
|
||||
*/
|
||||
public static byte[] generatePrivateKey() {
|
||||
byte[] seed = new byte[PRIVATE_KEY_LEN];
|
||||
SECURE_RANDOM.nextBytes(seed);
|
||||
return seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сгенерировать приватный ключ (seed, 32 байта) из произвольной строки:
|
||||
* строка → UTF-8 → SHA-256 → 32 байта.
|
||||
*
|
||||
* @param anyString любая строка (не null)
|
||||
* @return массив 32 байта (seed)
|
||||
*/
|
||||
public static byte[] generatePrivateKeyFromString(String anyString) {
|
||||
Objects.requireNonNull(anyString, "Строка для генерации приватного ключа не должна быть null");
|
||||
byte[] input = anyString.getBytes(StandardCharsets.UTF_8);
|
||||
return HashSHA256Util.sha256(input); // ровно 32 байта
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить публичный ключ (32 байта) из приватного (seed, 32 байта).
|
||||
*/
|
||||
public static byte[] derivePublicKey(byte[] privateKey32) {
|
||||
requireLength(privateKey32, PRIVATE_KEY_LEN, "приватного ключа (seed)");
|
||||
Ed25519PrivateKeyParameters priv = new Ed25519PrivateKeyParameters(privateKey32, 0);
|
||||
Ed25519PublicKeyParameters pub = priv.generatePublicKey();
|
||||
return pub.getEncoded(); // 32 байта
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписать сырые данные (без предварительного хеширования) приватным ключом Ed25519.
|
||||
*
|
||||
* @param data данные для подписи (не null)
|
||||
* @param privateKey32 приватный ключ (seed) 32 байта
|
||||
* @return подпись длиной 64 байта
|
||||
*/
|
||||
public static byte[] sign(byte[] data, byte[] privateKey32) {
|
||||
Objects.requireNonNull(data, "Данные для подписи не должны быть null");
|
||||
requireLength(privateKey32, PRIVATE_KEY_LEN, "приватного ключа (seed)");
|
||||
|
||||
Ed25519PrivateKeyParameters priv = new Ed25519PrivateKeyParameters(privateKey32, 0);
|
||||
Ed25519Signer signer = new Ed25519Signer();
|
||||
signer.init(true, priv);
|
||||
signer.update(data, 0, data.length);
|
||||
byte[] signature = signer.generateSignature();
|
||||
if (signature == null || signature.length != SIGNATURE_LEN) {
|
||||
throw new IllegalStateException("Ожидалась подпись длиной 64 байта.");
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить подпись Ed25519.
|
||||
*
|
||||
* @param data исходные данные
|
||||
* @param signature64 подпись 64 байта
|
||||
* @param publicKey32 публичный ключ 32 байта
|
||||
* @return true, если подпись корректна для этих данных и ключа
|
||||
*/
|
||||
public static boolean verify(byte[] data, byte[] signature64, byte[] publicKey32) {
|
||||
Objects.requireNonNull(data, "Данные для проверки подписи не должны быть null");
|
||||
requireLength(signature64, SIGNATURE_LEN, "подписи Ed25519");
|
||||
requireLength(publicKey32, PUBLIC_KEY_LEN, "публичного ключа");
|
||||
|
||||
Ed25519PublicKeyParameters pub = new Ed25519PublicKeyParameters(publicKey32, 0);
|
||||
Ed25519Signer verifier = new Ed25519Signer();
|
||||
verifier.init(false, pub);
|
||||
verifier.update(data, 0, data.length);
|
||||
return verifier.verifySignature(signature64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразовать 32-байтный ключ (приватный seed или публичный key) в Base64-строку.
|
||||
*/
|
||||
public static String keyToBase64(byte[] key32) {
|
||||
requireLength(key32, 32, "ключа (ожидалось 32 байта)");
|
||||
return Base64.getEncoder().encodeToString(key32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Из Base64-строки получить 32-байтный ключ.
|
||||
* @throws IllegalArgumentException если после декодирования длина ≠ 32
|
||||
*/
|
||||
public static byte[] keyFromBase64(String base64) {
|
||||
Objects.requireNonNull(base64, "Base64-строка не должна быть null");
|
||||
byte[] raw = Base64.getDecoder().decode(base64);
|
||||
requireLength(raw, 32, "ключа после декодирования Base64 (ожидалось 32 байта)");
|
||||
return raw;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ВСПОМОГАТЕЛЬНЫЕ
|
||||
// =====================================================================
|
||||
|
||||
private static void requireLength(byte[] data, int expectedLen, String what) {
|
||||
if (data == null) {
|
||||
throw new IllegalArgumentException("Массив " + what + " не должен быть null.");
|
||||
}
|
||||
if (data.length != expectedLen) {
|
||||
throw new IllegalArgumentException(
|
||||
"Некорректная длина " + what + ": " + data.length + " байт(а). Ожидалось: " + expectedLen + "."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package utils.crypto;
|
||||
|
||||
import org.bouncycastle.crypto.digests.SHA256Digest;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class HashSHA256Util {
|
||||
private HashSHA256Util() {}
|
||||
|
||||
/** Посчитать SHA-256 от всего массива. */
|
||||
public static byte[] sha256(byte[] data) {
|
||||
if (data == null) throw new IllegalArgumentException("data == null");
|
||||
SHA256Digest d = new SHA256Digest();
|
||||
d.update(data, 0, data.length);
|
||||
byte[] out = new byte[32];
|
||||
d.doFinal(out, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Инкрементальный SHA-256 (если нужно будет кормить по кускам). */
|
||||
public static final class Sha256 {
|
||||
private final SHA256Digest d = new SHA256Digest();
|
||||
public Sha256 update(byte[] part) {
|
||||
if (part != null) d.update(part, 0, part.length);
|
||||
return this;
|
||||
}
|
||||
public byte[] doFinal() {
|
||||
byte[] out = new byte[32];
|
||||
d.doFinal(out, 0);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# utils.crypto
|
||||
|
||||
Пакет отвечает за криптографию — подписи и хэши блоков.
|
||||
Используется при создании и проверке целостности `.bch`-блоков.
|
||||
|
||||
---
|
||||
|
||||
## Классы
|
||||
|
||||
### **Ed25519Util**
|
||||
Работает с подписями Ed25519.
|
||||
Методы:
|
||||
- `generatePrivateKey()` — создать приватный ключ (32 байта)
|
||||
- `generatePrivateKeyFromString(String)` — детерминированный ключ из строки
|
||||
- `derivePublicKey(byte[32])` — получить публичный ключ (32 байта)
|
||||
- `sign(byte[], byte[32])` — подписать данные (64-байтная подпись)
|
||||
- `verify(byte[], byte[64], byte[32])` — проверить подпись
|
||||
|
||||
### **HashUtil**
|
||||
Вычисляет SHA-256.
|
||||
Методы:
|
||||
- `sha256(byte[])` → `[32]` — вернуть хэш массива
|
||||
- `Sha256` — вложенный класс для пошагового хэширования
|
||||
|
||||
### **BchCryptoVerifier**
|
||||
Проверяет подпись и хэш блока перед записью в блокчейн.
|
||||
Методы:
|
||||
- `verifyAll(userLogin, blockchainId, prevHash32, rawBytes, signature64, hash32, publicKey32)`
|
||||
→ `true/false` — корректна ли подпись и хэш
|
||||
- `buildPreimage(...)` — собирает байты, которые подписываются:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
group = 'shine'
|
||||
version = '1.0.0'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.xerial:sqlite-jdbc:3.47.0.0' // sqlite
|
||||
|
||||
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
|
||||
|
||||
implementation project(':shine-server-config') // модуль с настройками
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTFILE="all_files.txt"
|
||||
|
||||
# очищаем или создаём файл
|
||||
: > "$OUTFILE"
|
||||
|
||||
# собрать только *.java файлы и вывести их содержимое в файл
|
||||
find . -type f -name "*.java" | sort | while read -r f; do
|
||||
cat "$f" >> "$OUTFILE"
|
||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
||||
done
|
||||
|
||||
# скопировать весь файл в буфер обмена (Wayland)
|
||||
wl-copy < "$OUTFILE"
|
||||
|
||||
echo "Готово!"
|
||||
echo "Все .java файлы собраны в $OUTFILE"
|
||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
||||
@@ -0,0 +1,650 @@
|
||||
package shine.db;
|
||||
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.*;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
/**
|
||||
* DatabaseInitializer — создание новой SQLite-БД по схеме SHiNE.
|
||||
*
|
||||
* В этой версии:
|
||||
* - создаём ТОЛЬКО таблицы/индексы
|
||||
* - в конце вызываем DatabaseTriggersInstaller.createAllTriggers(st)
|
||||
*
|
||||
* v2 (sessions):
|
||||
* - active_sessions.session_pwd удалён
|
||||
* - active_sessions.session_key хранит публичный ключ сессии целиком одной строкой
|
||||
*/
|
||||
public final class DatabaseInitializer {
|
||||
|
||||
public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version";
|
||||
public static final int SCHEMA_VERSION_1 = 1;
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
/* ===================== TEXT (msg_type=1) ===================== */
|
||||
|
||||
public static final short TEXT_POST = 10;
|
||||
public static final short TEXT_EDIT_POST = 11;
|
||||
public static final short TEXT_REPLY = 20;
|
||||
public static final short TEXT_EDIT_REPLY = 21;
|
||||
public static final short TEXT_REPOST = 30;
|
||||
|
||||
/* ===================== REACTION (msg_type=2) ===================== */
|
||||
|
||||
public static final short REACTION_LIKE = 1;
|
||||
public static final short REACTION_UNLIKE = 2;
|
||||
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
// Близкий друг (close friend). Исторически в коде использовалось имя FRIEND.
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
|
||||
public static final short CONNECTION_FOLLOW = 30;
|
||||
public static final short CONNECTION_UNFOLLOW = 31;
|
||||
|
||||
public static final short CONNECTION_SPOUSE = 40;
|
||||
public static final short CONNECTION_UNSPOUSE = 41;
|
||||
|
||||
public static final short CONNECTION_PARENT = 50;
|
||||
public static final short CONNECTION_UNPARENT = 51;
|
||||
|
||||
public static final short CONNECTION_CHILD = 52;
|
||||
public static final short CONNECTION_UNCHILD = 53;
|
||||
|
||||
public static final short CONNECTION_SIBLING = 54;
|
||||
public static final short CONNECTION_UNSIBLING = 55;
|
||||
|
||||
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;
|
||||
|
||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||
|
||||
public static void createNewDB(String[] args) {
|
||||
AppConfig config = AppConfig.getInstance();
|
||||
String dbPath = config.getParam("db.path");
|
||||
|
||||
if (dbPath == null || dbPath.isBlank()) {
|
||||
System.err.println("Параметр db.path не задан в application.properties");
|
||||
return;
|
||||
}
|
||||
|
||||
Path dbFile = Paths.get(dbPath);
|
||||
try {
|
||||
Path parent = dbFile.getParent();
|
||||
if (parent != null && !Files.exists(parent)) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
|
||||
if (Files.exists(dbFile)) {
|
||||
System.out.println("Файл базы данных уже существует: " + dbFile.toAbsolutePath());
|
||||
System.out.print("Пересоздать БД (СТАРАЯ БУДЕТ УДАЛЕНА)? [y/N]: ");
|
||||
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
|
||||
String answer = reader.readLine();
|
||||
if (!"y".equalsIgnoreCase(answer) && !"yes".equalsIgnoreCase(answer)) {
|
||||
System.out.println("Операция отменена. БД не изменена.");
|
||||
return;
|
||||
}
|
||||
|
||||
Files.delete(dbFile);
|
||||
System.out.println("Старый файл БД удалён.");
|
||||
}
|
||||
|
||||
createSchema("jdbc:sqlite:" + dbPath);
|
||||
System.out.println("Новая БД успешно создана по пути: " + dbFile.toAbsolutePath());
|
||||
|
||||
} catch (IOException e) {
|
||||
System.err.println("Ошибка работы с файлом БД: " + e.getMessage());
|
||||
} catch (SQLException e) {
|
||||
System.err.println("Ошибка создания схемы БД: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static void ensureSchemaV1Structure(String jdbcUrl) throws SQLException {
|
||||
createSchema(jdbcUrl, false);
|
||||
}
|
||||
|
||||
private static void createSchema(String jdbcUrl) throws SQLException {
|
||||
createSchema(jdbcUrl, true);
|
||||
}
|
||||
|
||||
private static void createSchema(String jdbcUrl, boolean initializeVersionRow) throws SQLException {
|
||||
try {
|
||||
Class.forName("org.sqlite.JDBC");
|
||||
} catch (ClassNotFoundException e) {
|
||||
throw new RuntimeException("SQLite JDBC driver not found", e);
|
||||
}
|
||||
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = conn.createStatement()) {
|
||||
|
||||
st.execute("PRAGMA foreign_keys = ON");
|
||||
|
||||
// 1. solana_users
|
||||
// ВАЖНО:
|
||||
// - Все требуемые поля теперь лежат в solana_users:
|
||||
// login, blockchain_name, solana_key, blockchain_key, device_key
|
||||
// - Поиск по login в DAO сделан case-insensitive.
|
||||
// - Для защиты от дублей "Anya" и "anya" добавляем COLLATE NOCASE на PRIMARY KEY.
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS solana_users (
|
||||
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
||||
blockchain_name TEXT NOT NULL,
|
||||
solana_key TEXT NOT NULL,
|
||||
blockchain_key TEXT NOT NULL,
|
||||
device_key TEXT NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_users_blockchain_name
|
||||
ON solana_users (blockchain_name);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_solana_users_login
|
||||
ON solana_users (login);
|
||||
""");
|
||||
|
||||
// 2. active_sessions (v2)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS active_sessions (
|
||||
session_id TEXT NOT NULL PRIMARY KEY,
|
||||
login TEXT NOT NULL,
|
||||
session_key TEXT NOT NULL,
|
||||
storage_pwd TEXT NOT NULL,
|
||||
session_created_at_ms INTEGER NOT NULL,
|
||||
last_authirificated_at_ms INTEGER NOT NULL,
|
||||
push_endpoint TEXT,
|
||||
push_p256dh_key TEXT,
|
||||
push_auth_key TEXT,
|
||||
client_ip TEXT,
|
||||
client_info_from_client TEXT,
|
||||
client_info_from_request TEXT,
|
||||
user_language TEXT,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_active_sessions_login
|
||||
ON active_sessions (login);
|
||||
""");
|
||||
|
||||
// 3. users_params
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS users_params (
|
||||
login TEXT NOT NULL,
|
||||
param TEXT NOT NULL,
|
||||
time_ms INTEGER NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
device_key TEXT,
|
||||
signature TEXT,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
UNIQUE (login, param)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_users_params_login
|
||||
ON users_params (login);
|
||||
""");
|
||||
|
||||
// 4. ip_geo_cache
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS ip_geo_cache (
|
||||
ip TEXT NOT NULL PRIMARY KEY,
|
||||
geo TEXT,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_ip_geo_cache_updated_at
|
||||
ON ip_geo_cache (updated_at_ms);
|
||||
""");
|
||||
|
||||
// 5. blockchain_state
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS blockchain_state (
|
||||
blockchain_name TEXT NOT NULL PRIMARY KEY,
|
||||
login TEXT NOT NULL,
|
||||
blockchain_key TEXT NOT NULL,
|
||||
|
||||
size_limit INTEGER NOT NULL,
|
||||
file_size_bytes INTEGER NOT NULL,
|
||||
|
||||
last_block_number INTEGER NOT NULL,
|
||||
last_block_hash BLOB,
|
||||
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_blockchain_state_login
|
||||
ON blockchain_state (login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at
|
||||
ON blockchain_state (updated_at_ms);
|
||||
""");
|
||||
|
||||
// 6. blocks (+ line_code)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
login TEXT NOT NULL,
|
||||
bch_name TEXT NOT NULL,
|
||||
block_number INTEGER NOT NULL CHECK(block_number >= 0),
|
||||
|
||||
msg_type INTEGER NOT NULL,
|
||||
msg_sub_type INTEGER NOT NULL,
|
||||
|
||||
block_bytes BLOB NOT NULL,
|
||||
|
||||
-- target (reply/like/edit и т.д.)
|
||||
to_login TEXT,
|
||||
to_bch_name TEXT,
|
||||
to_block_number INTEGER CHECK(to_block_number IS NULL OR to_block_number >= 0),
|
||||
to_block_hash BLOB,
|
||||
|
||||
-- собственные данные
|
||||
block_hash BLOB NOT NULL,
|
||||
block_signature BLOB NOT NULL,
|
||||
|
||||
-- если этот блок был изменён последним edit'ом
|
||||
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),
|
||||
prev_line_hash BLOB,
|
||||
this_line_number INTEGER CHECK(this_line_number IS NULL OR this_line_number >= 0),
|
||||
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
FOREIGN KEY (bch_name) REFERENCES blockchain_state(blockchain_name),
|
||||
|
||||
UNIQUE (bch_name, block_number)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number
|
||||
ON blocks (bch_name, block_number);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_to_target
|
||||
ON blocks (to_login, to_bch_name, to_block_number);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_line
|
||||
ON blocks (bch_name, line_code, this_line_number);
|
||||
""");
|
||||
|
||||
// 7) connections_state
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS connections_state (
|
||||
login TEXT NOT NULL,
|
||||
rel_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
|
||||
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_login
|
||||
ON connections_state (login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_to_login
|
||||
ON connections_state (to_login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_pair
|
||||
ON connections_state (login, to_login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_target
|
||||
ON connections_state (login, rel_type, to_bch_name, to_block_number);
|
||||
""");
|
||||
|
||||
// 8) message_stats
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS message_stats (
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
|
||||
likes_count INTEGER NOT NULL DEFAULT 0,
|
||||
replies_count INTEGER NOT NULL DEFAULT 0,
|
||||
edits_count INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
UNIQUE (
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash
|
||||
)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_message_stats_target
|
||||
ON message_stats (to_bch_name, to_block_number, to_block_hash);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_message_stats_login
|
||||
ON message_stats (to_login);
|
||||
""");
|
||||
|
||||
// 8.0) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS reactions_state (
|
||||
from_login TEXT NOT NULL,
|
||||
from_bch_name TEXT NOT NULL,
|
||||
reaction_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
last_sub_type INTEGER NOT NULL,
|
||||
|
||||
UNIQUE (
|
||||
from_login,
|
||||
from_bch_name,
|
||||
reaction_type,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash
|
||||
)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_target
|
||||
ON reactions_state (to_bch_name, to_block_number, to_block_hash);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_actor
|
||||
ON reactions_state (from_login, from_bch_name, reaction_type);
|
||||
""");
|
||||
|
||||
// 9) channel_names_state (global normalized channel names)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||
slug TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
channel_description TEXT NOT NULL DEFAULT '',
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_type_code INTEGER NOT NULL DEFAULT 1,
|
||||
channel_type_version INTEGER NOT NULL DEFAULT 1,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
channel_root_block_hash BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
|
||||
ON channel_names_state (owner_bch_name, channel_type_code, slug);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target
|
||||
ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
|
||||
ON channel_names_state (owner_login, owner_bch_name);
|
||||
""");
|
||||
|
||||
// 9.1) chat200_state
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
channel_root_block_hash BLOB NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
channel_type_version INTEGER NOT NULL,
|
||||
chat_title TEXT NOT NULL DEFAULT '',
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (owner_bch_name, channel_root_block_number)
|
||||
);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_chat200_state_owner
|
||||
ON chat200_state (owner_login, owner_bch_name);
|
||||
""");
|
||||
|
||||
// 9.2) chat200_members_state
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS chat200_members_state (
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
member_login TEXT NOT NULL,
|
||||
member_channel_name TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
updated_by_block_number INTEGER NOT NULL,
|
||||
PRIMARY KEY (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
member_login,
|
||||
member_channel_name
|
||||
)
|
||||
);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_chat200_members_owner
|
||||
ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active);
|
||||
""");
|
||||
|
||||
// 10) direct_messages
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS direct_messages (
|
||||
message_id TEXT NOT NULL PRIMARY KEY,
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (from_login) REFERENCES solana_users(login),
|
||||
FOREIGN KEY (to_login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_direct_messages_to_login
|
||||
ON direct_messages (to_login, created_at_ms);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_direct_messages_from_login
|
||||
ON direct_messages (from_login, created_at_ms);
|
||||
""");
|
||||
|
||||
// 11) user_push_tokens
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS user_push_tokens (
|
||||
token_id TEXT NOT NULL PRIMARY KEY,
|
||||
login TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
user_agent TEXT,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login
|
||||
ON user_push_tokens (login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login_session
|
||||
ON user_push_tokens (login, session_id);
|
||||
""");
|
||||
|
||||
// 11) signed_direct_message_replay (anti-replay window)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS signed_direct_message_replay (
|
||||
from_login TEXT NOT NULL,
|
||||
time_ms INTEGER NOT NULL,
|
||||
nonce INTEGER NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
UNIQUE (from_login, time_ms, nonce)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created
|
||||
ON signed_direct_message_replay (created_at_ms);
|
||||
""");
|
||||
|
||||
// 12) signed_direct_messages_history (сырой бинарный пакет + мета)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS signed_direct_messages_history (
|
||||
message_id TEXT NOT NULL PRIMARY KEY,
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
target_mode INTEGER NOT NULL,
|
||||
target_session_id TEXT,
|
||||
message_type INTEGER NOT NULL,
|
||||
time_ms INTEGER NOT NULL,
|
||||
nonce INTEGER NOT NULL,
|
||||
raw_packet BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (from_login) REFERENCES solana_users(login),
|
||||
FOREIGN KEY (to_login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_dm_history_to
|
||||
ON signed_direct_messages_history (to_login, created_at_ms);
|
||||
""");
|
||||
|
||||
// 13) signed_messages_v2 (универсальное хранилище блоков типов 1/2/3/4)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS signed_messages_v2 (
|
||||
message_key TEXT NOT NULL PRIMARY KEY,
|
||||
base_key TEXT NOT NULL,
|
||||
target_login TEXT NOT NULL,
|
||||
from_login TEXT NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
time_ms INTEGER NOT NULL,
|
||||
nonce INTEGER NOT NULL,
|
||||
message_type INTEGER NOT NULL,
|
||||
raw_block BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
source_api TEXT NOT NULL,
|
||||
origin_session_id TEXT,
|
||||
receipt_ref_base_key TEXT,
|
||||
receipt_ref_type INTEGER,
|
||||
FOREIGN KEY (from_login) REFERENCES solana_users(login),
|
||||
FOREIGN KEY (to_login) REFERENCES solana_users(login)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_v2_target
|
||||
ON signed_messages_v2 (target_login, time_ms, created_at_ms);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_v2_base
|
||||
ON signed_messages_v2 (base_key, message_type);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming
|
||||
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing
|
||||
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||
WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL;
|
||||
""");
|
||||
|
||||
// 14) signed_message_session_delivery (доставка по сессиям)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
|
||||
message_key TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
delivered INTEGER NOT NULL DEFAULT 0,
|
||||
delivered_at_ms INTEGER,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (message_key, session_id),
|
||||
FOREIGN KEY (message_key) REFERENCES signed_messages_v2(message_key)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
||||
ON signed_message_session_delivery (session_id, delivered);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS db_schema_version (
|
||||
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
|
||||
schema_version INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
if (initializeVersionRow) {
|
||||
st.executeUpdate("""
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 1, CAST(strftime('%s','now') AS INTEGER) * 1000)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
schema_version = excluded.schema_version,
|
||||
updated_at_ms = excluded.updated_at_ms;
|
||||
""");
|
||||
}
|
||||
|
||||
DatabaseTriggersInstaller.createAllTriggers(st);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
package shine.db;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DatabaseTriggersInstaller — устанавливает триггеры, которые поддерживают бизнес-логику БД.
|
||||
*
|
||||
* Мы специально сделали триггеры максимально "совместимыми":
|
||||
* - НЕТ динамических сообщений в RAISE(...): только фиксированные строки.
|
||||
* (Некоторые SQLite-сборки / просмотрщики падают на "||" внутри RAISE.)
|
||||
* - НЕТ UPSERT "ON CONFLICT DO UPDATE" — вместо него:
|
||||
* INSERT OR IGNORE + UPDATE
|
||||
* (Старые SQLite не знают UPSERT.)
|
||||
*
|
||||
* =============================================================================
|
||||
* РћРџРРЎРђРќРР• РўР РГГЕРОВ
|
||||
* =============================================================================
|
||||
*
|
||||
* [1] trg_blocks_line_integrity_bi (BEFORE INSERT ON blocks)
|
||||
* Контроль целостности "линий" (line_code / prev_line_number / prev_line_hash / this_line_number).
|
||||
*
|
||||
* Зачем это нужно:
|
||||
* - В каналах/ветках/действиях ты хочешь иметь "линейную" последовательность,
|
||||
* где каждый следующий блок явно ссылается на предыдущий блок линии
|
||||
* и подтверждает, что ссылка не подменена.
|
||||
*
|
||||
* Когда срабатывает:
|
||||
* - ТОЛЬКО если при вставке передано ХОТЯ БЫ ОДНО из line-полей.
|
||||
* - Если line-поля не переданы — триггер вообще не работает (это важно).
|
||||
*
|
||||
* Что проверяет:
|
||||
* A) line-поля допускаются только для msg_type:
|
||||
* 0 (TECH), 1 (TEXT), 3 (CONNECTION), 4 (USER_PARAM)
|
||||
* B) Если пришло хоть одно line-поле — обязаны прийти ВСЕ 4 (никаких "частичных")
|
||||
* C) prev-блок линии существует в той же цепочке bch_name
|
||||
* D) prev_hash совпадает с block_hash найденного prev-блока
|
||||
* E) line_code корректный:
|
||||
* - либо первый шаг после root: prev_line_number == line_code
|
||||
* - либо prev уже принадлежит этой линии: p.line_code == NEW.line_code
|
||||
* F) this_line_number:
|
||||
* - первый шаг после root:
|
||||
* TEXT: this_line_number = 0
|
||||
* TECH/CONNECTION/USER_PARAM: this_line_number = 1
|
||||
* - обычный шаг:
|
||||
* TEXT: допускаем same или +1 (чтобы "edit" мог не двигать шаг)
|
||||
* TECH/CONNECTION/USER_PARAM: строго prev.this + 1
|
||||
*
|
||||
* Какие ошибки кидает:
|
||||
* - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE
|
||||
* - LINE_ERR_PARTIAL_FIELDS
|
||||
* - LINE_ERR_NO_PREV
|
||||
* - LINE_ERR_PREV_HASH_MISMATCH
|
||||
* - LINE_ERR_LINE_CODE_MISMATCH
|
||||
* - LINE_ERR_FIRST_STEP_BAD_THIS
|
||||
* - LINE_ERR_THIS_LINE_BAD_STEP
|
||||
*
|
||||
* [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3)
|
||||
* Поддерживает таблицу connections_state как "текущее состояние" отношений:
|
||||
* - FRIEND/CONTACT/FOLLOW -> добавить/обновить состояние
|
||||
* - UNFRIEND/UNCONTACT/UNFOLLOW -> удалить соответствующее "позитивное" состояние
|
||||
*
|
||||
* [3] trg_blocks_message_stats_like_ai (AFTER INSERT ON blocks WHEN msg_type=2 AND sub_type=LIKE)
|
||||
* Поддерживает likes_count в message_stats для цели (to_*).
|
||||
*
|
||||
* [4] trg_blocks_message_stats_reply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=REPLY)
|
||||
* Поддерживает replies_count в message_stats.
|
||||
*
|
||||
* [5] trg_blocks_edit_apply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=EDIT)
|
||||
* Логика edit:
|
||||
* - помечает исходный блок edited_by_block_number = NEW.block_number
|
||||
* - увеличивает edits_count в message_stats
|
||||
*/
|
||||
public final class DatabaseTriggersInstaller {
|
||||
|
||||
private DatabaseTriggersInstaller() {}
|
||||
|
||||
public static void createAllTriggers(Statement st) throws SQLException {
|
||||
dropTriggersByPrefix(st, "trg_blocks_");
|
||||
|
||||
// На всякий случай убираем старые "криво названные" триггеры,
|
||||
// если они когда-то попадали в БД.
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_block_lini_integriti_by;");
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;");
|
||||
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai;");
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_like_ai;");
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_reply_ai;");
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_edit_apply_ai;");
|
||||
|
||||
createLineIntegrityTrigger(st);
|
||||
createConnectionStateTrigger(st);
|
||||
createMessageStatsLikeTrigger(st);
|
||||
createMessageStatsReplyTrigger(st);
|
||||
createEditApplyTrigger(st);
|
||||
}
|
||||
|
||||
private static void dropTriggersByPrefix(Statement st, String prefix) throws SQLException {
|
||||
List<String> triggerNames = new ArrayList<>();
|
||||
String sql = "SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '" + prefix + "%'";
|
||||
try (ResultSet rs = st.executeQuery(sql)) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("name");
|
||||
if (name != null && !name.isBlank()) {
|
||||
triggerNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (String name : triggerNames) {
|
||||
String safeName = name.replace("\"", "\"\"");
|
||||
st.executeUpdate("DROP TRIGGER IF EXISTS \"" + safeName + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
private static void createLineIntegrityTrigger(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi
|
||||
BEFORE INSERT ON blocks
|
||||
WHEN
|
||||
NEW.line_code IS NOT NULL
|
||||
OR NEW.prev_line_number IS NOT NULL
|
||||
OR NEW.prev_line_hash IS NOT NULL
|
||||
OR NEW.this_line_number IS NOT NULL
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE')
|
||||
WHERE NOT (NEW.msg_type IN (0, 1, 3, 4));
|
||||
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_PARTIAL_FIELDS')
|
||||
WHERE NEW.line_code IS NULL
|
||||
OR NEW.prev_line_number IS NULL
|
||||
OR NEW.prev_line_hash IS NULL
|
||||
OR NEW.this_line_number IS NULL;
|
||||
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_NO_PREV')
|
||||
WHERE NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM blocks p
|
||||
WHERE p.bch_name = NEW.bch_name
|
||||
AND p.block_number = NEW.prev_line_number
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_PREV_HASH_MISMATCH')
|
||||
WHERE NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM blocks p
|
||||
WHERE p.bch_name = NEW.bch_name
|
||||
AND p.block_number = NEW.prev_line_number
|
||||
AND p.block_hash = NEW.prev_line_hash
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_LINE_CODE_MISMATCH')
|
||||
WHERE NEW.prev_line_number <> NEW.line_code
|
||||
AND NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM blocks p
|
||||
WHERE p.bch_name = NEW.bch_name
|
||||
AND p.block_number = NEW.prev_line_number
|
||||
AND p.line_code = NEW.line_code
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_FIRST_STEP_BAD_THIS')
|
||||
WHERE NEW.prev_line_number = NEW.line_code
|
||||
AND NEW.this_line_number <> (CASE WHEN NEW.msg_type = 1 THEN 0 ELSE 1 END);
|
||||
|
||||
SELECT RAISE(ABORT, 'LINE_ERR_THIS_LINE_BAD_STEP')
|
||||
WHERE NEW.prev_line_number <> NEW.line_code
|
||||
AND NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM blocks p
|
||||
WHERE p.bch_name = NEW.bch_name
|
||||
AND p.block_number = NEW.prev_line_number
|
||||
AND p.this_line_number IS NOT NULL
|
||||
AND (
|
||||
(NEW.msg_type = 1 AND
|
||||
(NEW.this_line_number = p.this_line_number OR NEW.this_line_number = p.this_line_number + 1)
|
||||
)
|
||||
OR
|
||||
(NEW.msg_type IN (0,3,4) AND NEW.this_line_number = p.this_line_number + 1)
|
||||
)
|
||||
LIMIT 1
|
||||
);
|
||||
END;
|
||||
""");
|
||||
}
|
||||
|
||||
private static void createConnectionStateTrigger(Statement st) throws SQLException {
|
||||
int FRIEND = (int) DatabaseInitializer.CONNECTION_FRIEND;
|
||||
int CONTACT = (int) DatabaseInitializer.CONNECTION_CONTACT;
|
||||
int FOLLOW = (int) DatabaseInitializer.CONNECTION_FOLLOW;
|
||||
int SPOUSE = (int) DatabaseInitializer.CONNECTION_SPOUSE;
|
||||
int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT;
|
||||
int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD;
|
||||
int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING;
|
||||
int KNOWN = (int) DatabaseInitializer.CONNECTION_KNOWN_PERSON;
|
||||
int SHINE_CONF = (int) DatabaseInitializer.CONNECTION_SHINE_CONFIRMED;
|
||||
int SHINE_SEEN = (int) DatabaseInitializer.CONNECTION_SHINE_SEEN;
|
||||
|
||||
int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND;
|
||||
int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT;
|
||||
int UNFOLLOW = (int) DatabaseInitializer.CONNECTION_UNFOLLOW;
|
||||
int UNSPOUSE = (int) DatabaseInitializer.CONNECTION_UNSPOUSE;
|
||||
int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT;
|
||||
int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD;
|
||||
int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING;
|
||||
int UNKNOWN = (int) DatabaseInitializer.CONNECTION_UNKNOWN_PERSON;
|
||||
int SHINE_UNCONF = (int) DatabaseInitializer.CONNECTION_SHINE_UNCONFIRMED;
|
||||
int SHINE_UNSEEN = (int) DatabaseInitializer.CONNECTION_SHINE_UNSEEN;
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai
|
||||
AFTER INSERT ON blocks
|
||||
WHEN NEW.msg_type = 3
|
||||
BEGIN
|
||||
-- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING/KNOWN_PERSON/SHINE_*:
|
||||
-- 1) если записи нет — создаём
|
||||
INSERT OR IGNORE INTO connections_state (
|
||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||
)
|
||||
SELECT
|
||||
NEW.login,
|
||||
NEW.msg_sub_type,
|
||||
COALESCE(
|
||||
NEW.to_login,
|
||||
(
|
||||
SELECT su.login
|
||||
FROM solana_users su
|
||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||
LIMIT 1
|
||||
),
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
),
|
||||
NEW.to_bch_name,
|
||||
NEW.to_block_number,
|
||||
NEW.to_block_hash
|
||||
WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
|
||||
AND COALESCE(
|
||||
NEW.to_login,
|
||||
(
|
||||
SELECT su.login
|
||||
FROM solana_users su
|
||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||
LIMIT 1
|
||||
),
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
) IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL;
|
||||
|
||||
-- 2) если запись есть — обновляем актуальные to_*
|
||||
UPDATE connections_state
|
||||
SET
|
||||
to_bch_name = NEW.to_bch_name,
|
||||
to_block_number = NEW.to_block_number,
|
||||
to_block_hash = NEW.to_block_hash
|
||||
WHERE login = NEW.login
|
||||
AND rel_type = NEW.msg_sub_type
|
||||
AND to_login = COALESCE(
|
||||
NEW.to_login,
|
||||
(
|
||||
SELECT su.login
|
||||
FROM solana_users su
|
||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||
LIMIT 1
|
||||
),
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
|
||||
AND COALESCE(
|
||||
NEW.to_login,
|
||||
(
|
||||
SELECT su.login
|
||||
FROM solana_users su
|
||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||
LIMIT 1
|
||||
),
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
) IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL;
|
||||
|
||||
-- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING/UNKNOWN_PERSON/SHINE_UN*:
|
||||
-- удаляем соответствующее "позитивное" состояние
|
||||
DELETE FROM connections_state
|
||||
WHERE login = NEW.login
|
||||
AND to_login = COALESCE(
|
||||
NEW.to_login,
|
||||
(
|
||||
SELECT su.login
|
||||
FROM solana_users su
|
||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||
LIMIT 1
|
||||
),
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
AND rel_type = CASE NEW.msg_sub_type
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
WHEN %d THEN %d
|
||||
ELSE rel_type
|
||||
END
|
||||
AND COALESCE(
|
||||
NEW.to_login,
|
||||
(
|
||||
SELECT su.login
|
||||
FROM solana_users su
|
||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||
LIMIT 1
|
||||
),
|
||||
CASE
|
||||
WHEN NEW.to_bch_name IS NOT NULL
|
||||
AND length(NEW.to_bch_name) > 4
|
||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
||||
ELSE NULL
|
||||
END
|
||||
) IS NOT NULL
|
||||
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d);
|
||||
END;
|
||||
""".formatted(
|
||||
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
|
||||
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
|
||||
|
||||
UNFRIEND, FRIEND,
|
||||
UNCONTACT, CONTACT,
|
||||
UNFOLLOW, FOLLOW,
|
||||
UNSPOUSE, SPOUSE,
|
||||
UNPARENT, PARENT,
|
||||
UNCHILD, CHILD,
|
||||
UNSIBLING, SIBLING,
|
||||
UNKNOWN, KNOWN,
|
||||
SHINE_UNCONF, SHINE_CONF,
|
||||
SHINE_UNSEEN, SHINE_SEEN,
|
||||
|
||||
UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING, UNKNOWN, SHINE_UNCONF, SHINE_UNSEEN
|
||||
));
|
||||
}
|
||||
|
||||
private static void createMessageStatsLikeTrigger(Statement st) throws SQLException {
|
||||
int LIKE = (int) DatabaseInitializer.REACTION_LIKE;
|
||||
int UNLIKE = (int) DatabaseInitializer.REACTION_UNLIKE;
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai
|
||||
AFTER INSERT ON blocks
|
||||
WHEN NEW.msg_type = 2 AND NEW.msg_sub_type IN (%d, %d)
|
||||
BEGIN
|
||||
-- ensure target stats row exists
|
||||
INSERT OR IGNORE INTO message_stats (
|
||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||
likes_count, replies_count, edits_count
|
||||
)
|
||||
SELECT
|
||||
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
||||
0, 0, 0
|
||||
WHERE NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
|
||||
-- apply delta by state transition (none/unlike->like = +1, like->unlike = -1)
|
||||
UPDATE message_stats
|
||||
SET likes_count = MAX(
|
||||
0,
|
||||
likes_count + (
|
||||
CASE
|
||||
WHEN NEW.msg_sub_type = %d
|
||||
AND COALESCE((
|
||||
SELECT b.msg_sub_type
|
||||
FROM blocks b
|
||||
WHERE b.login = NEW.login
|
||||
AND b.bch_name = NEW.bch_name
|
||||
AND b.msg_type = 2
|
||||
AND b.to_login = NEW.to_login
|
||||
AND b.to_bch_name = NEW.to_bch_name
|
||||
AND b.to_block_number = NEW.to_block_number
|
||||
AND b.to_block_hash = NEW.to_block_hash
|
||||
AND b.block_number < NEW.block_number
|
||||
ORDER BY b.block_number DESC
|
||||
LIMIT 1
|
||||
), -1) <> %d
|
||||
THEN 1
|
||||
WHEN NEW.msg_sub_type = %d
|
||||
AND COALESCE((
|
||||
SELECT b.msg_sub_type
|
||||
FROM blocks b
|
||||
WHERE b.login = NEW.login
|
||||
AND b.bch_name = NEW.bch_name
|
||||
AND b.msg_type = 2
|
||||
AND b.to_login = NEW.to_login
|
||||
AND b.to_bch_name = NEW.to_bch_name
|
||||
AND b.to_block_number = NEW.to_block_number
|
||||
AND b.to_block_hash = NEW.to_block_hash
|
||||
AND b.block_number < NEW.block_number
|
||||
ORDER BY b.block_number DESC
|
||||
LIMIT 1
|
||||
), -1) = %d
|
||||
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
|
||||
AND NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
|
||||
-- persist latest actor->target reaction state
|
||||
INSERT OR IGNORE INTO reactions_state (
|
||||
from_login, from_bch_name, reaction_type,
|
||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||
last_sub_type
|
||||
)
|
||||
SELECT
|
||||
NEW.login, NEW.bch_name, %d,
|
||||
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
||||
NEW.msg_sub_type
|
||||
WHERE NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
|
||||
UPDATE reactions_state
|
||||
SET last_sub_type = NEW.msg_sub_type
|
||||
WHERE from_login = NEW.login
|
||||
AND from_bch_name = NEW.bch_name
|
||||
AND reaction_type = %d
|
||||
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
|
||||
AND NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
END;
|
||||
""".formatted(
|
||||
LIKE, UNLIKE,
|
||||
LIKE, LIKE,
|
||||
UNLIKE, LIKE,
|
||||
LIKE,
|
||||
LIKE
|
||||
));
|
||||
}
|
||||
|
||||
private static void createMessageStatsReplyTrigger(Statement st) throws SQLException {
|
||||
int REPLY = (int) DatabaseInitializer.TEXT_REPLY;
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_reply_ai
|
||||
AFTER INSERT ON blocks
|
||||
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type = %d
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO message_stats (
|
||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||
likes_count, replies_count, edits_count
|
||||
)
|
||||
SELECT
|
||||
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
||||
0, 0, 0
|
||||
WHERE NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
|
||||
UPDATE message_stats
|
||||
SET replies_count = replies_count + 1
|
||||
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
|
||||
AND NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
END;
|
||||
""".formatted(REPLY));
|
||||
}
|
||||
|
||||
private static void createEditApplyTrigger(Statement st) throws SQLException {
|
||||
int EDIT_POST = (int) DatabaseInitializer.TEXT_EDIT_POST;
|
||||
int EDIT_REPLY = (int) DatabaseInitializer.TEXT_EDIT_REPLY;
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_edit_apply_ai
|
||||
AFTER INSERT ON blocks
|
||||
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type IN (%d, %d)
|
||||
BEGIN
|
||||
-- 1) помечаем исходный блок, что его "перекрыл" этот edit
|
||||
UPDATE blocks
|
||||
SET edited_by_block_number = NEW.block_number
|
||||
WHERE login = NEW.login
|
||||
AND bch_name = NEW.bch_name
|
||||
AND block_number = NEW.to_block_number
|
||||
AND NEW.to_block_number IS NOT NULL;
|
||||
|
||||
-- 2) создаём stats-строку если её не было
|
||||
INSERT OR IGNORE INTO message_stats (
|
||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
||||
likes_count, replies_count, edits_count
|
||||
)
|
||||
SELECT
|
||||
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
||||
0, 0, 0
|
||||
WHERE NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
|
||||
-- 3) +1 edit
|
||||
UPDATE message_stats
|
||||
SET edits_count = edits_count + 1
|
||||
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
|
||||
AND NEW.to_login IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL
|
||||
AND NEW.to_block_number IS NOT NULL
|
||||
AND NEW.to_block_hash IS NOT NULL;
|
||||
END;
|
||||
""".formatted(EDIT_POST, EDIT_REPLY));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package shine.db;
|
||||
|
||||
/**
|
||||
* MsgSubType — единое место для ВСЕХ subType сообщений (msg_sub_type).
|
||||
*
|
||||
* ВАЖНО:
|
||||
* - Значения должны совпадать с body-классами (TextBody/ReactionBody/ConnectionBody/UserParamBody/HeaderBody).
|
||||
* - После релиза менять числа нельзя (иначе ломается совместимость данных).
|
||||
*/
|
||||
public final class MsgSubType {
|
||||
|
||||
private MsgSubType() {}
|
||||
|
||||
/* ===================== HEADER (msg_type=0) ===================== */
|
||||
|
||||
/** HeaderBody: subType всегда 0 (compat). */
|
||||
public static final short HEADER_COMPAT = 0;
|
||||
|
||||
/* ===================== TEXT (msg_type=1) ===================== */
|
||||
|
||||
/** POST — обычный пост в канале (в линии канала). */
|
||||
public static final short TEXT_POST = 10;
|
||||
|
||||
/** EDIT_POST — редактирование исходного поста. */
|
||||
public static final short TEXT_EDIT_POST = 11;
|
||||
|
||||
/** REPLY — ответ на сообщение. */
|
||||
public static final short TEXT_REPLY = 20;
|
||||
|
||||
/** EDIT_REPLY — редактирование исходного ответа. */
|
||||
public static final short TEXT_EDIT_REPLY = 21;
|
||||
|
||||
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */
|
||||
public static final short TEXT_REPOST = 30;
|
||||
|
||||
/* ===================== REACTION (msg_type=2) ===================== */
|
||||
|
||||
/** Лайк (LIKE). */
|
||||
public static final short REACTION_LIKE = 1;
|
||||
/** Снятие лайка (UNLIKE). */
|
||||
public static final short REACTION_UNLIKE = 2;
|
||||
|
||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||
/**
|
||||
* Совпадает с ConnectionBody:
|
||||
* SET: CLOSE_FRIEND(=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(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
|
||||
*/
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
public static final short CONNECTION_FRIEND = 10;
|
||||
|
||||
/** Удалить из близких друзей (close friend). */
|
||||
public static final short CONNECTION_UNFRIEND = 11;
|
||||
|
||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
||||
|
||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
||||
|
||||
/** Добавить в контакты. */
|
||||
public static final short CONNECTION_CONTACT = 20;
|
||||
|
||||
/** Удалить из контактов. */
|
||||
public static final short CONNECTION_UNCONTACT = 21;
|
||||
|
||||
/** Подписаться (follow). */
|
||||
public static final short CONNECTION_FOLLOW = 30;
|
||||
|
||||
/** Отписаться (unfollow). */
|
||||
public static final short CONNECTION_UNFOLLOW = 31;
|
||||
|
||||
/** Добавить связь "жена/муж". */
|
||||
public static final short CONNECTION_SPOUSE = 40;
|
||||
|
||||
/** Удалить связь "жена/муж". */
|
||||
public static final short CONNECTION_UNSPOUSE = 41;
|
||||
|
||||
/** Добавить связь "родитель". */
|
||||
public static final short CONNECTION_PARENT = 50;
|
||||
|
||||
/** Удалить связь "родитель". */
|
||||
public static final short CONNECTION_UNPARENT = 51;
|
||||
|
||||
/** Добавить связь "ребёнок". */
|
||||
public static final short CONNECTION_CHILD = 52;
|
||||
|
||||
/** Удалить связь "ребёнок". */
|
||||
public static final short CONNECTION_UNCHILD = 53;
|
||||
|
||||
/** Добавить связь "брат/сестра". */
|
||||
public static final short CONNECTION_SIBLING = 54;
|
||||
|
||||
/** Удалить связь "брат/сестра". */
|
||||
public static final short CONNECTION_UNSIBLING = 55;
|
||||
|
||||
/** Просто знаю этого человека. */
|
||||
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;
|
||||
|
||||
/** Мало знаком, но видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||
|
||||
/** Не отмечаю, что видел сияющим. */
|
||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||
|
||||
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||
|
||||
/** Параметр профиля key/value (обе строки). */
|
||||
public static final short USER_PARAM_TEXT_TEXT = 1;
|
||||
|
||||
/* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */
|
||||
// Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения,
|
||||
// не трогая уже занятые коды.
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package shine.db;
|
||||
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
public final class SqliteDbController {
|
||||
|
||||
private static volatile SqliteDbController instance;
|
||||
private static final int LATEST_SCHEMA_VERSION = 3;
|
||||
|
||||
private final String jdbcUrl;
|
||||
|
||||
private SqliteDbController() {
|
||||
try {
|
||||
Class.forName("org.sqlite.JDBC");
|
||||
} catch (ClassNotFoundException e) {
|
||||
throw new RuntimeException("SQLite JDBC driver not found", e);
|
||||
}
|
||||
|
||||
String dbPath = AppConfig.getInstance().getParam("db.path");
|
||||
if (dbPath == null || dbPath.isBlank()) {
|
||||
throw new RuntimeException("Config param 'db.path' is not set in application.properties");
|
||||
}
|
||||
|
||||
Path dbFile = Paths.get(dbPath);
|
||||
|
||||
if (!Files.exists(dbFile)) {
|
||||
System.out.println("[DB] Файл БД не найден: " + dbFile.toAbsolutePath());
|
||||
System.out.println("[DB] Создаём новую БД с помощью DatabaseInitializer...");
|
||||
DatabaseInitializer.createNewDB(new String[0]);
|
||||
}
|
||||
|
||||
this.jdbcUrl = "jdbc:sqlite:" + dbPath;
|
||||
ensureSchemaMigrations();
|
||||
}
|
||||
|
||||
public static SqliteDbController getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SqliteDbController.class) {
|
||||
if (instance == null) {
|
||||
instance = new SqliteDbController();
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public Connection getConnection() throws SQLException {
|
||||
Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||
conn.setAutoCommit(true);
|
||||
|
||||
try (Statement st = conn.createStatement()) {
|
||||
st.execute("PRAGMA foreign_keys = ON");
|
||||
st.execute("PRAGMA journal_mode = WAL");
|
||||
st.execute("PRAGMA synchronous = NORMAL");
|
||||
st.execute("PRAGMA busy_timeout = 5000");
|
||||
}
|
||||
|
||||
return conn;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
private void ensureSchemaMigrations() {
|
||||
int currentVersion = getCurrentSchemaVersion();
|
||||
|
||||
while (currentVersion < LATEST_SCHEMA_VERSION) {
|
||||
int nextVersion = currentVersion + 1;
|
||||
applyMigration(nextVersion);
|
||||
currentVersion = nextVersion;
|
||||
}
|
||||
}
|
||||
|
||||
private void applyMigration(int targetVersion) {
|
||||
switch (targetVersion) {
|
||||
case 1 -> migrateToV1();
|
||||
case 2 -> migrateToV2();
|
||||
case 3 -> migrateToV3();
|
||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV1() {
|
||||
try {
|
||||
DatabaseInitializer.ensureSchemaV1Structure(jdbcUrl);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v1 failed (base schema)", e);
|
||||
}
|
||||
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
st.execute("PRAGMA foreign_keys = OFF");
|
||||
|
||||
if (tableExists(c, "connections_state") && needsConnectionsStateUpgrade(c)) {
|
||||
rebuildConnectionsStateTable(st);
|
||||
}
|
||||
|
||||
ensureChannelNamesDescriptionColumn(c, st);
|
||||
ensureChannelNamesTypeColumns(c, st);
|
||||
ensureSignedMessageReceiptUniq(c, st);
|
||||
DatabaseTriggersInstaller.createAllTriggers(st);
|
||||
setSchemaVersion(c, 1);
|
||||
|
||||
st.execute("PRAGMA foreign_keys = ON");
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v1 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v1 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV2() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
st.execute("PRAGMA foreign_keys = OFF");
|
||||
st.executeUpdate("DROP TABLE IF EXISTS message_views_state");
|
||||
st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_target");
|
||||
st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_viewer_channel");
|
||||
setSchemaVersion(c, 2);
|
||||
st.execute("PRAGMA foreign_keys = ON");
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v2 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v2 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV3() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ensureChat200StateTables(st);
|
||||
setSchemaVersion(c, 3);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v3 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v3 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
channel_root_block_hash BLOB NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
channel_type_version INTEGER NOT NULL,
|
||||
chat_title TEXT NOT NULL DEFAULT '',
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (owner_bch_name, channel_root_block_number)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS chat200_members_state (
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
member_login TEXT NOT NULL,
|
||||
member_channel_name TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
updated_by_block_number INTEGER NOT NULL,
|
||||
PRIMARY KEY (
|
||||
owner_bch_name,
|
||||
channel_root_block_number,
|
||||
member_login,
|
||||
member_channel_name
|
||||
)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_chat200_state_owner
|
||||
ON chat200_state (owner_login, owner_bch_name);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_chat200_members_owner
|
||||
ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active);
|
||||
""");
|
||||
}
|
||||
|
||||
private int getCurrentSchemaVersion() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl)) {
|
||||
if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try (var ps = c.prepareStatement("""
|
||||
SELECT schema_version
|
||||
FROM db_schema_version
|
||||
WHERE id = 1
|
||||
LIMIT 1
|
||||
""");
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return rs.getInt(1);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Cannot read DB schema version", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void setSchemaVersion(Connection c, int version) throws SQLException {
|
||||
try (var ps = c.prepareStatement("""
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, ?, CAST(strftime('%s','now') AS INTEGER) * 1000)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
schema_version = excluded.schema_version,
|
||||
updated_at_ms = excluded.updated_at_ms
|
||||
""")) {
|
||||
ps.setInt(1, version);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureReactionsStateTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS reactions_state (
|
||||
from_login TEXT NOT NULL,
|
||||
from_bch_name TEXT NOT NULL,
|
||||
reaction_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
last_sub_type INTEGER NOT NULL,
|
||||
UNIQUE (
|
||||
from_login,
|
||||
from_bch_name,
|
||||
reaction_type,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash
|
||||
)
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
|
||||
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS connections_state (
|
||||
login TEXT NOT NULL,
|
||||
rel_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureConnectionsIndexes(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_login
|
||||
ON connections_state (login);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_to_login
|
||||
ON connections_state (to_login);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_pair
|
||||
ON connections_state (login, to_login);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_connections_state_target
|
||||
ON connections_state (login, rel_type, to_bch_name, to_block_number);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureReactionsIndexes(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_target
|
||||
ON reactions_state (to_bch_name, to_block_number, to_block_hash);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_actor
|
||||
ON reactions_state (from_login, from_bch_name, reaction_type);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureChannelNamesStateTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||
slug TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
channel_description TEXT NOT NULL DEFAULT '',
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_type_code INTEGER NOT NULL DEFAULT 1,
|
||||
channel_type_version INTEGER NOT NULL DEFAULT 1,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
channel_root_block_hash BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureChannelNamesDescriptionColumn(Connection c, Statement st) throws SQLException {
|
||||
boolean hasDescription = false;
|
||||
try (Statement probe = c.createStatement();
|
||||
ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("name");
|
||||
if ("channel_description".equalsIgnoreCase(name)) {
|
||||
hasDescription = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasDescription) {
|
||||
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_description TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureChannelNamesTypeColumns(Connection c, Statement st) throws SQLException {
|
||||
boolean hasTypeCode = false;
|
||||
boolean hasTypeVersion = false;
|
||||
|
||||
try (Statement probe = c.createStatement();
|
||||
ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("name");
|
||||
if ("channel_type_code".equalsIgnoreCase(name)) hasTypeCode = true;
|
||||
if ("channel_type_version".equalsIgnoreCase(name)) hasTypeVersion = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTypeCode) {
|
||||
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_code INTEGER NOT NULL DEFAULT 1");
|
||||
}
|
||||
if (!hasTypeVersion) {
|
||||
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_version INTEGER NOT NULL DEFAULT 1");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureChannelNamesIndexes(Statement st) throws SQLException {
|
||||
st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_slug");
|
||||
st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_owner_slug");
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
|
||||
ON channel_names_state (owner_bch_name, channel_type_code, slug);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target
|
||||
ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash);
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
|
||||
ON channel_names_state (owner_login, owner_bch_name);
|
||||
""");
|
||||
}
|
||||
|
||||
private static void ensureSignedMessageReceiptUniq(Connection c, Statement st) throws SQLException {
|
||||
if (!tableExists(c, "signed_messages_v2")) return;
|
||||
|
||||
if (tableExists(c, "signed_message_session_delivery")) {
|
||||
st.executeUpdate("""
|
||||
DELETE FROM signed_message_session_delivery
|
||||
WHERE message_key IN (
|
||||
SELECT message_key
|
||||
FROM signed_messages_v2
|
||||
WHERE message_type IN (3, 4)
|
||||
AND receipt_ref_base_key IS NOT NULL
|
||||
AND rowid NOT IN (
|
||||
SELECT MIN(rowid)
|
||||
FROM signed_messages_v2
|
||||
WHERE message_type IN (3, 4)
|
||||
AND receipt_ref_base_key IS NOT NULL
|
||||
GROUP BY target_login, message_type, receipt_ref_base_key
|
||||
)
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
st.executeUpdate("""
|
||||
DELETE FROM signed_messages_v2
|
||||
WHERE message_type IN (3, 4)
|
||||
AND receipt_ref_base_key IS NOT NULL
|
||||
AND rowid NOT IN (
|
||||
SELECT MIN(rowid)
|
||||
FROM signed_messages_v2
|
||||
WHERE message_type IN (3, 4)
|
||||
AND receipt_ref_base_key IS NOT NULL
|
||||
GROUP BY target_login, message_type, receipt_ref_base_key
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming
|
||||
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
|
||||
""");
|
||||
st.executeUpdate("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing
|
||||
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||
WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL;
|
||||
""");
|
||||
}
|
||||
|
||||
private static void rebuildConnectionsStateTable(Statement st) throws SQLException {
|
||||
st.executeUpdate("DROP TABLE IF EXISTS connections_state_v2");
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE connections_state_v2 (
|
||||
login TEXT NOT NULL,
|
||||
rel_type INTEGER NOT NULL,
|
||||
to_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
to_block_number INTEGER NOT NULL,
|
||||
to_block_hash BLOB NOT NULL,
|
||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
||||
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
INSERT OR IGNORE INTO connections_state_v2
|
||||
(login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
SELECT
|
||||
login,
|
||||
rel_type,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
COALESCE(to_block_number, 0),
|
||||
COALESCE(to_block_hash, zeroblob(32))
|
||||
FROM connections_state
|
||||
WHERE login IS NOT NULL
|
||||
AND to_login IS NOT NULL
|
||||
AND to_bch_name IS NOT NULL;
|
||||
""");
|
||||
|
||||
st.executeUpdate("DROP TABLE connections_state");
|
||||
st.executeUpdate("ALTER TABLE connections_state_v2 RENAME TO connections_state");
|
||||
}
|
||||
|
||||
private static boolean tableExists(Connection c, String tableName) throws SQLException {
|
||||
String sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1";
|
||||
try (var ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, tableName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean needsConnectionsStateUpgrade(Connection c) throws SQLException {
|
||||
boolean toBlockNumberNotNull = false;
|
||||
boolean toBlockHashNotNull = false;
|
||||
|
||||
try (Statement st = c.createStatement();
|
||||
ResultSet rs = st.executeQuery("PRAGMA table_info(connections_state)")) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString("name");
|
||||
int notNull = rs.getInt("notnull");
|
||||
if ("to_block_number".equalsIgnoreCase(name)) {
|
||||
toBlockNumberNotNull = notNull == 1;
|
||||
}
|
||||
if ("to_block_hash".equalsIgnoreCase(name)) {
|
||||
toBlockHashNotNull = notNull == 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return !toBlockNumberNotNull || !toBlockHashNotNull;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package shine.db.channels;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class ChannelNameRules {
|
||||
private static final int MIN_DISPLAY_NAME_LENGTH = 3;
|
||||
private static final int MAX_DISPLAY_NAME_LENGTH = 32;
|
||||
private static final Pattern DISPLAY_ALLOWED_PATTERN =
|
||||
Pattern.compile("^[A-Za-z0-9_-]+$");
|
||||
|
||||
private ChannelNameRules() {}
|
||||
|
||||
public static String normalizeDisplayName(String value) {
|
||||
if (value == null) return "";
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
public static String requireValidDisplayNameForCreate(String rawName) {
|
||||
String normalized = normalizeDisplayName(rawName);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("channelName is blank");
|
||||
}
|
||||
|
||||
int length = normalized.codePointCount(0, normalized.length());
|
||||
if (length < MIN_DISPLAY_NAME_LENGTH || length > MAX_DISPLAY_NAME_LENGTH) {
|
||||
throw new IllegalArgumentException("channelName length must be 3..32");
|
||||
}
|
||||
|
||||
if (!DISPLAY_ALLOWED_PATTERN.matcher(normalized).matches()) {
|
||||
throw new IllegalArgumentException("channelName contains unsupported characters");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static String toCanonicalSlug(String rawName) {
|
||||
String normalized = normalizeDisplayName(rawName);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("channelName is blank");
|
||||
}
|
||||
|
||||
String lowered = normalized.toLowerCase(Locale.ROOT);
|
||||
if (!DISPLAY_ALLOWED_PATTERN.matcher(lowered).matches()) {
|
||||
throw new IllegalArgumentException("channelName contains unsupported characters");
|
||||
}
|
||||
return lowered;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.ActiveSessionEntry;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DAO для таблицы active_sessions.
|
||||
*
|
||||
* Правило:
|
||||
* - методы с Connection НЕ закрывают соединение
|
||||
* - методы без Connection сами открывают и закрывают соединение
|
||||
*/
|
||||
public final class ActiveSessionsDAO {
|
||||
|
||||
private static volatile ActiveSessionsDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private ActiveSessionsDAO() { }
|
||||
|
||||
public static ActiveSessionsDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ActiveSessionsDAO.class) {
|
||||
if (instance == null) instance = new ActiveSessionsDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
// -------------------- INSERT --------------------
|
||||
|
||||
public void insert(Connection c, ActiveSessionEntry session) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO active_sessions (
|
||||
session_id,
|
||||
login,
|
||||
session_key,
|
||||
storage_pwd,
|
||||
session_created_at_ms,
|
||||
last_authirificated_at_ms,
|
||||
push_endpoint,
|
||||
push_p256dh_key,
|
||||
push_auth_key,
|
||||
client_ip,
|
||||
client_info_from_client,
|
||||
client_info_from_request,
|
||||
user_language
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, session.getSessionId());
|
||||
ps.setString(2, session.getLogin());
|
||||
ps.setString(3, session.getSessionKey());
|
||||
ps.setString(4, session.getStoragePwd());
|
||||
ps.setLong(5, session.getSessionCreatedAtMs());
|
||||
ps.setLong(6, session.getLastAuthirificatedAtMs());
|
||||
ps.setString(7, session.getPushEndpoint());
|
||||
ps.setString(8, session.getPushP256dhKey());
|
||||
ps.setString(9, session.getPushAuthKey());
|
||||
ps.setString(10, session.getClientIp());
|
||||
ps.setString(11, session.getClientInfoFromClient());
|
||||
ps.setString(12, session.getClientInfoFromRequest());
|
||||
ps.setString(13, session.getUserLanguage());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void insert(ActiveSessionEntry session) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
insert(c, session);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT --------------------
|
||||
|
||||
public ActiveSessionEntry getBySessionId(Connection c, String sessionId) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
session_id,
|
||||
login,
|
||||
session_key,
|
||||
storage_pwd,
|
||||
session_created_at_ms,
|
||||
last_authirificated_at_ms,
|
||||
push_endpoint,
|
||||
push_p256dh_key,
|
||||
push_auth_key,
|
||||
client_ip,
|
||||
client_info_from_client,
|
||||
client_info_from_request,
|
||||
user_language
|
||||
FROM active_sessions
|
||||
WHERE session_id = ?
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, sessionId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ActiveSessionEntry getBySessionId(String sessionId) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getBySessionId(c, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
public List<ActiveSessionEntry> getByLogin(Connection c, String login) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
session_id,
|
||||
login,
|
||||
session_key,
|
||||
storage_pwd,
|
||||
session_created_at_ms,
|
||||
last_authirificated_at_ms,
|
||||
push_endpoint,
|
||||
push_p256dh_key,
|
||||
push_auth_key,
|
||||
client_ip,
|
||||
client_info_from_client,
|
||||
client_info_from_request,
|
||||
user_language
|
||||
FROM active_sessions
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
""";
|
||||
|
||||
List<ActiveSessionEntry> result = new ArrayList<>();
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) result.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<ActiveSessionEntry> getByLogin(String login) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByLogin(c, login);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- UPDATE --------------------
|
||||
|
||||
public void updateLastAuthirificatedAtMs(Connection c, String sessionId, long lastAuthMs) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE active_sessions
|
||||
SET last_authirificated_at_ms = ?
|
||||
WHERE session_id = ?
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, lastAuthMs);
|
||||
ps.setString(2, sessionId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateLastAuthirificatedAtMs(String sessionId, long lastAuthMs) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
updateLastAuthirificatedAtMs(c, sessionId, lastAuthMs);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateOnRefresh(
|
||||
Connection c,
|
||||
String sessionId,
|
||||
long lastAuthMs,
|
||||
String clientIp,
|
||||
String clientInfoFromClient,
|
||||
String clientInfoFromRequest,
|
||||
String userLanguage
|
||||
) throws SQLException {
|
||||
|
||||
String sql = """
|
||||
UPDATE active_sessions
|
||||
SET
|
||||
last_authirificated_at_ms = ?,
|
||||
client_ip = ?,
|
||||
client_info_from_client = ?,
|
||||
client_info_from_request = ?,
|
||||
user_language = ?
|
||||
WHERE session_id = ?
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, lastAuthMs);
|
||||
ps.setString(2, clientIp);
|
||||
ps.setString(3, clientInfoFromClient);
|
||||
ps.setString(4, clientInfoFromRequest);
|
||||
ps.setString(5, userLanguage);
|
||||
ps.setString(6, sessionId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateOnRefresh(
|
||||
String sessionId,
|
||||
long lastAuthMs,
|
||||
String clientIp,
|
||||
String clientInfoFromClient,
|
||||
String clientInfoFromRequest,
|
||||
String userLanguage
|
||||
) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
updateOnRefresh(c, sessionId, lastAuthMs, clientIp, clientInfoFromClient, clientInfoFromRequest, userLanguage);
|
||||
}
|
||||
}
|
||||
|
||||
public void updatePushSubscription(String sessionId, String endpoint, String p256dhKey, String authKey) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
UPDATE active_sessions
|
||||
SET push_endpoint = ?,
|
||||
push_p256dh_key = ?,
|
||||
push_auth_key = ?
|
||||
WHERE session_id = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, endpoint);
|
||||
ps.setString(2, p256dhKey);
|
||||
ps.setString(3, authKey);
|
||||
ps.setString(4, sessionId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- DELETE --------------------
|
||||
|
||||
public void deleteBySessionId(Connection c, String sessionId) throws SQLException {
|
||||
String sql = "DELETE FROM active_sessions WHERE session_id = ?";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, sessionId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteBySessionId(String sessionId) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
deleteBySessionId(c, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- MAPPER --------------------
|
||||
|
||||
private ActiveSessionEntry mapRow(ResultSet rs) throws SQLException {
|
||||
String sessionId = rs.getString("session_id");
|
||||
String login = rs.getString("login");
|
||||
String sessionKey = rs.getString("session_key");
|
||||
String storagePwd = rs.getString("storage_pwd");
|
||||
long sessionCreatedAtMs = rs.getLong("session_created_at_ms");
|
||||
long lastAuthirificatedAtMs = rs.getLong("last_authirificated_at_ms");
|
||||
String pushEndpoint = rs.getString("push_endpoint");
|
||||
String pushP256dhKey = rs.getString("push_p256dh_key");
|
||||
String pushAuthKey = rs.getString("push_auth_key");
|
||||
String clientIp = rs.getString("client_ip");
|
||||
String clientInfoFromClient = rs.getString("client_info_from_client");
|
||||
String clientInfoFromRequest = rs.getString("client_info_from_request");
|
||||
String userLanguage = rs.getString("user_language");
|
||||
|
||||
return new ActiveSessionEntry(
|
||||
sessionId,
|
||||
login,
|
||||
sessionKey,
|
||||
storagePwd,
|
||||
sessionCreatedAtMs,
|
||||
lastAuthirificatedAtMs,
|
||||
pushEndpoint,
|
||||
pushP256dhKey,
|
||||
pushAuthKey,
|
||||
clientIp,
|
||||
clientInfoFromClient,
|
||||
clientInfoFromRequest,
|
||||
userLanguage
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
|
||||
import java.sql.*;
|
||||
|
||||
public final class BlockchainStateDAO {
|
||||
|
||||
private static volatile BlockchainStateDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private BlockchainStateDAO() {}
|
||||
|
||||
public static BlockchainStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (BlockchainStateDAO.class) {
|
||||
if (instance == null) instance = new BlockchainStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Получить по blockchainName без внешнего соединения. Сам открывает/закрывает. */
|
||||
public BlockchainStateEntry getByBlockchainName(String blockchainName) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByBlockchainName(c, blockchainName);
|
||||
}
|
||||
}
|
||||
|
||||
/** Получить по blockchainName с внешним соединением. Соединение НЕ закрывает. */
|
||||
public BlockchainStateEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
blockchain_name,
|
||||
login,
|
||||
blockchain_key,
|
||||
size_limit,
|
||||
file_size_bytes,
|
||||
last_block_number,
|
||||
last_block_hash,
|
||||
updated_at_ms
|
||||
FROM blockchain_state
|
||||
WHERE blockchain_name = ?
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, blockchainName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** UPSERT без внешнего соединения. Сам открывает/закрывает. */
|
||||
public void upsert(BlockchainStateEntry e) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
upsert(c, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** UPSERT с внешним соединением. Соединение НЕ закрывает. */
|
||||
public void upsert(Connection c, BlockchainStateEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO blockchain_state (
|
||||
blockchain_name,
|
||||
login,
|
||||
blockchain_key,
|
||||
size_limit,
|
||||
file_size_bytes,
|
||||
last_block_number,
|
||||
last_block_hash,
|
||||
updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(blockchain_name)
|
||||
DO UPDATE SET
|
||||
login = excluded.login,
|
||||
blockchain_key = excluded.blockchain_key,
|
||||
size_limit = excluded.size_limit,
|
||||
file_size_bytes = excluded.file_size_bytes,
|
||||
last_block_number= excluded.last_block_number,
|
||||
last_block_hash = excluded.last_block_hash,
|
||||
updated_at_ms = excluded.updated_at_ms
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
|
||||
ps.setString(i++, e.getBlockchainName());
|
||||
ps.setString(i++, nn(e.getLogin()));
|
||||
ps.setString(i++, nn(e.getBlockchainKey()));
|
||||
|
||||
ps.setLong(i++, e.getSizeLimit());
|
||||
ps.setLong(i++, e.getFileSizeBytes());
|
||||
|
||||
ps.setInt(i++, e.getLastBlockNumber());
|
||||
setBytesNullable(ps, i++, e.getLastBlockHash());
|
||||
|
||||
ps.setLong(i++, e.getUpdatedAtMs());
|
||||
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Атомарно увеличить file_size_bytes на deltaBytes, но только если НЕ превысим size_limit.
|
||||
*/
|
||||
public boolean tryIncreaseFileSizeWithinLimit(Connection c, String blockchainName, long deltaBytes, long nowMs) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE blockchain_state
|
||||
SET
|
||||
file_size_bytes = file_size_bytes + ?,
|
||||
updated_at_ms = ?
|
||||
WHERE
|
||||
blockchain_name = ?
|
||||
AND (file_size_bytes + ?) <= size_limit
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, deltaBytes);
|
||||
ps.setLong(2, nowMs);
|
||||
ps.setString(3, blockchainName);
|
||||
ps.setLong(4, deltaBytes);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
private BlockchainStateEntry mapRow(ResultSet rs) throws SQLException {
|
||||
BlockchainStateEntry e = new BlockchainStateEntry();
|
||||
|
||||
e.setBlockchainName(rs.getString("blockchain_name"));
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setBlockchainKey(rs.getString("blockchain_key"));
|
||||
|
||||
e.setSizeLimit(rs.getLong("size_limit"));
|
||||
e.setFileSizeBytes(rs.getLong("file_size_bytes"));
|
||||
|
||||
e.setLastBlockNumber(rs.getInt("last_block_number"));
|
||||
e.setLastBlockHash(rs.getBytes("last_block_hash")); // nullable
|
||||
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
|
||||
return e;
|
||||
}
|
||||
|
||||
private static void setBytesNullable(PreparedStatement ps, int index, byte[] b) throws SQLException {
|
||||
if (b != null) ps.setBytes(index, b);
|
||||
else ps.setNull(index, Types.BLOB);
|
||||
}
|
||||
|
||||
private static String nn(String s) { return s == null ? "" : s; }
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.BlockEntry;
|
||||
|
||||
import java.sql.*;
|
||||
|
||||
/**
|
||||
* DAO для таблицы blocks (новый формат).
|
||||
*
|
||||
* Правило:
|
||||
* - методы с Connection НЕ закрывают соединение
|
||||
* - методы без Connection сами открывают и закрывают соединение
|
||||
*
|
||||
* Ключ:
|
||||
* - (bch_name, block_number) — уникальная пара в рамках общей БД сервера.
|
||||
*/
|
||||
public final class BlocksDAO {
|
||||
|
||||
private static volatile BlocksDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.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();
|
||||
}
|
||||
}
|
||||
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""";
|
||||
|
||||
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.BLOB);
|
||||
|
||||
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.BLOB);
|
||||
|
||||
if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Вставка без внешнего соединения. Сам открывает/закрывает. */
|
||||
public void insert(BlockEntry e) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
insert(c, e);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT: HASH BY NUMBER --------------------
|
||||
|
||||
/** Получить block_hash по (bch_name, block_number). Нужен для линейной проверки. */
|
||||
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
||||
String sql = """
|
||||
SELECT block_hash
|
||||
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 rs.getBytes("block_hash");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getHashByNumber(c, bchName, blockNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT: FULL ENTRY --------------------
|
||||
|
||||
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
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- 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);
|
||||
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
|
||||
public final class ChannelNameStateDAO {
|
||||
private static volatile ChannelNameStateDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private ChannelNameStateDAO() {}
|
||||
|
||||
public static ChannelNameStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ChannelNameStateDAO.class) {
|
||||
if (instance == null) instance = new ChannelNameStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public boolean existsByOwnerTypeAndSlug(Connection c, String ownerBchName, int channelTypeCode, String slug) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name = ? AND channel_type_code = ? AND slug = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setInt(2, channelTypeCode);
|
||||
ps.setString(3, slug);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean existsByOwnerTypeAndSlug(String ownerBchName, int channelTypeCode, String slug) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return existsByOwnerTypeAndSlug(c, ownerBchName, channelTypeCode, slug);
|
||||
}
|
||||
}
|
||||
|
||||
public void clearAll(Connection c) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("DELETE FROM channel_names_state")) {
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void insert(Connection c, ChannelNameStateEntry entry) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO channel_names_state (
|
||||
slug,
|
||||
display_name,
|
||||
channel_description,
|
||||
owner_login,
|
||||
owner_bch_name,
|
||||
channel_type_code,
|
||||
channel_type_version,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getSlug());
|
||||
ps.setString(2, entry.getDisplayName());
|
||||
ps.setString(3, entry.getChannelDescription() == null ? "" : entry.getChannelDescription());
|
||||
ps.setString(4, entry.getOwnerLogin());
|
||||
ps.setString(5, entry.getOwnerBlockchainName());
|
||||
ps.setInt(6, entry.getChannelTypeCode());
|
||||
ps.setInt(7, entry.getChannelTypeVersion());
|
||||
ps.setInt(8, entry.getChannelRootBlockNumber());
|
||||
ps.setBytes(9, entry.getChannelRootBlockHash());
|
||||
ps.setLong(10, entry.getCreatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void insertAll(Connection c, List<ChannelNameStateEntry> entries) throws SQLException {
|
||||
for (ChannelNameStateEntry entry : entries) {
|
||||
insert(c, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* ConnectionsStateDAO — чтение текущего состояния связей из connections_state.
|
||||
*
|
||||
* ВАЖНО:
|
||||
* - login в запросах может быть в любом регистре, поэтому в WHERE используем COLLATE NOCASE
|
||||
* - в ответах возвращаем логины в каноническом регистре через JOIN на solana_users
|
||||
*
|
||||
* ПРИМЕЧАНИЕ:
|
||||
* Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй в SQL.
|
||||
*/
|
||||
public final class ConnectionsStateDAO {
|
||||
|
||||
private static volatile ConnectionsStateDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private ConnectionsStateDAO() {}
|
||||
|
||||
public static ConnectionsStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ConnectionsStateDAO.class) {
|
||||
if (instance == null) instance = new ConnectionsStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outgoing: список логинов (канонических), кому login поставил relType.
|
||||
*/
|
||||
public List<String> listOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
||||
String sql = """
|
||||
SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login
|
||||
FROM connections_state cs
|
||||
LEFT JOIN solana_users u_login
|
||||
ON u_login.login = cs.to_login COLLATE NOCASE
|
||||
LEFT JOIN solana_users u_bch
|
||||
ON u_bch.blockchain_name = cs.to_bch_name COLLATE NOCASE
|
||||
WHERE cs.login = ? COLLATE NOCASE
|
||||
AND cs.rel_type = ?
|
||||
ORDER BY friend_login
|
||||
""";
|
||||
|
||||
List<String> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, loginAnyCase);
|
||||
ps.setInt(2, relType);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String v = rs.getString("friend_login");
|
||||
if (v != null) out.add(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incoming: список логинов (канонических), кто поставил relType пользователю login.
|
||||
*/
|
||||
public List<String> listIncomingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
||||
String sql = """
|
||||
SELECT COALESCE(u_actor.login, cs.login) AS friend_login
|
||||
FROM connections_state cs
|
||||
LEFT JOIN solana_users u_actor
|
||||
ON u_actor.login = cs.login COLLATE NOCASE
|
||||
LEFT JOIN solana_users u_target
|
||||
ON u_target.login = ? COLLATE NOCASE
|
||||
WHERE (
|
||||
cs.to_login = ? COLLATE NOCASE
|
||||
OR (u_target.blockchain_name IS NOT NULL AND cs.to_bch_name = u_target.blockchain_name COLLATE NOCASE)
|
||||
)
|
||||
AND cs.rel_type = ?
|
||||
ORDER BY friend_login
|
||||
""";
|
||||
|
||||
List<String> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, loginAnyCase);
|
||||
ps.setString(2, loginAnyCase);
|
||||
ps.setInt(3, relType);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String v = rs.getString("friend_login");
|
||||
if (v != null) out.add(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutual: список логинов (канонических), у кого дружба в обе стороны.
|
||||
*/
|
||||
public List<String> listMutualByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
||||
String sql = """
|
||||
SELECT u.login AS friend_login
|
||||
FROM connections_state a
|
||||
JOIN solana_users u
|
||||
ON u.login = a.to_login COLLATE NOCASE
|
||||
WHERE a.login = ? COLLATE NOCASE
|
||||
AND a.rel_type = ?
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM connections_state b
|
||||
WHERE b.login = a.to_login COLLATE NOCASE
|
||||
AND b.to_login = a.login COLLATE NOCASE
|
||||
AND b.rel_type = a.rel_type
|
||||
)
|
||||
ORDER BY u.login
|
||||
""";
|
||||
|
||||
List<String> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, loginAnyCase);
|
||||
ps.setInt(2, relType);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String v = rs.getString("friend_login");
|
||||
if (v != null) out.add(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public void upsertRelation(Connection c,
|
||||
String login,
|
||||
int relType,
|
||||
String toLogin,
|
||||
String toBchName,
|
||||
Integer toBlockNumber,
|
||||
byte[] toBlockHash) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO connections_state (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(login, rel_type, to_login) DO UPDATE SET
|
||||
to_bch_name=excluded.to_bch_name,
|
||||
to_block_number=excluded.to_block_number,
|
||||
to_block_hash=excluded.to_block_hash
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setInt(2, relType);
|
||||
ps.setString(3, toLogin);
|
||||
ps.setString(4, toBchName);
|
||||
if (toBlockNumber == null) ps.setNull(5, java.sql.Types.INTEGER); else ps.setInt(5, toBlockNumber);
|
||||
ps.setBytes(6, toBlockHash);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.DirectMessageEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class DirectMessagesDAO {
|
||||
private static volatile DirectMessagesDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private DirectMessagesDAO() {}
|
||||
|
||||
public static DirectMessagesDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DirectMessagesDAO.class) {
|
||||
if (instance == null) instance = new DirectMessagesDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void insert(DirectMessageEntry entry) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO direct_messages (
|
||||
message_id, from_login, to_login, text, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getMessageId());
|
||||
ps.setString(2, entry.getFromLogin());
|
||||
ps.setString(3, entry.getToLogin());
|
||||
ps.setString(4, entry.getText());
|
||||
ps.setLong(5, entry.getCreatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean existsFromTo(String fromLogin, String toLogin) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = "SELECT 1 FROM direct_messages WHERE from_login = ? AND to_login = ? LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, fromLogin);
|
||||
ps.setString(2, toLogin);
|
||||
return ps.executeQuery().next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.IpGeoCacheEntry;
|
||||
|
||||
import java.sql.*;
|
||||
|
||||
/**
|
||||
* DAO для таблицы ip_geo_cache.
|
||||
*
|
||||
* Таблица:
|
||||
* - ip TEXT PRIMARY KEY
|
||||
* - geo TEXT
|
||||
* - updated_at_ms INTEGER NOT NULL
|
||||
*
|
||||
* Правило:
|
||||
* - методы с Connection НЕ закрывают соединение
|
||||
* - методы без Connection сами открывают и закрывают соединение
|
||||
*/
|
||||
public final class IpGeoCacheDAO {
|
||||
|
||||
private static volatile IpGeoCacheDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private IpGeoCacheDAO() { }
|
||||
|
||||
public static IpGeoCacheDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (IpGeoCacheDAO.class) {
|
||||
if (instance == null) instance = new IpGeoCacheDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
// -------------------- UPSERT --------------------
|
||||
|
||||
/** UPSERT с внешним соединением. Соединение НЕ закрывает. */
|
||||
public void upsert(Connection c, IpGeoCacheEntry entry) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO ip_geo_cache (ip, geo, updated_at_ms)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(ip)
|
||||
DO UPDATE SET
|
||||
geo = excluded.geo,
|
||||
updated_at_ms = excluded.updated_at_ms
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getIp());
|
||||
ps.setString(2, entry.getGeo());
|
||||
ps.setLong(3, entry.getUpdatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** UPSERT без внешнего соединения. Сам открывает/закрывает. */
|
||||
public void upsert(IpGeoCacheEntry entry) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
upsert(c, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT --------------------
|
||||
|
||||
/** Получить по IP с внешним соединением. Соединение НЕ закрывает. */
|
||||
public IpGeoCacheEntry getByIp(Connection c, String ip) throws SQLException {
|
||||
String sql = """
|
||||
SELECT ip, geo, updated_at_ms
|
||||
FROM ip_geo_cache
|
||||
WHERE ip = ?
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ip);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Получить по IP без внешнего соединения. Сам открывает/закрывает. */
|
||||
public IpGeoCacheEntry getByIp(String ip) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByIp(c, ip);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- DELETE --------------------
|
||||
|
||||
/** Удалить старые записи с внешним соединением. Соединение НЕ закрывает. */
|
||||
public int deleteOlderThan(Connection c, long thresholdMs) throws SQLException {
|
||||
String sql = "DELETE FROM ip_geo_cache WHERE updated_at_ms < ?";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, thresholdMs);
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Удалить старые записи без внешнего соединения. Сам открывает/закрывает. */
|
||||
public int deleteOlderThan(long thresholdMs) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return deleteOlderThan(c, thresholdMs);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- MAPPER --------------------
|
||||
|
||||
private IpGeoCacheEntry mapRow(ResultSet rs) throws SQLException {
|
||||
String ip = rs.getString("ip");
|
||||
String geo = rs.getString("geo");
|
||||
long updatedAtMs = rs.getLong("updated_at_ms");
|
||||
return new IpGeoCacheEntry(ip, geo, updatedAtMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.PushTokenEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class PushTokensDAO {
|
||||
private static volatile PushTokensDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private PushTokensDAO() {}
|
||||
|
||||
public static PushTokensDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (PushTokensDAO.class) {
|
||||
if (instance == null) instance = new PushTokensDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void upsert(PushTokenEntry entry) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO user_push_tokens (
|
||||
token_id, login, session_id, provider, token, platform, user_agent, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(token_id) DO UPDATE SET
|
||||
login=excluded.login,
|
||||
session_id=excluded.session_id,
|
||||
provider=excluded.provider,
|
||||
token=excluded.token,
|
||||
platform=excluded.platform,
|
||||
user_agent=excluded.user_agent,
|
||||
updated_at_ms=excluded.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getTokenId());
|
||||
ps.setString(2, entry.getLogin());
|
||||
ps.setString(3, entry.getSessionId());
|
||||
ps.setString(4, entry.getProvider());
|
||||
ps.setString(5, entry.getToken());
|
||||
ps.setString(6, entry.getPlatform());
|
||||
ps.setString(7, entry.getUserAgent());
|
||||
ps.setLong(8, entry.getUpdatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<PushTokenEntry> listByLogin(String login) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
SELECT token_id, login, session_id, provider, token, platform, user_agent, updated_at_ms
|
||||
FROM user_push_tokens
|
||||
WHERE login = ?
|
||||
ORDER BY updated_at_ms DESC
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PushTokenEntry> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
PushTokenEntry e = new PushTokenEntry();
|
||||
e.setTokenId(rs.getString("token_id"));
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setSessionId(rs.getString("session_id"));
|
||||
e.setProvider(rs.getString("provider"));
|
||||
e.setToken(rs.getString("token"));
|
||||
e.setPlatform(rs.getString("platform"));
|
||||
e.setUserAgent(rs.getString("user_agent"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
out.add(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<PushTokenEntry> listByLoginAndSession(String login, String sessionId) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
SELECT token_id, login, session_id, provider, token, platform, user_agent, updated_at_ms
|
||||
FROM user_push_tokens
|
||||
WHERE login = ? AND session_id = ?
|
||||
ORDER BY updated_at_ms DESC
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setString(2, sessionId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PushTokenEntry> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
PushTokenEntry e = new PushTokenEntry();
|
||||
e.setTokenId(rs.getString("token_id"));
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setSessionId(rs.getString("session_id"));
|
||||
e.setProvider(rs.getString("provider"));
|
||||
e.setToken(rs.getString("token"));
|
||||
e.setPlatform(rs.getString("platform"));
|
||||
e.setUserAgent(rs.getString("user_agent"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
out.add(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.SignedDirectMessageHistoryEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class SignedDirectMessagesHistoryDAO {
|
||||
private static volatile SignedDirectMessagesHistoryDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private SignedDirectMessagesHistoryDAO() {}
|
||||
|
||||
public static SignedDirectMessagesHistoryDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SignedDirectMessagesHistoryDAO.class) {
|
||||
if (instance == null) instance = new SignedDirectMessagesHistoryDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void insert(SignedDirectMessageHistoryEntry e) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO signed_direct_messages_history (
|
||||
message_id, from_login, to_login, target_mode, target_session_id,
|
||||
message_type, time_ms, nonce, raw_packet, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getMessageId());
|
||||
ps.setString(2, e.getFromLogin());
|
||||
ps.setString(3, e.getToLogin());
|
||||
ps.setInt(4, e.getTargetMode());
|
||||
ps.setString(5, e.getTargetSessionId());
|
||||
ps.setInt(6, e.getMessageType());
|
||||
ps.setLong(7, e.getTimeMs());
|
||||
ps.setLong(8, e.getNonce());
|
||||
ps.setBytes(9, e.getRawPacket());
|
||||
ps.setLong(10, e.getCreatedAtMs());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
|
||||
public final class SignedDmReplayDAO {
|
||||
private static volatile SignedDmReplayDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private SignedDmReplayDAO() {}
|
||||
|
||||
public static SignedDmReplayDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SignedDmReplayDAO.class) {
|
||||
if (instance == null) instance = new SignedDmReplayDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public boolean registerUnique(String fromLogin, long timeMs, long nonce, long nowMs) throws Exception {
|
||||
cleanupExpired(nowMs - 15L * 60L * 1000L);
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO signed_direct_message_replay (
|
||||
from_login, time_ms, nonce, created_at_ms
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, fromLogin);
|
||||
ps.setLong(2, timeMs);
|
||||
ps.setLong(3, nonce);
|
||||
ps.setLong(4, nowMs);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void cleanupExpired(long minCreatedAtMs) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = "DELETE FROM signed_direct_message_replay WHERE created_at_ms < ?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, minCreatedAtMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.SignedMessageV2Entry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class SignedMessagesV2DAO {
|
||||
private static volatile SignedMessagesV2DAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private SignedMessagesV2DAO() {}
|
||||
|
||||
public static SignedMessagesV2DAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SignedMessagesV2DAO.class) {
|
||||
if (instance == null) instance = new SignedMessagesV2DAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public boolean insertIfAbsent(SignedMessageV2Entry e) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, raw_block, created_at_ms,
|
||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getMessageKey());
|
||||
ps.setString(2, e.getBaseKey());
|
||||
ps.setString(3, e.getTargetLogin());
|
||||
ps.setString(4, e.getFromLogin());
|
||||
ps.setString(5, e.getToLogin());
|
||||
ps.setLong(6, e.getTimeMs());
|
||||
ps.setLong(7, e.getNonce());
|
||||
ps.setInt(8, e.getMessageType());
|
||||
ps.setBytes(9, e.getRawBlock());
|
||||
ps.setLong(10, e.getCreatedAtMs());
|
||||
ps.setString(11, e.getSourceApi());
|
||||
ps.setString(12, e.getOriginSessionId());
|
||||
ps.setString(13, e.getReceiptRefBaseKey());
|
||||
if (e.getReceiptRefType() == null) ps.setObject(14, null);
|
||||
else ps.setInt(14, e.getReceiptRefType());
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Атомарная вставка пары блоков: либо вставляются оба, либо не вставляется ни один.
|
||||
* Возвращает true только если обе записи добавлены в БД.
|
||||
* Если хотя бы одна запись уже существует (или конфликтует по уникальности), возвращает false.
|
||||
*/
|
||||
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
int insertedFirst = insertStrict(c, first);
|
||||
int insertedSecond = insertStrict(c, second);
|
||||
if (insertedFirst == 1 && insertedSecond == 1) {
|
||||
c.commit();
|
||||
return true;
|
||||
}
|
||||
c.rollback();
|
||||
return false;
|
||||
} catch (SQLException sqlEx) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
if (isConstraintViolation(sqlEx)) {
|
||||
return false;
|
||||
}
|
||||
throw sqlEx;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, raw_block, created_at_ms,
|
||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getMessageKey());
|
||||
ps.setString(2, e.getBaseKey());
|
||||
ps.setString(3, e.getTargetLogin());
|
||||
ps.setString(4, e.getFromLogin());
|
||||
ps.setString(5, e.getToLogin());
|
||||
ps.setLong(6, e.getTimeMs());
|
||||
ps.setLong(7, e.getNonce());
|
||||
ps.setInt(8, e.getMessageType());
|
||||
ps.setBytes(9, e.getRawBlock());
|
||||
ps.setLong(10, e.getCreatedAtMs());
|
||||
ps.setString(11, e.getSourceApi());
|
||||
ps.setString(12, e.getOriginSessionId());
|
||||
ps.setString(13, e.getReceiptRefBaseKey());
|
||||
if (e.getReceiptRefType() == null) ps.setObject(14, null);
|
||||
else ps.setInt(14, e.getReceiptRefType());
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isConstraintViolation(SQLException ex) {
|
||||
String msg = String.valueOf(ex.getMessage()).toLowerCase();
|
||||
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
||||
}
|
||||
|
||||
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
SELECT
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, raw_block, created_at_ms,
|
||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||
FROM signed_messages_v2
|
||||
WHERE message_key = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
) VALUES (?, ?, 0, NULL, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.setString(2, sessionId);
|
||||
ps.setLong(3, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void markDelivered(String messageKey, String sessionId, long deliveredAtMs) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String insertSql = """
|
||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
) VALUES (?, ?, 0, NULL, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(insertSql)) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.setString(2, sessionId);
|
||||
ps.setLong(3, deliveredAtMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
String updateSql = """
|
||||
UPDATE signed_message_session_delivery
|
||||
SET delivered = 1, delivered_at_ms = ?
|
||||
WHERE message_key = ? AND session_id = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(updateSql)) {
|
||||
ps.setLong(1, deliveredAtMs);
|
||||
ps.setString(2, messageKey);
|
||||
ps.setString(3, sessionId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId, int limit) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String fillSql = """
|
||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
)
|
||||
SELECT m.message_key, ?, 0, NULL, ?
|
||||
FROM signed_messages_v2 m
|
||||
WHERE m.target_login = ? COLLATE NOCASE
|
||||
""";
|
||||
long now = System.currentTimeMillis();
|
||||
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
||||
ps.setString(1, sessionId);
|
||||
ps.setLong(2, now);
|
||||
ps.setString(3, login);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
String sql = """
|
||||
SELECT
|
||||
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
||||
m.time_ms, m.nonce, m.message_type, m.raw_block, m.created_at_ms,
|
||||
m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type
|
||||
FROM signed_messages_v2 m
|
||||
JOIN signed_message_session_delivery d
|
||||
ON d.message_key = m.message_key
|
||||
WHERE d.session_id = ? AND d.delivered = 0
|
||||
ORDER BY m.time_ms ASC, m.created_at_ms ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, sessionId);
|
||||
ps.setInt(2, Math.max(1, limit));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception {
|
||||
SignedMessageV2Entry e = new SignedMessageV2Entry();
|
||||
e.setMessageKey(rs.getString("message_key"));
|
||||
e.setBaseKey(rs.getString("base_key"));
|
||||
e.setTargetLogin(rs.getString("target_login"));
|
||||
e.setFromLogin(rs.getString("from_login"));
|
||||
e.setToLogin(rs.getString("to_login"));
|
||||
e.setTimeMs(rs.getLong("time_ms"));
|
||||
e.setNonce(rs.getLong("nonce"));
|
||||
e.setMessageType(rs.getInt("message_type"));
|
||||
e.setRawBlock(rs.getBytes("raw_block"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setSourceApi(rs.getString("source_api"));
|
||||
e.setOriginSessionId(rs.getString("origin_session_id"));
|
||||
e.setReceiptRefBaseKey(rs.getString("receipt_ref_base_key"));
|
||||
int maybeRefType = rs.getInt("receipt_ref_type");
|
||||
e.setReceiptRefType(rs.wasNull() ? null : maybeRefType);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.SolanaUserEntry;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SolanaUsersDAO — локальная таблица пользователей из Solana.
|
||||
*
|
||||
* Таблица: solana_users
|
||||
*
|
||||
* Колонки:
|
||||
* - login TEXT PRIMARY KEY (COLLATE NOCASE)
|
||||
* - blockchain_name TEXT NOT NULL
|
||||
* - solana_key TEXT NOT NULL
|
||||
* - blockchain_key TEXT NOT NULL
|
||||
* - device_key TEXT NOT NULL
|
||||
*
|
||||
* Правило работы с соединениями:
|
||||
* - методы с Connection НЕ закрывают соединение
|
||||
* - методы без Connection сами открывают и закрывают соединение
|
||||
*/
|
||||
public final class SolanaUsersDAO {
|
||||
|
||||
private static volatile SolanaUsersDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private SolanaUsersDAO() {}
|
||||
|
||||
public static SolanaUsersDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SolanaUsersDAO.class) {
|
||||
if (instance == null) instance = new SolanaUsersDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
// -------------------- INSERT --------------------
|
||||
|
||||
/** Вставка с внешним соединением. Соединение НЕ закрывает. */
|
||||
public void insert(Connection c, SolanaUserEntry user) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO solana_users (
|
||||
login, blockchain_name, solana_key, blockchain_key, device_key
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, user.getLogin());
|
||||
ps.setString(2, user.getBlockchainName());
|
||||
ps.setString(3, user.getSolanaKey());
|
||||
ps.setString(4, user.getBlockchainKey());
|
||||
ps.setString(5, user.getDeviceKey());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Вставка без внешнего соединения. Сам открывает/закрывает. */
|
||||
public void insert(SolanaUserEntry user) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
insert(c, user);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- EXISTS --------------------
|
||||
|
||||
/** Проверка существования по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */
|
||||
public boolean existsByLogin(Connection c, String login) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM solana_users
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Проверка существования по login (case-insensitive) без внешнего соединения. Сам открывает/закрывает. */
|
||||
public boolean existsByLogin(String login) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return existsByLogin(c, login);
|
||||
}
|
||||
}
|
||||
|
||||
/** Проверка существования по blockchain_name (case-sensitive, как в БД) с внешним соединением. */
|
||||
public boolean existsByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM solana_users
|
||||
WHERE blockchain_name = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, blockchainName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Проверка существования по blockchain_name без внешнего соединения. */
|
||||
public boolean existsByBlockchainName(String blockchainName) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return existsByBlockchainName(c, blockchainName);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT --------------------
|
||||
|
||||
/** Получить по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */
|
||||
public SolanaUserEntry getByLogin(Connection c, String login) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
blockchain_name,
|
||||
solana_key,
|
||||
blockchain_key,
|
||||
device_key
|
||||
FROM solana_users
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Получить по login (case-insensitive) без внешнего соединения. Сам открывает/закрывает. */
|
||||
public SolanaUserEntry getByLogin(String login) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByLogin(c, login);
|
||||
}
|
||||
}
|
||||
|
||||
/** Получить по blockchain_name (case-sensitive) с внешним соединением. Соединение НЕ закрывает. */
|
||||
public SolanaUserEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
blockchain_name,
|
||||
solana_key,
|
||||
blockchain_key,
|
||||
device_key
|
||||
FROM solana_users
|
||||
WHERE blockchain_name = ?
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, blockchainName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Получить по blockchain_name без внешнего соединения. */
|
||||
public SolanaUserEntry getByBlockchainName(String blockchainName) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByBlockchainName(c, blockchainName);
|
||||
}
|
||||
}
|
||||
|
||||
/** Поиск по префиксу с внешним соединением. Соединение НЕ закрывает. */
|
||||
public List<SolanaUserEntry> searchByLoginPrefix(Connection c, String prefix) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
blockchain_name,
|
||||
solana_key,
|
||||
blockchain_key,
|
||||
device_key
|
||||
FROM solana_users
|
||||
WHERE LOWER(login) LIKE ?
|
||||
ORDER BY login
|
||||
LIMIT 5
|
||||
""";
|
||||
|
||||
List<SolanaUserEntry> result = new ArrayList<>();
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, prefix.toLowerCase() + "%");
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) result.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */
|
||||
public List<SolanaUserEntry> searchByLoginPrefix(String prefix) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return searchByLoginPrefix(c, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- MAPPER --------------------
|
||||
|
||||
private SolanaUserEntry mapRow(ResultSet rs) throws SQLException {
|
||||
SolanaUserEntry e = new SolanaUserEntry();
|
||||
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setBlockchainName(rs.getString("blockchain_name"));
|
||||
e.setSolanaKey(rs.getString("solana_key"));
|
||||
e.setBlockchainKey(rs.getString("blockchain_key"));
|
||||
e.setDeviceKey(rs.getString("device_key"));
|
||||
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.SqliteDbController;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SubscriptionsDAO — агрегатный DAO для "каналов" (подписок).
|
||||
*
|
||||
* Возвращает по каждой активной подписке (FOLLOW) + "сам на себя":
|
||||
* - login цели (channelLogin)
|
||||
* - blockchainName цели (channelBchName)
|
||||
* - count публикаций (TEXT_POST)
|
||||
* - last publication: bytes оригинального блока (для timestamp)
|
||||
* - last publication: bytes актуального блока (edit или orig) — для текста превью
|
||||
*
|
||||
* Важно:
|
||||
* - это НЕ таблица => сущность результата хранится вложенным классом.
|
||||
* - методы с Connection НЕ закрывают соединение
|
||||
* - методы без Connection сами открывают и закрывают соединение
|
||||
*/
|
||||
public final class SubscriptionsDAO {
|
||||
|
||||
private static volatile SubscriptionsDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private SubscriptionsDAO() {}
|
||||
|
||||
public static SubscriptionsDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (SubscriptionsDAO.class) {
|
||||
if (instance == null) instance = new SubscriptionsDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Результат одной строки ("канал") для подписок. */
|
||||
public static final class ChannelRow {
|
||||
|
||||
private final String channelLogin;
|
||||
private final String channelBchName;
|
||||
|
||||
private final int publicationsCount;
|
||||
|
||||
/** Последняя публикация: global number (nullable если публикаций нет). */
|
||||
private final Integer lastPublicationGlobalNumber;
|
||||
|
||||
/** Байты оригинальной публикации (FULL bytes блока) — для timestamp (nullable). */
|
||||
private final byte[] lastPublicationBlockBytes;
|
||||
|
||||
/** Если публикация редактировалась: global number edit-блока (nullable). */
|
||||
private final Integer lastEditGlobalNumber;
|
||||
|
||||
/** Байты edit-блока (FULL bytes блока) (nullable). */
|
||||
private final byte[] lastEditBlockBytes;
|
||||
|
||||
public ChannelRow(String channelLogin,
|
||||
String channelBchName,
|
||||
int publicationsCount,
|
||||
Integer lastPublicationGlobalNumber,
|
||||
byte[] lastPublicationBlockBytes,
|
||||
Integer lastEditGlobalNumber,
|
||||
byte[] lastEditBlockBytes) {
|
||||
|
||||
this.channelLogin = channelLogin;
|
||||
this.channelBchName = channelBchName;
|
||||
this.publicationsCount = publicationsCount;
|
||||
this.lastPublicationGlobalNumber = lastPublicationGlobalNumber;
|
||||
this.lastPublicationBlockBytes = lastPublicationBlockBytes;
|
||||
this.lastEditGlobalNumber = lastEditGlobalNumber;
|
||||
this.lastEditBlockBytes = lastEditBlockBytes;
|
||||
}
|
||||
|
||||
public String getChannelLogin() { return channelLogin; }
|
||||
public String getChannelBchName() { return channelBchName; }
|
||||
|
||||
public int getPublicationsCount() { return publicationsCount; }
|
||||
|
||||
public Integer getLastPublicationGlobalNumber() { return lastPublicationGlobalNumber; }
|
||||
public byte[] getLastPublicationBlockBytes() { return lastPublicationBlockBytes; }
|
||||
|
||||
public Integer getLastEditGlobalNumber() { return lastEditGlobalNumber; }
|
||||
public byte[] getLastEditBlockBytes() { return lastEditBlockBytes; }
|
||||
}
|
||||
|
||||
// В проекте msg_type=1 означает TEXT (у тебя это уже зафиксировано).
|
||||
private static final int MSG_TYPE_TEXT = 1;
|
||||
|
||||
/**
|
||||
* Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой:
|
||||
* - count публикаций (TEXT_POST)
|
||||
* - последнюю публикацию (orig bytes) + её edit (если есть)
|
||||
*
|
||||
* Поведение при 0 публикаций:
|
||||
* - publications_count = 0
|
||||
* - last_pub_* = NULL
|
||||
* - last_edit_* = NULL
|
||||
*/
|
||||
public List<ChannelRow> getSubscribedChannels(Connection c, String requesterLogin) throws SQLException {
|
||||
|
||||
String sql = """
|
||||
WITH subs AS (
|
||||
-- 1) FOLLOW-каналы
|
||||
SELECT
|
||||
cs.to_login AS channel_login,
|
||||
cs.to_bch_name AS channel_bch_name
|
||||
FROM connections_state cs
|
||||
WHERE cs.login = ?
|
||||
AND cs.rel_type = ?
|
||||
|
||||
UNION
|
||||
|
||||
-- 2) self: все блокчейны пользователя (если их несколько)
|
||||
SELECT
|
||||
bs.login AS channel_login,
|
||||
bs.blockchain_name AS channel_bch_name
|
||||
FROM blockchain_state bs
|
||||
WHERE bs.login = ?
|
||||
),
|
||||
pub_counts AS (
|
||||
SELECT
|
||||
b.login AS channel_login,
|
||||
b.bch_name AS channel_bch_name,
|
||||
COUNT(*) AS publications_count
|
||||
FROM blocks b
|
||||
JOIN subs s
|
||||
ON s.channel_login = b.login
|
||||
AND s.channel_bch_name = b.bch_name
|
||||
WHERE b.msg_type = ?
|
||||
AND b.msg_sub_type IN (?, ?)
|
||||
GROUP BY b.login, b.bch_name
|
||||
),
|
||||
last_pub AS (
|
||||
SELECT
|
||||
b.login AS channel_login,
|
||||
b.bch_name AS channel_bch_name,
|
||||
MAX(b.block_global_number) AS last_pub_global_number
|
||||
FROM blocks b
|
||||
JOIN subs s
|
||||
ON s.channel_login = b.login
|
||||
AND s.channel_bch_name = b.bch_name
|
||||
WHERE b.msg_type = ?
|
||||
AND b.msg_sub_type IN (?, ?)
|
||||
GROUP BY b.login, b.bch_name
|
||||
),
|
||||
last_pub_block AS (
|
||||
SELECT
|
||||
b.login AS channel_login,
|
||||
b.bch_name AS channel_bch_name,
|
||||
b.block_global_number AS last_pub_global_number,
|
||||
b.block_byte AS last_pub_block_bytes,
|
||||
b.edited_by_block_global_number AS last_edit_global_number
|
||||
FROM blocks b
|
||||
JOIN last_pub lp
|
||||
ON lp.channel_login = b.login
|
||||
AND lp.channel_bch_name = b.bch_name
|
||||
AND lp.last_pub_global_number = b.block_global_number
|
||||
),
|
||||
last_edit_block AS (
|
||||
SELECT
|
||||
e.login AS channel_login,
|
||||
e.bch_name AS channel_bch_name,
|
||||
e.block_global_number AS last_edit_global_number,
|
||||
e.block_byte AS last_edit_block_bytes
|
||||
FROM blocks e
|
||||
JOIN last_pub_block p
|
||||
ON p.channel_login = e.login
|
||||
AND p.channel_bch_name = e.bch_name
|
||||
AND p.last_edit_global_number = e.block_global_number
|
||||
)
|
||||
SELECT
|
||||
s.channel_login,
|
||||
s.channel_bch_name,
|
||||
COALESCE(pc.publications_count, 0) AS publications_count,
|
||||
p.last_pub_global_number,
|
||||
p.last_pub_block_bytes,
|
||||
p.last_edit_global_number,
|
||||
e.last_edit_block_bytes
|
||||
FROM subs s
|
||||
LEFT JOIN pub_counts pc
|
||||
ON pc.channel_login = s.channel_login
|
||||
AND pc.channel_bch_name = s.channel_bch_name
|
||||
LEFT JOIN last_pub_block p
|
||||
ON p.channel_login = s.channel_login
|
||||
AND p.channel_bch_name = s.channel_bch_name
|
||||
LEFT JOIN last_edit_block e
|
||||
ON e.channel_login = s.channel_login
|
||||
AND e.channel_bch_name = s.channel_bch_name
|
||||
ORDER BY s.channel_login, s.channel_bch_name
|
||||
""";
|
||||
|
||||
List<ChannelRow> out = new ArrayList<>();
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
|
||||
// FOLLOW
|
||||
ps.setString(i++, requesterLogin);
|
||||
ps.setInt(i++, (int) MsgSubType.CONNECTION_FOLLOW);
|
||||
|
||||
// self
|
||||
ps.setString(i++, requesterLogin);
|
||||
|
||||
// pub_counts
|
||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
||||
|
||||
// last_pub
|
||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
||||
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String channelLogin = rs.getString("channel_login");
|
||||
String channelBchName = rs.getString("channel_bch_name");
|
||||
|
||||
int publicationsCount = rs.getInt("publications_count");
|
||||
|
||||
Integer lastPubGn = (Integer) rs.getObject("last_pub_global_number");
|
||||
byte[] lastPubBytes = rs.getBytes("last_pub_block_bytes");
|
||||
|
||||
Integer lastEditGn = (Integer) rs.getObject("last_edit_global_number");
|
||||
byte[] lastEditBytes = rs.getBytes("last_edit_block_bytes");
|
||||
|
||||
out.add(new ChannelRow(
|
||||
channelLogin,
|
||||
channelBchName,
|
||||
publicationsCount,
|
||||
lastPubGn,
|
||||
lastPubBytes,
|
||||
lastEditGn,
|
||||
lastEditBytes
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Вариант без внешнего соединения. Сам открывает/закрывает. */
|
||||
public List<ChannelRow> getSubscribedChannels(String requesterLogin) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getSubscribedChannels(c, requesterLogin);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.SolanaUserEntry;
|
||||
|
||||
import java.sql.*;
|
||||
|
||||
/**
|
||||
* UserCreateDAO — атомарное добавление пользователя:
|
||||
* - solana_users (login, blockchain_name, solana_key, blockchain_key, device_key)
|
||||
* - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...)
|
||||
*
|
||||
* ВАЖНО:
|
||||
* - только INSERT (без перезаписи существующих записей)
|
||||
* - если login или blockchainName заняты — возвращаем false (пользователь уже есть/занято)
|
||||
*/
|
||||
public final class UserCreateDAO {
|
||||
|
||||
private static volatile UserCreateDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
private final SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance();
|
||||
|
||||
private UserCreateDAO() {}
|
||||
|
||||
public static UserCreateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserCreateDAO.class) {
|
||||
if (instance == null) instance = new UserCreateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true если добавили; false если занято (login уже есть или blockchainName уже существует).
|
||||
*/
|
||||
public boolean insertUserWithBlockchain(
|
||||
String login,
|
||||
String blockchainName,
|
||||
String solanaKey,
|
||||
String blockchainKey,
|
||||
String deviceKey,
|
||||
long sizeLimit,
|
||||
long nowMs
|
||||
) throws SQLException {
|
||||
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAuto = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
|
||||
try {
|
||||
// 1) solana_users
|
||||
SolanaUserEntry u = new SolanaUserEntry();
|
||||
u.setLogin(login);
|
||||
u.setBlockchainName(blockchainName);
|
||||
u.setSolanaKey(solanaKey);
|
||||
u.setBlockchainKey(blockchainKey);
|
||||
u.setDeviceKey(deviceKey);
|
||||
|
||||
usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint
|
||||
|
||||
// 2) blockchain_state — строго INSERT, без UPSERT (иначе можно перезаписать существующую цепочку)
|
||||
insertBlockchainStateStrict(
|
||||
c,
|
||||
blockchainName,
|
||||
login,
|
||||
blockchainKey,
|
||||
sizeLimit,
|
||||
nowMs
|
||||
);
|
||||
|
||||
c.commit();
|
||||
return true;
|
||||
|
||||
} catch (SQLException e) {
|
||||
c.rollback();
|
||||
|
||||
String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase();
|
||||
if (msg.contains("constraint")) {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
|
||||
} finally {
|
||||
c.setAutoCommit(oldAuto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void insertBlockchainStateStrict(
|
||||
Connection c,
|
||||
String blockchainName,
|
||||
String login,
|
||||
String blockchainKey,
|
||||
long sizeLimit,
|
||||
long nowMs
|
||||
) throws SQLException {
|
||||
|
||||
String sql = """
|
||||
INSERT INTO blockchain_state (
|
||||
blockchain_name,
|
||||
login,
|
||||
blockchain_key,
|
||||
size_limit,
|
||||
file_size_bytes,
|
||||
last_block_number,
|
||||
last_block_hash,
|
||||
updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
ps.setString(i++, blockchainName);
|
||||
ps.setString(i++, login);
|
||||
ps.setString(i++, blockchainKey);
|
||||
|
||||
ps.setLong(i++, sizeLimit);
|
||||
ps.setLong(i++, 0L);
|
||||
|
||||
ps.setInt(i++, -1);
|
||||
ps.setNull(i++, Types.BLOB); // старт: блоков ещё нет
|
||||
ps.setLong(i++, nowMs);
|
||||
|
||||
ps.executeUpdate(); // если blockchainName занят -> constraint (PK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* UserParamsDAO — хранение сохранённых параметров пользователя.
|
||||
*
|
||||
* Правило:
|
||||
* - методы с Connection НЕ закрывают соединение
|
||||
* - методы без Connection сами открывают и закрывают соединение
|
||||
*
|
||||
* ЛОГИКА time_ms:
|
||||
* - БД принимает запись только если она "новее" (time_ms строго больше текущего).
|
||||
* - Реализовано атомарно одним SQL: UPSERT + WHERE users_params.time_ms < excluded.time_ms
|
||||
*/
|
||||
public final class UserParamsDAO {
|
||||
|
||||
private static volatile UserParamsDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private UserParamsDAO() { }
|
||||
|
||||
public static UserParamsDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserParamsDAO.class) {
|
||||
if (instance == null) instance = new UserParamsDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
// -------------------- UPSERT (IF NEWER) --------------------
|
||||
|
||||
public int upsertIfNewer(Connection c, UserParamEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO users_params (
|
||||
login,
|
||||
param,
|
||||
time_ms,
|
||||
value,
|
||||
device_key,
|
||||
signature
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(login, param)
|
||||
DO UPDATE SET
|
||||
time_ms = excluded.time_ms,
|
||||
value = excluded.value,
|
||||
device_key = excluded.device_key,
|
||||
signature = excluded.signature
|
||||
WHERE users_params.time_ms < excluded.time_ms
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, e.getLogin());
|
||||
ps.setString(2, e.getParam());
|
||||
ps.setLong(3, e.getTimeMs());
|
||||
ps.setString(4, e.getValue());
|
||||
|
||||
if (e.getDeviceKey() != null) ps.setString(5, e.getDeviceKey());
|
||||
else ps.setNull(5, Types.VARCHAR);
|
||||
|
||||
if (e.getSignature() != null) ps.setString(6, e.getSignature());
|
||||
else ps.setNull(6, Types.VARCHAR);
|
||||
|
||||
return ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int upsertIfNewer(UserParamEntry e) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return upsertIfNewer(c, e);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT --------------------
|
||||
|
||||
public UserParamEntry getByLoginAndParam(Connection c, String login, String param) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
param,
|
||||
time_ms,
|
||||
value,
|
||||
device_key,
|
||||
signature
|
||||
FROM users_params
|
||||
WHERE login = ? COLLATE NOCASE AND param = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
ps.setString(2, param);
|
||||
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public UserParamEntry getByLoginAndParam(String login, String param) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByLoginAndParam(c, login, param);
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserParamEntry> getByLogin(Connection c, String login) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
param,
|
||||
time_ms,
|
||||
value,
|
||||
device_key,
|
||||
signature
|
||||
FROM users_params
|
||||
WHERE login = ? COLLATE NOCASE
|
||||
ORDER BY time_ms DESC
|
||||
""";
|
||||
|
||||
List<UserParamEntry> list = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) list.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public List<UserParamEntry> getByLogin(String login) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByLogin(c, login);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- MAPPER --------------------
|
||||
|
||||
private static UserParamEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserParamEntry e = new UserParamEntry();
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setParam(rs.getString("param"));
|
||||
e.setTimeMs(rs.getLong("time_ms"));
|
||||
e.setValue(rs.getString("value"));
|
||||
|
||||
String dk = rs.getString("device_key");
|
||||
if (rs.wasNull()) dk = null;
|
||||
e.setDeviceKey(dk);
|
||||
|
||||
String sig = rs.getString("signature");
|
||||
if (rs.wasNull()) sig = null;
|
||||
e.setSignature(sig);
|
||||
|
||||
return e;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
DAO: правило перегруженных методов (короткая справка)
|
||||
|
||||
1) Всегда два метода:
|
||||
|
||||
insert(Connection c, …) // не закрывает соединение, не коммитит
|
||||
insert(…) // открывает своё соединение и сам закрывает
|
||||
|
||||
|
||||
2) Внутри одного бизнес-метода:
|
||||
открываем одно соединение conn = db.getConnection();
|
||||
делаем несколько DAO-вызовов через версии с Connection
|
||||
в конце вручную conn.commit();
|
||||
conn закрываем только один раз в finally
|
||||
|
||||
3) DAO-методы с Connection:
|
||||
не создают соединение
|
||||
не закрывают соединение
|
||||
не делают commit/rollback
|
||||
|
||||
4) DAO-методы без Connection:
|
||||
маленькие удобные обёртки
|
||||
открывают соединение в try-with-resources
|
||||
внутри вызывают версию с Connection
|
||||
|
||||
5) Итог:
|
||||
одиночные операции → вызываем короткий метод без Connection
|
||||
пакетные/атомарные операции → берём одно соединение и используем только методы с Connection
|
||||
@@ -0,0 +1,95 @@
|
||||
package shine.db.entities;
|
||||
|
||||
/**
|
||||
* Модель активной сессии (таблица active_sessions).
|
||||
*/
|
||||
public class ActiveSessionEntry {
|
||||
|
||||
private String sessionId;
|
||||
private String login;
|
||||
|
||||
/** session_key: публичный ключ сессии целиком одной строкой, например ed25519/BASE64_PUBLIC_KEY. */
|
||||
private String sessionKey;
|
||||
|
||||
private String storagePwd;
|
||||
private long sessionCreatedAtMs;
|
||||
private long lastAuthirificatedAtMs;
|
||||
|
||||
private String pushEndpoint;
|
||||
private String pushP256dhKey;
|
||||
private String pushAuthKey;
|
||||
|
||||
private String clientIp;
|
||||
private String clientInfoFromClient;
|
||||
private String clientInfoFromRequest;
|
||||
private String userLanguage;
|
||||
|
||||
public ActiveSessionEntry() { }
|
||||
|
||||
public ActiveSessionEntry(String sessionId,
|
||||
String login,
|
||||
String sessionKey,
|
||||
String storagePwd,
|
||||
long sessionCreatedAtMs,
|
||||
long lastAuthirificatedAtMs,
|
||||
String pushEndpoint,
|
||||
String pushP256dhKey,
|
||||
String pushAuthKey,
|
||||
String clientIp,
|
||||
String clientInfoFromClient,
|
||||
String clientInfoFromRequest,
|
||||
String userLanguage) {
|
||||
this.sessionId = sessionId;
|
||||
this.login = login;
|
||||
this.sessionKey = sessionKey;
|
||||
this.storagePwd = storagePwd;
|
||||
this.sessionCreatedAtMs = sessionCreatedAtMs;
|
||||
this.lastAuthirificatedAtMs = lastAuthirificatedAtMs;
|
||||
this.pushEndpoint = pushEndpoint;
|
||||
this.pushP256dhKey = pushP256dhKey;
|
||||
this.pushAuthKey = pushAuthKey;
|
||||
this.clientIp = clientIp;
|
||||
this.clientInfoFromClient = clientInfoFromClient;
|
||||
this.clientInfoFromRequest = clientInfoFromRequest;
|
||||
this.userLanguage = userLanguage;
|
||||
}
|
||||
|
||||
public String getSessionId() { return sessionId; }
|
||||
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getSessionKey() { return sessionKey; }
|
||||
public void setSessionKey(String sessionKey) { this.sessionKey = sessionKey; }
|
||||
|
||||
public String getStoragePwd() { return storagePwd; }
|
||||
public void setStoragePwd(String storagePwd) { this.storagePwd = storagePwd; }
|
||||
|
||||
public long getSessionCreatedAtMs() { return sessionCreatedAtMs; }
|
||||
public void setSessionCreatedAtMs(long sessionCreatedAtMs) { this.sessionCreatedAtMs = sessionCreatedAtMs; }
|
||||
|
||||
public long getLastAuthirificatedAtMs() { return lastAuthirificatedAtMs; }
|
||||
public void setLastAuthirificatedAtMs(long lastAuthirificatedAtMs) { this.lastAuthirificatedAtMs = lastAuthirificatedAtMs; }
|
||||
|
||||
public String getPushEndpoint() { return pushEndpoint; }
|
||||
public void setPushEndpoint(String pushEndpoint) { this.pushEndpoint = pushEndpoint; }
|
||||
|
||||
public String getPushP256dhKey() { return pushP256dhKey; }
|
||||
public void setPushP256dhKey(String pushP256dhKey) { this.pushP256dhKey = pushP256dhKey; }
|
||||
|
||||
public String getPushAuthKey() { return pushAuthKey; }
|
||||
public void setPushAuthKey(String pushAuthKey) { this.pushAuthKey = pushAuthKey; }
|
||||
|
||||
public String getClientIp() { return clientIp; }
|
||||
public void setClientIp(String clientIp) { this.clientIp = clientIp; }
|
||||
|
||||
public String getClientInfoFromClient() { return clientInfoFromClient; }
|
||||
public void setClientInfoFromClient(String clientInfoFromClient) { this.clientInfoFromClient = clientInfoFromClient; }
|
||||
|
||||
public String getClientInfoFromRequest() { return clientInfoFromRequest; }
|
||||
public void setClientInfoFromRequest(String clientInfoFromRequest) { this.clientInfoFromRequest = clientInfoFromRequest; }
|
||||
|
||||
public String getUserLanguage() { return userLanguage; }
|
||||
public void setUserLanguage(String userLanguage) { this.userLanguage = userLanguage; }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package shine.db.entities;
|
||||
|
||||
/**
|
||||
* Запись блока (таблица blocks) — обновлённая модель под новый формат.
|
||||
*
|
||||
* Храним:
|
||||
* - login, bch_name (как было в проекте, чтобы не ломать общую БД)
|
||||
* - block_number (глобальный номер в этой цепочке)
|
||||
* - block_bytes (полный блок: preimage + signature)
|
||||
* - block_hash (32 байта вычисленный SHA-256(preimage))
|
||||
* - block_signature (64 байта)
|
||||
*
|
||||
* Опционально:
|
||||
* - line_code / prev_line_number / prev_line_hash / this_line_number
|
||||
*
|
||||
* Плюс поля индексации:
|
||||
* - msg_type / msg_sub_type
|
||||
* - to_* (если есть target)
|
||||
* - edited_by_block_number (для TEXT_EDIT_POST / TEXT_EDIT_REPLY)
|
||||
*/
|
||||
public class BlockEntry {
|
||||
|
||||
private String login;
|
||||
private String bchName;
|
||||
|
||||
private int blockNumber;
|
||||
|
||||
private int msgType;
|
||||
private int msgSubType;
|
||||
|
||||
private byte[] blockBytes;
|
||||
|
||||
private String toLogin;
|
||||
private String toBchName;
|
||||
private Integer toBlockNumber;
|
||||
private byte[] toBlockHash;
|
||||
|
||||
private byte[] blockHash;
|
||||
private byte[] blockSignature;
|
||||
|
||||
private Integer editedByBlockNumber;
|
||||
|
||||
// NEW:
|
||||
private Integer lineCode;
|
||||
|
||||
private Integer prevLineNumber;
|
||||
private byte[] prevLineHash;
|
||||
private Integer thisLineNumber;
|
||||
|
||||
public BlockEntry() {}
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getBchName() { return bchName; }
|
||||
public void setBchName(String bchName) { this.bchName = bchName; }
|
||||
|
||||
public int getBlockNumber() { return blockNumber; }
|
||||
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||
|
||||
public int getMsgType() { return msgType; }
|
||||
public void setMsgType(int msgType) { this.msgType = msgType; }
|
||||
|
||||
public int getMsgSubType() { return msgSubType; }
|
||||
public void setMsgSubType(int msgSubType) { this.msgSubType = msgSubType; }
|
||||
|
||||
public byte[] getBlockBytes() { return blockBytes; }
|
||||
public void setBlockBytes(byte[] blockBytes) { this.blockBytes = blockBytes; }
|
||||
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
|
||||
public String getToBchName() { return toBchName; }
|
||||
public void setToBchName(String toBchName) { this.toBchName = toBchName; }
|
||||
|
||||
public Integer getToBlockNumber() { return toBlockNumber; }
|
||||
public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; }
|
||||
|
||||
public byte[] getToBlockHash() { return toBlockHash; }
|
||||
public void setToBlockHash(byte[] toBlockHash) { this.toBlockHash = toBlockHash; }
|
||||
|
||||
public byte[] getBlockHash() { return blockHash; }
|
||||
public void setBlockHash(byte[] blockHash) { this.blockHash = blockHash; }
|
||||
|
||||
public byte[] getBlockSignature() { return blockSignature; }
|
||||
public void setBlockSignature(byte[] blockSignature) { this.blockSignature = blockSignature; }
|
||||
|
||||
public Integer getEditedByBlockNumber() { return editedByBlockNumber; }
|
||||
public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; }
|
||||
|
||||
// NEW:
|
||||
public Integer getLineCode() { return lineCode; }
|
||||
public void setLineCode(Integer lineCode) { this.lineCode = lineCode; }
|
||||
|
||||
public Integer getPrevLineNumber() { return prevLineNumber; }
|
||||
public void setPrevLineNumber(Integer prevLineNumber) { this.prevLineNumber = prevLineNumber; }
|
||||
|
||||
public byte[] getPrevLineHash() { return prevLineHash; }
|
||||
public void setPrevLineHash(byte[] prevLineHash) { this.prevLineHash = prevLineHash; }
|
||||
|
||||
public Integer getThisLineNumber() { return thisLineNumber; }
|
||||
public void setThisLineNumber(Integer thisLineNumber) { this.thisLineNumber = thisLineNumber; }
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package shine.db.entities;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Агрегатная сущность текущего состояния блокчейна.
|
||||
*
|
||||
* ВАЖНО:
|
||||
* - Убраны все поля линий line0..7 (они больше не нужны).
|
||||
* - Оставляем:
|
||||
* last_block_number
|
||||
* last_block_hash
|
||||
*
|
||||
* Остальные поля (login, blockchain_key, лимиты) оставлены как в проекте,
|
||||
* потому что серверу они реально нужны (ключ подписи/лимит файла).
|
||||
*/
|
||||
public final class BlockchainStateEntry {
|
||||
|
||||
private String blockchainName;
|
||||
private String login;
|
||||
|
||||
private String blockchainKey; // Base64(32)
|
||||
|
||||
private long sizeLimit;
|
||||
private long fileSizeBytes;
|
||||
|
||||
private int lastBlockNumber; // было last_global_number
|
||||
private byte[] lastBlockHash; // было last_global_hash (nullable)
|
||||
|
||||
private long updatedAtMs;
|
||||
|
||||
public BlockchainStateEntry() {}
|
||||
|
||||
public String getBlockchainName() { return blockchainName; }
|
||||
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getBlockchainKey() { return blockchainKey; }
|
||||
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
|
||||
|
||||
public byte[] getBlockchainKeyBytes() {
|
||||
if (blockchainKey == null) return null;
|
||||
String s = blockchainKey.trim();
|
||||
if (s.isEmpty()) return null;
|
||||
try {
|
||||
byte[] b = Base64.getDecoder().decode(s);
|
||||
return (b != null && b.length == 32) ? b : null;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public long getSizeLimit() { return sizeLimit; }
|
||||
public void setSizeLimit(long sizeLimit) { this.sizeLimit = sizeLimit; }
|
||||
|
||||
public long getFileSizeBytes() { return fileSizeBytes; }
|
||||
public void setFileSizeBytes(long fileSizeBytes) { this.fileSizeBytes = fileSizeBytes; }
|
||||
|
||||
public int getLastBlockNumber() { return lastBlockNumber; }
|
||||
public void setLastBlockNumber(int lastBlockNumber) { this.lastBlockNumber = lastBlockNumber; }
|
||||
|
||||
public byte[] getLastBlockHash() { return lastBlockHash; }
|
||||
public void setLastBlockHash(byte[] lastBlockHash) { this.lastBlockHash = lastBlockHash; }
|
||||
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package shine.db.entities;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public class ChannelNameStateEntry {
|
||||
private String slug;
|
||||
private String displayName;
|
||||
private String channelDescription;
|
||||
private String ownerLogin;
|
||||
private String ownerBlockchainName;
|
||||
private int channelTypeCode;
|
||||
private int channelTypeVersion;
|
||||
private int channelRootBlockNumber;
|
||||
private byte[] channelRootBlockHash;
|
||||
private long createdAtMs;
|
||||
|
||||
public String getSlug() {
|
||||
return slug;
|
||||
}
|
||||
|
||||
public void setSlug(String slug) {
|
||||
this.slug = slug;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getChannelDescription() {
|
||||
return channelDescription;
|
||||
}
|
||||
|
||||
public void setChannelDescription(String channelDescription) {
|
||||
this.channelDescription = channelDescription;
|
||||
}
|
||||
|
||||
public String getOwnerLogin() {
|
||||
return ownerLogin;
|
||||
}
|
||||
|
||||
public void setOwnerLogin(String ownerLogin) {
|
||||
this.ownerLogin = ownerLogin;
|
||||
}
|
||||
|
||||
public String getOwnerBlockchainName() {
|
||||
return ownerBlockchainName;
|
||||
}
|
||||
|
||||
public void setOwnerBlockchainName(String ownerBlockchainName) {
|
||||
this.ownerBlockchainName = ownerBlockchainName;
|
||||
}
|
||||
|
||||
public int getChannelTypeCode() {
|
||||
return channelTypeCode;
|
||||
}
|
||||
|
||||
public void setChannelTypeCode(int channelTypeCode) {
|
||||
this.channelTypeCode = channelTypeCode;
|
||||
}
|
||||
|
||||
public int getChannelTypeVersion() {
|
||||
return channelTypeVersion;
|
||||
}
|
||||
|
||||
public void setChannelTypeVersion(int channelTypeVersion) {
|
||||
this.channelTypeVersion = channelTypeVersion;
|
||||
}
|
||||
|
||||
public int getChannelRootBlockNumber() {
|
||||
return channelRootBlockNumber;
|
||||
}
|
||||
|
||||
public void setChannelRootBlockNumber(int channelRootBlockNumber) {
|
||||
this.channelRootBlockNumber = channelRootBlockNumber;
|
||||
}
|
||||
|
||||
public byte[] getChannelRootBlockHash() {
|
||||
return channelRootBlockHash == null ? null : Arrays.copyOf(channelRootBlockHash, channelRootBlockHash.length);
|
||||
}
|
||||
|
||||
public void setChannelRootBlockHash(byte[] channelRootBlockHash) {
|
||||
this.channelRootBlockHash = channelRootBlockHash == null ? null : Arrays.copyOf(channelRootBlockHash, channelRootBlockHash.length);
|
||||
}
|
||||
|
||||
public long getCreatedAtMs() {
|
||||
return createdAtMs;
|
||||
}
|
||||
|
||||
public void setCreatedAtMs(long createdAtMs) {
|
||||
this.createdAtMs = createdAtMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class DirectMessageEntry {
|
||||
private String messageId;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private String text;
|
||||
private long createdAtMs;
|
||||
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
|
||||
public String getText() { return text; }
|
||||
public void setText(String text) { this.text = text; }
|
||||
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package shine.db.entities;
|
||||
|
||||
/**
|
||||
* Запись в таблице ip_geo_cache.
|
||||
*/
|
||||
public class IpGeoCacheEntry {
|
||||
|
||||
private String ip;
|
||||
private String geo;
|
||||
private long updatedAtMs;
|
||||
|
||||
public IpGeoCacheEntry() {
|
||||
}
|
||||
|
||||
public IpGeoCacheEntry(String ip, String geo, long updatedAtMs) {
|
||||
this.ip = ip;
|
||||
this.geo = geo;
|
||||
this.updatedAtMs = updatedAtMs;
|
||||
}
|
||||
|
||||
public String getIp() {
|
||||
return ip;
|
||||
}
|
||||
|
||||
public void setIp(String ip) {
|
||||
this.ip = ip;
|
||||
}
|
||||
|
||||
public String getGeo() {
|
||||
return geo;
|
||||
}
|
||||
|
||||
public void setGeo(String geo) {
|
||||
this.geo = geo;
|
||||
}
|
||||
|
||||
public long getUpdatedAtMs() {
|
||||
return updatedAtMs;
|
||||
}
|
||||
|
||||
public void setUpdatedAtMs(long updatedAtMs) {
|
||||
this.updatedAtMs = updatedAtMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class PushTokenEntry {
|
||||
private String tokenId;
|
||||
private String login;
|
||||
private String sessionId;
|
||||
private String provider;
|
||||
private String token;
|
||||
private String platform;
|
||||
private String userAgent;
|
||||
private long updatedAtMs;
|
||||
|
||||
public String getTokenId() { return tokenId; }
|
||||
public void setTokenId(String tokenId) { this.tokenId = tokenId; }
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getSessionId() { return sessionId; }
|
||||
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
||||
|
||||
public String getProvider() { return provider; }
|
||||
public void setProvider(String provider) { this.provider = provider; }
|
||||
|
||||
public String getToken() { return token; }
|
||||
public void setToken(String token) { this.token = token; }
|
||||
|
||||
public String getPlatform() { return platform; }
|
||||
public void setPlatform(String platform) { this.platform = platform; }
|
||||
|
||||
public String getUserAgent() { return userAgent; }
|
||||
public void setUserAgent(String userAgent) { this.userAgent = userAgent; }
|
||||
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class SignedDirectMessageHistoryEntry {
|
||||
private String messageId;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private int targetMode;
|
||||
private String targetSessionId;
|
||||
private int messageType;
|
||||
private long timeMs;
|
||||
private long nonce;
|
||||
private byte[] rawPacket;
|
||||
private long createdAtMs;
|
||||
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
public int getTargetMode() { return targetMode; }
|
||||
public void setTargetMode(int targetMode) { this.targetMode = targetMode; }
|
||||
public String getTargetSessionId() { return targetSessionId; }
|
||||
public void setTargetSessionId(String targetSessionId) { this.targetSessionId = targetSessionId; }
|
||||
public int getMessageType() { return messageType; }
|
||||
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||
public long getTimeMs() { return timeMs; }
|
||||
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||
public long getNonce() { return nonce; }
|
||||
public void setNonce(long nonce) { this.nonce = nonce; }
|
||||
public byte[] getRawPacket() { return rawPacket; }
|
||||
public void setRawPacket(byte[] rawPacket) { this.rawPacket = rawPacket; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class SignedMessageV2Entry {
|
||||
private String messageKey;
|
||||
private String baseKey;
|
||||
private String targetLogin;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private long timeMs;
|
||||
private long nonce;
|
||||
private int messageType;
|
||||
private byte[] rawBlock;
|
||||
private long createdAtMs;
|
||||
private String sourceApi;
|
||||
private String originSessionId;
|
||||
private String receiptRefBaseKey;
|
||||
private Integer receiptRefType;
|
||||
|
||||
public String getMessageKey() { return messageKey; }
|
||||
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
|
||||
public String getBaseKey() { return baseKey; }
|
||||
public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
|
||||
public String getTargetLogin() { return targetLogin; }
|
||||
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
public long getTimeMs() { return timeMs; }
|
||||
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||
public long getNonce() { return nonce; }
|
||||
public void setNonce(long nonce) { this.nonce = nonce; }
|
||||
public int getMessageType() { return messageType; }
|
||||
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||
public byte[] getRawBlock() { return rawBlock; }
|
||||
public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
public String getSourceApi() { return sourceApi; }
|
||||
public void setSourceApi(String sourceApi) { this.sourceApi = sourceApi; }
|
||||
public String getOriginSessionId() { return originSessionId; }
|
||||
public void setOriginSessionId(String originSessionId) { this.originSessionId = originSessionId; }
|
||||
public String getReceiptRefBaseKey() { return receiptRefBaseKey; }
|
||||
public void setReceiptRefBaseKey(String receiptRefBaseKey) { this.receiptRefBaseKey = receiptRefBaseKey; }
|
||||
public Integer getReceiptRefType() { return receiptRefType; }
|
||||
public void setReceiptRefType(Integer receiptRefType) { this.receiptRefType = receiptRefType; }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package shine.db.entities;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* SolanaUserEntry — локальная запись пользователя из Solana.
|
||||
*
|
||||
* Таблица: solana_users
|
||||
*
|
||||
* Поля:
|
||||
* - login — PRIMARY KEY (TEXT) (case-insensitive на уровне COLLATE NOCASE)
|
||||
* - blockchain_name — TEXT NOT NULL
|
||||
* - solana_key — TEXT NOT NULL
|
||||
* - blockchain_key — TEXT NOT NULL
|
||||
* - device_key — TEXT NOT NULL
|
||||
*/
|
||||
public class SolanaUserEntry {
|
||||
|
||||
private String login;
|
||||
|
||||
private String blockchainName;
|
||||
|
||||
/** Ключ пользователя Solana (публичный ключ логина) */
|
||||
private String solanaKey;
|
||||
|
||||
/** Ключ блокчейна (публичный ключ блокчейна) */
|
||||
private String blockchainKey;
|
||||
|
||||
/** Ключ устройства (публичный ключ устройства) */
|
||||
private String deviceKey;
|
||||
|
||||
public SolanaUserEntry() {}
|
||||
|
||||
public SolanaUserEntry(String login,
|
||||
String blockchainName,
|
||||
String solanaKey,
|
||||
String blockchainKey,
|
||||
String deviceKey) {
|
||||
this.login = login;
|
||||
this.blockchainName = blockchainName;
|
||||
this.solanaKey = solanaKey;
|
||||
this.blockchainKey = blockchainKey;
|
||||
this.deviceKey = deviceKey;
|
||||
}
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getBlockchainName() { return blockchainName; }
|
||||
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||
|
||||
public String getSolanaKey() { return solanaKey; }
|
||||
public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; }
|
||||
|
||||
public String getBlockchainKey() { return blockchainKey; }
|
||||
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
|
||||
|
||||
public String getDeviceKey() { return deviceKey; }
|
||||
public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; }
|
||||
|
||||
// оставляю этот метод как утилиту (иногда удобно), но он работает только для deviceKey:
|
||||
public byte[] getDeviceKeyByte() {
|
||||
if (deviceKey == null) return null;
|
||||
String s = deviceKey.trim();
|
||||
if (s.isEmpty()) return null;
|
||||
|
||||
try {
|
||||
byte[] b = Base64.getDecoder().decode(s);
|
||||
if (b != null && b.length == 32) return b;
|
||||
} catch (IllegalArgumentException ignore) {}
|
||||
|
||||
if (s.length() == 64 && s.matches("^[0-9a-fA-F]+$")) {
|
||||
byte[] out = new byte[32];
|
||||
for (int i = 0; i < 32; i++) {
|
||||
int hi = Character.digit(s.charAt(i * 2), 16);
|
||||
int lo = Character.digit(s.charAt(i * 2 + 1), 16);
|
||||
out[i] = (byte) ((hi << 4) | lo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package shine.db.entities;
|
||||
|
||||
/**
|
||||
* UserParamEntry — сохранённый параметр пользователя.
|
||||
*
|
||||
* Таблица: users_params
|
||||
* - login TEXT NOT NULL
|
||||
* - param TEXT NOT NULL
|
||||
* - time_ms INTEGER NOT NULL
|
||||
* - value TEXT NOT NULL
|
||||
* - device_key TEXT NULL
|
||||
* - signature TEXT NULL
|
||||
*/
|
||||
public class UserParamEntry {
|
||||
|
||||
private String login;
|
||||
private String param;
|
||||
private long timeMs;
|
||||
private String value;
|
||||
|
||||
private String deviceKey;
|
||||
private String signature;
|
||||
|
||||
public UserParamEntry() {}
|
||||
|
||||
public UserParamEntry(String login, String param, long timeMs, String value, String deviceKey, String signature) {
|
||||
this.login = login;
|
||||
this.param = param;
|
||||
this.timeMs = timeMs;
|
||||
this.value = value;
|
||||
this.deviceKey = deviceKey;
|
||||
this.signature = signature;
|
||||
}
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getParam() { return param; }
|
||||
public void setParam(String param) { this.param = param; }
|
||||
|
||||
public long getTimeMs() { return timeMs; }
|
||||
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||
|
||||
public String getValue() { return value; }
|
||||
public void setValue(String value) { this.value = value; }
|
||||
|
||||
public String getDeviceKey() { return deviceKey; }
|
||||
public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; }
|
||||
|
||||
public String getSignature() { return signature; }
|
||||
public void setSignature(String signature) { this.signature = signature; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
group = 'shine'
|
||||
version = '1.0.0'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' // json
|
||||
|
||||
implementation 'org.eclipse.jetty:jetty-server:11.0.20' // WS сервер
|
||||
implementation 'org.eclipse.jetty:jetty-servlet:11.0.20'
|
||||
implementation 'org.eclipse.jetty.websocket:websocket-jetty-server:11.0.20'
|
||||
|
||||
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
|
||||
|
||||
implementation project(':shine-server-db') // модуль для работы с БД содержит и сущности из БД и саму работу с БД
|
||||
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package shine.geo;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.eclipse.jetty.websocket.api.UpgradeRequest;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.SocketAddress;
|
||||
import java.util.List;
|
||||
|
||||
public final class ClientInfoService {
|
||||
|
||||
public static final String CLIENT_INFO_UNKNOWN = "";
|
||||
public static final String LANGUAGE_UNKNOWN = "";
|
||||
|
||||
private ClientInfoService() { }
|
||||
|
||||
/**
|
||||
* Собирает строку с информацией о клиенте:
|
||||
* User-Agent, client-hints и удалённый IP.
|
||||
*/
|
||||
public static String buildClientInfoString(Session wsSession) {
|
||||
if (wsSession == null) {
|
||||
return CLIENT_INFO_UNKNOWN;
|
||||
}
|
||||
|
||||
UpgradeRequest req = wsSession.getUpgradeRequest();
|
||||
if (req == null) {
|
||||
return CLIENT_INFO_UNKNOWN;
|
||||
}
|
||||
|
||||
String userAgent = getFirstHeader(req, "User-Agent");
|
||||
String secChUa = getFirstHeader(req, "Sec-CH-UA");
|
||||
String secChPlatform = getFirstHeader(req, "Sec-CH-UA-Platform");
|
||||
String secChMobile = getFirstHeader(req, "Sec-CH-UA-Mobile");
|
||||
|
||||
// IP берём через общий метод, чтобы не дублировать логику
|
||||
String remoteIp = extractClientIp(wsSession);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
if (userAgent != null) {
|
||||
sb.append("UA=").append(userAgent);
|
||||
}
|
||||
if (secChUa != null) {
|
||||
appendSep(sb);
|
||||
sb.append("chUa=").append(secChUa);
|
||||
}
|
||||
if (secChPlatform != null) {
|
||||
appendSep(sb);
|
||||
sb.append("platform=").append(secChPlatform);
|
||||
}
|
||||
if (secChMobile != null) {
|
||||
appendSep(sb);
|
||||
sb.append("mobile=").append(secChMobile);
|
||||
}
|
||||
if (remoteIp != null && !remoteIp.isEmpty()) {
|
||||
appendSep(sb);
|
||||
sb.append("remote=").append(remoteIp);
|
||||
}
|
||||
|
||||
String result = sb.toString().trim();
|
||||
return result.isEmpty() ? CLIENT_INFO_UNKNOWN : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пытается вытащить реальный IP клиента из WebSocket-сессии.
|
||||
*
|
||||
* Приоритет:
|
||||
* 1) X-Forwarded-For (если стоим за прокси / балансировщиком)
|
||||
* 2) X-Real-IP
|
||||
* 3) remoteAddress из WebSocket-сессии
|
||||
*/
|
||||
public static String extractClientIp(Session wsSession) {
|
||||
if (wsSession == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
UpgradeRequest req = wsSession.getUpgradeRequest();
|
||||
|
||||
// 1) X-Forwarded-For: может быть список IP через запятую
|
||||
if (req != null) {
|
||||
String xff = getFirstHeader(req, "X-Forwarded-For");
|
||||
if (xff != null && !xff.isBlank()) {
|
||||
String first = xff.split(",")[0].trim();
|
||||
if (!first.isBlank()) {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) X-Real-IP
|
||||
String xRealIp = getFirstHeader(req, "X-Real-IP");
|
||||
if (xRealIp != null && !xRealIp.isBlank()) {
|
||||
return xRealIp.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// 3) fallback: remoteAddress из WebSocket-сессии
|
||||
SocketAddress rawAddr = wsSession.getRemoteAddress();
|
||||
if (rawAddr instanceof InetSocketAddress inet) {
|
||||
if (inet.getAddress() != null) {
|
||||
return inet.getAddress().getHostAddress();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String extractPreferredLanguageTag(Session wsSession) {
|
||||
if (wsSession == null) {
|
||||
return LANGUAGE_UNKNOWN;
|
||||
}
|
||||
|
||||
UpgradeRequest req = wsSession.getUpgradeRequest();
|
||||
if (req == null) {
|
||||
return LANGUAGE_UNKNOWN;
|
||||
}
|
||||
|
||||
String acceptLanguage = getFirstHeader(req, "Accept-Language");
|
||||
if (acceptLanguage == null || acceptLanguage.isBlank()) {
|
||||
return LANGUAGE_UNKNOWN;
|
||||
}
|
||||
|
||||
String first = acceptLanguage.split(",")[0].trim();
|
||||
if (first.isEmpty()) {
|
||||
return LANGUAGE_UNKNOWN;
|
||||
}
|
||||
|
||||
String[] parts = first.split(";");
|
||||
String tag = parts[0].trim();
|
||||
|
||||
return tag.isEmpty() ? LANGUAGE_UNKNOWN : tag;
|
||||
}
|
||||
|
||||
private static String getFirstHeader(UpgradeRequest req, String headerName) {
|
||||
if (req == null || headerName == null) return null;
|
||||
List<String> values = req.getHeaders().get(headerName);
|
||||
if (values == null || values.isEmpty()) return null;
|
||||
String v = values.get(0);
|
||||
return (v == null || v.isBlank()) ? null : v.trim();
|
||||
}
|
||||
|
||||
private static void appendSep(StringBuilder sb) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append("; ");
|
||||
}
|
||||
// если строка пустая — разделитель не нужен
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package shine.geo;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import shine.db.dao.IpGeoCacheDAO;
|
||||
import shine.db.entities.IpGeoCacheEntry;
|
||||
|
||||
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.sql.SQLException;
|
||||
|
||||
/**
|
||||
* Сервис для геолокации по IP.
|
||||
*
|
||||
* Основной метод без кэша:
|
||||
* resolveCountryCityOrIp(ip) -> "Country, City" или GEO_UNKNOWN
|
||||
*
|
||||
* Метод с кэшированием в БД:
|
||||
* resolveCountryCityOrIpWithCache(ip) -> сначала смотрит в ip_geo_cache,
|
||||
* при отсутствии записи — обращается к внешнему сервису, сохраняет результат в кэш и возвращает его.
|
||||
*/
|
||||
public final class GeoLookupService {
|
||||
|
||||
private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();
|
||||
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
|
||||
|
||||
// Константа — что возвращать, если геолокация недоступна
|
||||
public static final String GEO_UNKNOWN = "unknown";
|
||||
|
||||
// Сервис геолокации (потом можно вынести в конфиг)
|
||||
private static final String GEO_API_URL = "http://ip-api.com/json/";
|
||||
|
||||
// Сервис для получения собственного внешнего IP
|
||||
private static final String PUBLIC_IP_URL = "https://api.ipify.org";
|
||||
|
||||
private GeoLookupService() {
|
||||
// utility-класс
|
||||
}
|
||||
|
||||
/**
|
||||
* ВАРИАНТ БЕЗ КЭША.
|
||||
*
|
||||
* Возвращает строку вида "Country, City" по IP.
|
||||
* Если запрос не удался, возвращает GEO_UNKNOWN.
|
||||
*/
|
||||
public static String resolveCountryCityOrIp(String ip) {
|
||||
if (ip == null || ip.isBlank()) {
|
||||
return GEO_UNKNOWN;
|
||||
}
|
||||
|
||||
// Приватные/локальные IP — геолокация невозможна
|
||||
if (isPrivateOrLocalIp(ip)) {
|
||||
return GEO_UNKNOWN;
|
||||
}
|
||||
|
||||
try {
|
||||
String url = GEO_API_URL + ip + "?fields=status,country,city,message";
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = HTTP_CLIENT.send(
|
||||
request,
|
||||
HttpResponse.BodyHandlers.ofString()
|
||||
);
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
return GEO_UNKNOWN;
|
||||
}
|
||||
|
||||
JsonNode root = JSON_MAPPER.readTree(response.body());
|
||||
String status = root.path("status").asText();
|
||||
|
||||
if (!"success".equals(status)) {
|
||||
// "fail", "private range", "quota exceeded", и т.д.
|
||||
return GEO_UNKNOWN;
|
||||
}
|
||||
|
||||
String country = root.path("country").asText(null);
|
||||
String city = root.path("city").asText(null);
|
||||
|
||||
if (country == null && city == null) {
|
||||
return GEO_UNKNOWN;
|
||||
}
|
||||
|
||||
if (country != null && city != null) {
|
||||
return country + ", " + city;
|
||||
} else if (country != null) {
|
||||
return country;
|
||||
} else {
|
||||
return city;
|
||||
}
|
||||
|
||||
} catch (IOException | InterruptedException e) {
|
||||
// Ошибки сети — возвращаем unknown
|
||||
return GEO_UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ВАРИАНТ С КЭШЕМ В БАЗЕ (ip_geo_cache).
|
||||
*
|
||||
* Логика:
|
||||
* 1) Если IP пустой или локальный — сразу GEO_UNKNOWN (и ничего не пишем в кэш).
|
||||
* 2) Пытаемся найти ip в ip_geo_cache:
|
||||
* - если нашли — возвращаем geo из записи.
|
||||
* 3) Если не нашли — вызываем resolveCountryCityOrIp(ip) (внешний сервис),
|
||||
* - результат (включая GEO_UNKNOWN) сохраняем в ip_geo_cache через IpGeoCacheDAO.upsert()
|
||||
* - возвращаем сохранённый результат.
|
||||
*
|
||||
* В случае ошибок БД — просто падаем назад на поведение без кэша.
|
||||
*/
|
||||
public static String resolveCountryCityOrIpWithCache(String ip) {
|
||||
if (ip == null || ip.isBlank()) {
|
||||
return GEO_UNKNOWN;
|
||||
}
|
||||
|
||||
// Приватные/локальные IP не кешируем и не запрашиваем
|
||||
if (isPrivateOrLocalIp(ip)) {
|
||||
return GEO_UNKNOWN;
|
||||
}
|
||||
|
||||
// 1. Сначала пробуем взять из кэша
|
||||
IpGeoCacheDAO dao = IpGeoCacheDAO.getInstance();
|
||||
try {
|
||||
IpGeoCacheEntry cached = dao.getByIp(ip);
|
||||
if (cached != null) {
|
||||
String geo = cached.getGeo();
|
||||
if (geo != null && !geo.isBlank()) {
|
||||
return geo;
|
||||
}
|
||||
// Если geo пустая строка (на всякий случай) — идём за свежими данными.
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
// Ошибка БД — логируем при желании и продолжаем без кэша
|
||||
// log.warn("Failed to read IP geo cache", e);
|
||||
}
|
||||
|
||||
// 2. Вызываем "сырой" метод, который ходит во внешний сервис
|
||||
String resolvedGeo = resolveCountryCityOrIp(ip);
|
||||
|
||||
// 3. Пишем результат в кэш (включая GEO_UNKNOWN)
|
||||
try {
|
||||
IpGeoCacheEntry entry = new IpGeoCacheEntry(
|
||||
ip,
|
||||
resolvedGeo,
|
||||
System.currentTimeMillis()
|
||||
);
|
||||
dao.upsert(entry);
|
||||
} catch (SQLException e) {
|
||||
// Ошибка БД при записи — просто игнорируем, кэш не обязателен для работы
|
||||
// log.warn("Failed to upsert IP geo cache", e);
|
||||
}
|
||||
|
||||
return resolvedGeo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пытается получить внешний IP текущей машины через HTTP-сервис.
|
||||
* В случае ошибки возвращает fallbackIp.
|
||||
*/
|
||||
public static String fetchPublicIpOrDefault(String fallbackIp) {
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(PUBLIC_IP_URL))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = HTTP_CLIENT.send(
|
||||
request,
|
||||
HttpResponse.BodyHandlers.ofString()
|
||||
);
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
return fallbackIp;
|
||||
}
|
||||
|
||||
String body = response.body();
|
||||
if (body == null || body.isBlank()) {
|
||||
return fallbackIp;
|
||||
}
|
||||
|
||||
return body.trim();
|
||||
|
||||
} catch (IOException | InterruptedException e) {
|
||||
return fallbackIp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка на частные/локальные IP.
|
||||
*/
|
||||
private static boolean isPrivateOrLocalIp(String ip) {
|
||||
ip = ip.trim();
|
||||
|
||||
return ip.startsWith("10.")
|
||||
|| ip.startsWith("192.168.")
|
||||
|| ip.startsWith("127.")
|
||||
|| ip.startsWith("0.")
|
||||
|| ip.startsWith("169.254.")
|
||||
// Диапазон 172.16.0.0 – 172.31.255.255
|
||||
|| ip.matches("^172\\.(1[6-9]|2[0-9]|3[0-1])\\..*");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package shine.geo;
|
||||
|
||||
/**
|
||||
* Тестовый запуск геолокации.
|
||||
*.
|
||||
* Логика:
|
||||
* 1) Если в args[0] передан IP — используем его.
|
||||
* 2) Иначе пробуем узнать внешний IP текущей машины.
|
||||
* 3) Если не удалось — берём константу TEST_IP.
|
||||
* 4) Вызываем GeoLookupService.resolveCountryCityOrIp(...) и печатаем результат.
|
||||
*/
|
||||
public class GeoLookupTestMain {
|
||||
|
||||
// Константа на случай, если не удалось узнать внешний IP.
|
||||
private static final String TEST_IP = "8.8.8.8";
|
||||
|
||||
public static void main(String[] args) {
|
||||
String ip;
|
||||
|
||||
if (args.length > 0 && args[0] != null && !args[0].isBlank()) {
|
||||
ip = args[0].trim();
|
||||
System.out.println("Используем IP из аргумента: " + ip);
|
||||
} else {
|
||||
// Пытаемся узнать внешний IP
|
||||
String detectedIp = GeoLookupService.fetchPublicIpOrDefault(TEST_IP);
|
||||
if (TEST_IP.equals(detectedIp)) {
|
||||
System.out.println("Не удалось определить внешний IP, используем тестовый: " + TEST_IP);
|
||||
} else {
|
||||
System.out.println("Определён внешний IP: " + detectedIp);
|
||||
}
|
||||
ip = detectedIp;
|
||||
}
|
||||
|
||||
// Замер времени
|
||||
long start = System.currentTimeMillis();
|
||||
String result = GeoLookupService.resolveCountryCityOrIp(ip);
|
||||
long end = System.currentTimeMillis();
|
||||
long duration = end - start;
|
||||
|
||||
System.out.println("Результат геолокации: " + result);
|
||||
System.out.println("Время ответа геолокации: " + duration + " мс");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
group = 'shine'
|
||||
version = '1.0.0'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
|
||||
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTFILE="all_files.txt"
|
||||
|
||||
# очищаем или создаём файл
|
||||
: > "$OUTFILE"
|
||||
|
||||
# собрать только *.java файлы и вывести их содержимое в файл
|
||||
find . -type f -name "*.java" | sort | while read -r f; do
|
||||
cat "$f" >> "$OUTFILE"
|
||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
||||
done
|
||||
|
||||
# скопировать весь файл в буфер обмена (Wayland)
|
||||
wl-copy < "$OUTFILE"
|
||||
|
||||
echo "Готово!"
|
||||
echo "Все .java файлы собраны в $OUTFILE"
|
||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
||||
@@ -0,0 +1,60 @@
|
||||
package shine.log;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* ===============================================================
|
||||
* BlockchainAdminNotifier — уведомления администратору о критических
|
||||
* ошибках консистентности блокчейн-файлов.
|
||||
*
|
||||
* Сейчас:
|
||||
* - пишет МАКСИМАЛЬНО ЗАМЕТНЫЙ лог
|
||||
*
|
||||
* TODO:
|
||||
* - реальная отправка уведомления администратору:
|
||||
* * Telegram bot / email / SMS / webhook / Sentry / PagerDuty
|
||||
* * желательно с hostname, временем, именем блокчейна, размерами и stacktrace
|
||||
* ===============================================================
|
||||
*/
|
||||
public final class BlockchainAdminNotifier {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BlockchainAdminNotifier.class);
|
||||
|
||||
private BlockchainAdminNotifier() {}
|
||||
|
||||
public static void critical(String message) {
|
||||
critical(message, null);
|
||||
}
|
||||
|
||||
public static void critical(String message, Throwable t) {
|
||||
|
||||
String bannerTop =
|
||||
"\n" +
|
||||
"=================================================================\n" +
|
||||
"==================== !!! КРИТИЧЕСКАЯ ОШИБКА !!! ===============\n" +
|
||||
"=================================================================";
|
||||
|
||||
String bannerBottom =
|
||||
"=================================================================\n" +
|
||||
"==================== !!! НУЖНО ВМЕШАТЕЛЬСТВО !!! ===============\n" +
|
||||
"=================================================================\n";
|
||||
|
||||
if (t == null) {
|
||||
log.error("{}\n{}\n{}",
|
||||
bannerTop,
|
||||
message,
|
||||
bannerBottom
|
||||
);
|
||||
} else {
|
||||
log.error("{}\n{}\n{}",
|
||||
bannerTop,
|
||||
message,
|
||||
bannerBottom,
|
||||
t
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Реальная отправка уведомления администратору (Telegram/email/webhook/Sentry)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
group = 'shine'
|
||||
version = '1.0.0'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.eclipse.jetty:jetty-server:11.0.20' // WS сервер
|
||||
implementation 'org.eclipse.jetty:jetty-servlet:11.0.20'
|
||||
implementation 'org.eclipse.jetty.websocket:websocket-jetty-server:11.0.20'
|
||||
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' // json
|
||||
|
||||
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
|
||||
implementation 'nl.martijndwars:web-push:5.1.1'
|
||||
|
||||
implementation project(':shine-server-config') // модуль с настройками
|
||||
implementation project(":shine-server-log") // модуль логирования и уведомления админов
|
||||
|
||||
implementation project(':shine-server-crypto') // модуль сервера для работы с криптографией
|
||||
implementation project(':shine-server-blockchain') // модуль для работы с блокчейном
|
||||
implementation project(':shine-server-db') // модуль для работы с БД содержит и сущности из БД и саму работу с БД
|
||||
implementation project(':shine-server-geo') // модуль для определения геолокации по IP
|
||||
|
||||
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTFILE="all_files.txt"
|
||||
|
||||
# очищаем или создаём файл
|
||||
: > "$OUTFILE"
|
||||
|
||||
# собрать только *.java файлы и вывести их содержимое в файл
|
||||
find . -type f -name "*.java" | sort | while read -r f; do
|
||||
cat "$f" >> "$OUTFILE"
|
||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
||||
done
|
||||
|
||||
# скопировать весь файл в буфер обмена (Wayland)
|
||||
wl-copy < "$OUTFILE"
|
||||
|
||||
echo "Готово!"
|
||||
echo "Все .java файлы собраны в $OUTFILE"
|
||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
||||
File diff suppressed because it is too large
Load Diff
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTFILE="all_files.txt"
|
||||
|
||||
# очищаем или создаём файл
|
||||
: > "$OUTFILE"
|
||||
|
||||
# собрать только *.java файлы и вывести их содержимое в файл
|
||||
find . -type f -name "*.java" | sort | while read -r f; do
|
||||
cat "$f" >> "$OUTFILE"
|
||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
||||
done
|
||||
|
||||
# скопировать весь файл в буфер обмена (Wayland)
|
||||
wl-copy < "$OUTFILE"
|
||||
|
||||
echo "Готово!"
|
||||
echo "Все .java файлы собраны в $OUTFILE"
|
||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package server.logic.ws_protocol;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Единая утилита Base64 для всего WS-протокола.
|
||||
*
|
||||
* ВАЖНО:
|
||||
* - Используем ТОЛЬКО стандартный Base64 (RFC 4648) алфавит: '+' и '/'.
|
||||
* - Без padding '=' (чтобы строки были короче и стабильнее для JSON).
|
||||
* - Декодер при этом спокойно принимает и с '=' и без '='.
|
||||
*/
|
||||
public final class Base64Ws {
|
||||
|
||||
private static final Base64.Encoder ENC = Base64.getEncoder().withoutPadding();
|
||||
private static final Base64.Decoder DEC = Base64.getDecoder();
|
||||
|
||||
private Base64Ws() {}
|
||||
|
||||
public static String encode(byte[] bytes) {
|
||||
if (bytes == null) throw new IllegalArgumentException("bytes == null");
|
||||
return ENC.encodeToString(bytes);
|
||||
}
|
||||
|
||||
public static byte[] decode(String b64) throws IllegalArgumentException {
|
||||
if (b64 == null) throw new IllegalArgumentException("base64 is null");
|
||||
String s = b64.trim();
|
||||
if (s.isEmpty()) throw new IllegalArgumentException("base64 is empty");
|
||||
return DEC.decode(s);
|
||||
}
|
||||
|
||||
public static byte[] decodeLen(String b64, int expectedLen, String fieldName) throws IllegalArgumentException {
|
||||
byte[] v = decode(b64);
|
||||
if (v.length != expectedLen) {
|
||||
String f = (fieldName == null || fieldName.isBlank()) ? "value" : fieldName;
|
||||
throw new IllegalArgumentException(f + " must be " + expectedLen + " bytes, got " + v.length);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package server.logic.ws_protocol.JSON;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
|
||||
/**
|
||||
* Реестр активных подключений (только авторизованные).
|
||||
*/
|
||||
public final class ActiveConnectionsRegistry {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ActiveConnectionsRegistry.class);
|
||||
|
||||
private static final ActiveConnectionsRegistry INSTANCE = new ActiveConnectionsRegistry();
|
||||
|
||||
public static ActiveConnectionsRegistry getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private ActiveConnectionsRegistry() {
|
||||
// singleton
|
||||
}
|
||||
|
||||
// sessionId (String) -> ConnectionContext
|
||||
private final ConcurrentHashMap<String, ConnectionContext> bySessionId = new ConcurrentHashMap<>();
|
||||
|
||||
// lowercase(login) -> множество ConnectionContext для этого пользователя
|
||||
private final ConcurrentHashMap<String, Set<ConnectionContext>> byLogin = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Зарегистрировать авторизованное подключение.
|
||||
* Ожидается, что в ctx уже выставлены login и sessionId.
|
||||
*/
|
||||
public void register(ConnectionContext ctx) {
|
||||
if (ctx == null) return;
|
||||
|
||||
String sessionId = ctx.getSessionId();
|
||||
String login = ctx.getLogin();
|
||||
|
||||
if (sessionId == null || sessionId.isBlank() || login == null || login.isBlank()) {
|
||||
log.debug("register skipped: bad ctx fields (login='{}', sessionId='{}')", login, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Если кто-то перерегистрировал тот же sessionId — вычищаем старый ctx из byLogin
|
||||
ConnectionContext prev = bySessionId.put(sessionId, ctx);
|
||||
if (prev != null && prev != ctx) {
|
||||
String prevLogin = prev.getLogin();
|
||||
if (prevLogin != null && !prevLogin.isBlank()) {
|
||||
String prevKey = toLoginKey(prevLogin);
|
||||
Set<ConnectionContext> prevSet = byLogin.get(prevKey);
|
||||
if (prevSet != null) {
|
||||
prevSet.remove(prev);
|
||||
if (prevSet.isEmpty()) {
|
||||
byLogin.remove(prevKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.warn("sessionId reused: replaced previous ctx (sessionId={}, prevLogin={}, newLogin={})",
|
||||
sessionId, prevLogin, login);
|
||||
}
|
||||
|
||||
byLogin
|
||||
.computeIfAbsent(toLoginKey(login), id -> new CopyOnWriteArraySet<>())
|
||||
.add(ctx);
|
||||
|
||||
log.debug("registered ctx (login={}, sessionId={})", login, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить подключение по контексту (например, при onClose).
|
||||
*/
|
||||
public void remove(ConnectionContext ctx) {
|
||||
if (ctx == null) return;
|
||||
|
||||
String sessionId = ctx.getSessionId();
|
||||
String login = ctx.getLogin();
|
||||
|
||||
if (sessionId != null && !sessionId.isBlank()) {
|
||||
// Удаляем только если под ключом всё ещё лежит именно этот ctx.
|
||||
// Иначе это старое соединение после re-register, и удалять новый ctx нельзя.
|
||||
boolean removedCurrent = bySessionId.remove(sessionId, ctx);
|
||||
|
||||
// Если в мапе уже другой ctx под тем же sessionId — не трогаем byLogin.
|
||||
if (!removedCurrent) {
|
||||
log.debug("remove(ctx): sessionId mapped to another ctx, skip byLogin cleanup (sessionId={})", sessionId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (login != null && !login.isBlank()) {
|
||||
String key = toLoginKey(login);
|
||||
Set<ConnectionContext> set = byLogin.get(key);
|
||||
if (set != null) {
|
||||
set.remove(ctx);
|
||||
if (set.isEmpty()) {
|
||||
byLogin.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("removed ctx (login={}, sessionId={})", login, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить подключение по sessionId.
|
||||
*/
|
||||
public void removeBySessionId(String sessionId) {
|
||||
if (sessionId == null || sessionId.isBlank()) return;
|
||||
|
||||
ConnectionContext ctx = bySessionId.remove(sessionId);
|
||||
if (ctx == null) return;
|
||||
|
||||
String login = ctx.getLogin();
|
||||
if (login != null && !login.isBlank()) {
|
||||
String key = toLoginKey(login);
|
||||
Set<ConnectionContext> set = byLogin.get(key);
|
||||
if (set != null) {
|
||||
set.remove(ctx);
|
||||
if (set.isEmpty()) {
|
||||
byLogin.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("removed by sessionId (login={}, sessionId={})", login, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить контекст по sessionId.
|
||||
*/
|
||||
public ConnectionContext getBySessionId(String sessionId) {
|
||||
if (sessionId == null || sessionId.isBlank()) return null;
|
||||
return bySessionId.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить все активные подключения пользователя по login.
|
||||
*/
|
||||
public Set<ConnectionContext> getByLogin(String login) {
|
||||
if (login == null || login.isBlank()) return Set.of();
|
||||
Set<ConnectionContext> set = byLogin.get(toLoginKey(login));
|
||||
return (set == null) ? Set.of() : set; // CopyOnWriteArraySet можно отдавать как есть
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Снимок всех активных контекстов на текущий момент.
|
||||
*/
|
||||
public Set<ConnectionContext> getAllConnectionsSnapshot() {
|
||||
return Set.copyOf(bySessionId.values());
|
||||
}
|
||||
|
||||
private static String toLoginKey(String login) {
|
||||
return login.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package server.logic.ws_protocol.JSON;
|
||||
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import shine.db.entities.SolanaUserEntry;
|
||||
import shine.db.entities.ActiveSessionEntry;
|
||||
|
||||
/**
|
||||
* ConnectionContext — контекст состояния одного WebSocket-соединения.
|
||||
* Живёт ровно столько же, сколько живёт подключение.
|
||||
*
|
||||
* Важно (v2):
|
||||
* - Авторизация всегда 2 шага:
|
||||
* A) Создание новой сессии через deviceKey:
|
||||
* AuthChallenge(login) -> ctx.authNonce
|
||||
* CreateAuthSession(...) -> ctx.AUTH_STATUS_USER + ctx.activeSession
|
||||
*
|
||||
* B) Вход в существующую сессию через sessionKey:
|
||||
* SessionChallenge(sessionId) -> ctx.sessionLoginNonce + ctx.sessionLoginSessionId + expiresAt
|
||||
* SessionLogin(...) -> проверка подписи sessionKey по pubkey из БД -> ctx.AUTH_STATUS_USER
|
||||
*/
|
||||
public class ConnectionContext {
|
||||
|
||||
// Статусы аутентификации
|
||||
public static final int AUTH_STATUS_NONE = 0; // анонимный / не авторизован
|
||||
public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge)
|
||||
public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь
|
||||
|
||||
// Полный пользователь из БД (solana_users)
|
||||
private SolanaUserEntry solanaUserEntry;
|
||||
|
||||
// Активная сессия из БД (active_sessions)
|
||||
private ActiveSessionEntry activeSessionEntry;
|
||||
|
||||
/**
|
||||
* Идентификатор сессии — base64-строка от 32 байт.
|
||||
* Заполняется после успешного входа (AUTH_STATUS_USER).
|
||||
*/
|
||||
private String sessionId;
|
||||
|
||||
/**
|
||||
* Одноразовый nonce, выданный на шаге 1 (AuthChallenge),
|
||||
* используется на шаге CreateAuthSession для проверки подписи deviceKey.
|
||||
*/
|
||||
private String authNonce;
|
||||
|
||||
/* ===================== SessionLogin challenge (v2) ===================== */
|
||||
|
||||
/**
|
||||
* Одноразовый nonce, выданный на шаге SessionChallenge(sessionId),
|
||||
* используется на шаге SessionLogin для проверки подписи sessionKey.
|
||||
*/
|
||||
private String sessionLoginNonce;
|
||||
|
||||
/**
|
||||
* sessionId, для которого был выдан sessionLoginNonce.
|
||||
* Нужен, чтобы SessionLogin не мог "подставить" другой sessionId.
|
||||
*/
|
||||
private String sessionLoginSessionId;
|
||||
|
||||
/**
|
||||
* Время истечения sessionLoginNonce (мс с 1970-01-01).
|
||||
* Если текущее время > expiresAt, то nonce считается недействительным.
|
||||
*/
|
||||
private long sessionLoginNonceExpiresAtMs;
|
||||
|
||||
/* ====================================================================== */
|
||||
|
||||
/**
|
||||
* Текущий статус аутентификации.
|
||||
* См. константы AUTH_STATUS_*
|
||||
*/
|
||||
private int authenticationStatus = AUTH_STATUS_NONE;
|
||||
|
||||
/**
|
||||
* WebSocket-сессия Jetty для данного подключения.
|
||||
* Нужна, чтобы через ConnectionContext можно было отправлять сообщения клиенту.
|
||||
*/
|
||||
private Session wsSession;
|
||||
|
||||
// --- WebSocket Session ---
|
||||
|
||||
public Session getWsSession() {
|
||||
return wsSession;
|
||||
}
|
||||
|
||||
public void setWsSession(Session wsSession) {
|
||||
this.wsSession = wsSession;
|
||||
}
|
||||
|
||||
// --- SolanaUser / ActiveSession ---
|
||||
|
||||
public SolanaUserEntry getSolanaUser() {
|
||||
return solanaUserEntry;
|
||||
}
|
||||
|
||||
public void setSolanaUser(SolanaUserEntry solanaUserEntry) {
|
||||
this.solanaUserEntry = solanaUserEntry;
|
||||
}
|
||||
|
||||
public ActiveSessionEntry getActiveSession() {
|
||||
return activeSessionEntry;
|
||||
}
|
||||
|
||||
public void setActiveSession(ActiveSessionEntry activeSessionEntry) {
|
||||
this.activeSessionEntry = activeSessionEntry;
|
||||
}
|
||||
|
||||
// --- Удобный геттер для логина ---
|
||||
|
||||
public String getLogin() {
|
||||
return solanaUserEntry != null ? solanaUserEntry.getLogin() : null;
|
||||
}
|
||||
|
||||
// --- sessionId ---
|
||||
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public void setSessionId(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
// --- authNonce ---
|
||||
|
||||
public String getAuthNonce() {
|
||||
return authNonce;
|
||||
}
|
||||
|
||||
public void setAuthNonce(String authNonce) {
|
||||
this.authNonce = authNonce;
|
||||
}
|
||||
|
||||
// --- sessionLoginNonce (v2) ---
|
||||
|
||||
public String getSessionLoginNonce() {
|
||||
return sessionLoginNonce;
|
||||
}
|
||||
|
||||
public void setSessionLoginNonce(String sessionLoginNonce) {
|
||||
this.sessionLoginNonce = sessionLoginNonce;
|
||||
}
|
||||
|
||||
public String getSessionLoginSessionId() {
|
||||
return sessionLoginSessionId;
|
||||
}
|
||||
|
||||
public void setSessionLoginSessionId(String sessionLoginSessionId) {
|
||||
this.sessionLoginSessionId = sessionLoginSessionId;
|
||||
}
|
||||
|
||||
public long getSessionLoginNonceExpiresAtMs() {
|
||||
return sessionLoginNonceExpiresAtMs;
|
||||
}
|
||||
|
||||
public void setSessionLoginNonceExpiresAtMs(long sessionLoginNonceExpiresAtMs) {
|
||||
this.sessionLoginNonceExpiresAtMs = sessionLoginNonceExpiresAtMs;
|
||||
}
|
||||
|
||||
// --- auth status ---
|
||||
|
||||
public int getAuthenticationStatus() {
|
||||
return authenticationStatus;
|
||||
}
|
||||
|
||||
public void setAuthenticationStatus(int authenticationStatus) {
|
||||
this.authenticationStatus = authenticationStatus;
|
||||
}
|
||||
|
||||
public boolean isAuthenticatedUser() {
|
||||
return authenticationStatus == AUTH_STATUS_USER;
|
||||
}
|
||||
|
||||
public boolean isAnonymous() {
|
||||
return authenticationStatus == AUTH_STATUS_NONE;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
solanaUserEntry = null;
|
||||
activeSessionEntry = null;
|
||||
|
||||
sessionId = null;
|
||||
authNonce = null;
|
||||
|
||||
sessionLoginNonce = null;
|
||||
sessionLoginSessionId = null;
|
||||
sessionLoginNonceExpiresAtMs = 0;
|
||||
|
||||
authenticationStatus = AUTH_STATUS_NONE;
|
||||
wsSession = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ConnectionContext{" +
|
||||
"login='" + getLogin() + '\'' +
|
||||
", sessionId=" + sessionId +
|
||||
", authenticationStatus=" + authenticationStatus +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
package server.logic.ws_protocol.JSON;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler;
|
||||
|
||||
// --- NEW v2 session login ---
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionChallenge_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionLogin_Handler;
|
||||
|
||||
// --- auth entities ---
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request;
|
||||
|
||||
// --- NEW v2 entities ---
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
||||
|
||||
// --- NEW: SearchUsers ---
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUsers_Request;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.Net_GetUserParam_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.Net_ListUserParams_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request;
|
||||
|
||||
// --- NEW: connections friends lists ---
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Request;
|
||||
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_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.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_ListGroupChats200_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
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.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.messages.Net_AckSessionDelivery_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request;
|
||||
|
||||
// --- NEW: Ping ---
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_GetCallIceConfig_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_ClientErrorLog_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_CallDeliveryReport_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetCallIceConfig_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* JsonHandlerRegistry — единое место, где руками регистрируются
|
||||
* JSON-операции: op → handler и op → requestClass.
|
||||
*/
|
||||
public final class JsonHandlerRegistry {
|
||||
|
||||
static {
|
||||
ChannelNamesStateBootstrapper.bootstrapOrFailFast();
|
||||
}
|
||||
|
||||
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
|
||||
Map.entry("AddUser", new Net_AddUser_Handler()),
|
||||
Map.entry("GetUser", new Net_GetUser_Handler()),
|
||||
Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
|
||||
|
||||
// --- auth ---
|
||||
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
|
||||
Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()),
|
||||
Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()),
|
||||
Map.entry("ListSessions", new Net_ListSessions_Handler()),
|
||||
|
||||
// --- login to existing session in 2 steps ---
|
||||
Map.entry("SessionChallenge", new Net_SessionChallenge_Handler()),
|
||||
Map.entry("SessionLogin", new Net_SessionLogin_Handler()),
|
||||
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||
|
||||
// --- userParams ---
|
||||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||||
Map.entry("GetUserParam", new Net_GetUserParam_Handler()),
|
||||
Map.entry("ListUserParams", new Net_ListUserParams_Handler()),
|
||||
|
||||
// --- connections ---
|
||||
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
|
||||
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
||||
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
|
||||
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
|
||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||
Map.entry("SendTestWebPush", new Net_SendTestWebPush_Handler()),
|
||||
Map.entry("SendDirectMessage", new Net_SendDirectMessage_Handler()),
|
||||
Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
|
||||
Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()),
|
||||
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
|
||||
|
||||
// --- system ---
|
||||
Map.entry("Ping", new Net_Ping_Handler()),
|
||||
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
|
||||
Map.entry("GetCallIceConfig", new Net_GetCallIceConfig_Handler()),
|
||||
Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler()),
|
||||
Map.entry("ClientDebugLog", new Net_ClientDebugLog_Handler()),
|
||||
Map.entry("CallDeliveryReport", new Net_CallDeliveryReport_Handler())
|
||||
|
||||
// --- subscriptions ---
|
||||
// Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler())
|
||||
);
|
||||
|
||||
private static final Map<String, Class<? extends Net_Request>> REQUEST_TYPES = Map.ofEntries(
|
||||
Map.entry("AddUser", Net_AddUser_Request.class),
|
||||
Map.entry("GetUser", Net_GetUser_Request.class),
|
||||
Map.entry("SearchUsers", Net_SearchUsers_Request.class),
|
||||
|
||||
// --- auth ---
|
||||
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
|
||||
Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class),
|
||||
Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class),
|
||||
Map.entry("ListSessions", Net_ListSessions_Request.class),
|
||||
|
||||
// --- NEW v2 ---
|
||||
Map.entry("SessionChallenge", Net_SessionChallenge_Request.class),
|
||||
Map.entry("SessionLogin", Net_SessionLogin_Request.class),
|
||||
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
||||
|
||||
// --- userParams ---
|
||||
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
||||
Map.entry("GetUserParam", Net_GetUserParam_Request.class),
|
||||
Map.entry("ListUserParams", Net_ListUserParams_Request.class),
|
||||
|
||||
// --- connections ---
|
||||
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
|
||||
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||
Map.entry("GetMessageThread", Net_GetMessageThread_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("ListContacts", Net_ListContacts_Request.class),
|
||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||
|
||||
// --- direct messages / push ---
|
||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||
Map.entry("SendTestWebPush", Net_SendTestWebPush_Request.class),
|
||||
Map.entry("SendDirectMessage", Net_SendDirectMessage_Request.class),
|
||||
Map.entry("SendMessagePair", Net_SendMessagePair_Request.class),
|
||||
Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class),
|
||||
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
||||
|
||||
// --- system ---
|
||||
Map.entry("Ping", Net_Ping_Request.class),
|
||||
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
|
||||
Map.entry("GetCallIceConfig", Net_GetCallIceConfig_Request.class),
|
||||
Map.entry("ClientErrorLog", Net_ClientErrorLog_Request.class),
|
||||
Map.entry("ClientDebugLog", Net_ClientDebugLog_Request.class),
|
||||
Map.entry("CallDeliveryReport", Net_CallDeliveryReport_Request.class)
|
||||
);
|
||||
|
||||
private JsonHandlerRegistry() { }
|
||||
|
||||
public static Map<String, JsonMessageHandler> getHandlers() {
|
||||
return HANDLERS;
|
||||
}
|
||||
|
||||
public static Map<String, Class<? extends Net_Request>> getRequestTypes() {
|
||||
return REQUEST_TYPES;
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
package server.logic.ws_protocol.JSON;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response;
|
||||
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.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* JsonInboundProcessor — обработка JSON-сообщений.
|
||||
*
|
||||
* 1) Парсит общий пакет (op, requestId, payload).
|
||||
* 2) По op выбирает класс запроса и хэндлер.
|
||||
* 3) Собирает "плоский" объект: op + requestId + поля из payload.
|
||||
* 4) Маппит его в NetRequest через ObjectMapper.
|
||||
* 5) Вызывает хэндлер, получает NetResponse.
|
||||
* 6) Собирает JSON-ответ:
|
||||
* {
|
||||
* "op": ...,
|
||||
* "requestId": ...,
|
||||
* "status": ...,
|
||||
* "payload": { все поля response, кроме op/requestId/status/payload }
|
||||
* }
|
||||
*/
|
||||
public final class JsonInboundProcessor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JsonInboundProcessor.class);
|
||||
|
||||
private static final ObjectMapper JSON_MAPPER = new ObjectMapper()
|
||||
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
|
||||
|
||||
private static final Map<String, JsonMessageHandler> JSON_HANDLERS =
|
||||
JsonHandlerRegistry.getHandlers();
|
||||
|
||||
private static final Map<String, Class<? extends Net_Request>> JSON_REQUEST_TYPES =
|
||||
JsonHandlerRegistry.getRequestTypes();
|
||||
|
||||
private JsonInboundProcessor() {
|
||||
// utility
|
||||
}
|
||||
|
||||
public static String processJson(String json, ConnectionContext ctx) {
|
||||
String op = null;
|
||||
String requestId = null;
|
||||
|
||||
// Для лога полезно знать, кто прислал (хотя бы login/sessionId, если есть)
|
||||
String ctxLogin = safe(ctx != null ? ctx.getLogin() : null);
|
||||
String ctxSessionId = safe(ctx != null ? ctx.getSessionId() : null);
|
||||
|
||||
try {
|
||||
if (json == null || json.isBlank()) {
|
||||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||||
null,
|
||||
null,
|
||||
WireCodes.Status.BAD_REQUEST,
|
||||
"EMPTY_JSON",
|
||||
"Пустое JSON-сообщение"
|
||||
);
|
||||
|
||||
String out = writeResponse(err);
|
||||
|
||||
// DEBUG: что пришло / что ушло
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("JSON IN (login={}, sessionId={}): <empty>", ctxLogin, ctxSessionId);
|
||||
log.debug("JSON OUT (login={}, sessionId={}): {}", ctxLogin, ctxSessionId, shorten(out, 1200));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// DEBUG: сырой вход (обрезаем, чтобы не убить лог)
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("JSON IN (login={}, sessionId={}): {}", ctxLogin, ctxSessionId, shorten(json, 1200));
|
||||
}
|
||||
|
||||
// 1) Парсим общий пакет
|
||||
JsonNode root = JSON_MAPPER.readTree(json);
|
||||
|
||||
// 2) op и requestId из корня
|
||||
op = getTextOrNull(root, "op");
|
||||
requestId = getTextOrNull(root, "requestId");
|
||||
|
||||
if (op == null || op.isEmpty()) {
|
||||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||||
null,
|
||||
requestId,
|
||||
WireCodes.Status.BAD_REQUEST,
|
||||
"NO_OP",
|
||||
"Поле 'op' отсутствует или пустое"
|
||||
);
|
||||
|
||||
String out = writeResponse(err);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
JsonMessageHandler handler = JSON_HANDLERS.get(op);
|
||||
Class<? extends Net_Request> reqClass = JSON_REQUEST_TYPES.get(op);
|
||||
|
||||
if (handler == null || reqClass == null) {
|
||||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||||
op,
|
||||
requestId,
|
||||
WireCodes.Status.BAD_REQUEST,
|
||||
"UNKNOWN_OP",
|
||||
"Неизвестная операция: " + op
|
||||
);
|
||||
|
||||
String out = writeResponse(err);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 3) Берём payload
|
||||
JsonNode payloadNode = root.get("payload");
|
||||
if (payloadNode == null || payloadNode.isNull()) {
|
||||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||||
op,
|
||||
requestId,
|
||||
WireCodes.Status.BAD_REQUEST,
|
||||
"NO_PAYLOAD",
|
||||
"Поле 'payload' отсутствует"
|
||||
);
|
||||
|
||||
String out = writeResponse(err);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (!payloadNode.isObject()) {
|
||||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||||
op,
|
||||
requestId,
|
||||
WireCodes.Status.BAD_REQUEST,
|
||||
"BAD_PAYLOAD",
|
||||
"Поле 'payload' должно быть объектом"
|
||||
);
|
||||
|
||||
String out = writeResponse(err);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 3.1 Собираем "плоский" объект для маппинга в NetRequest:
|
||||
// op + requestId + поля из payload
|
||||
ObjectNode merged = JSON_MAPPER.createObjectNode();
|
||||
|
||||
// Добавляем op и requestId, чтобы они попали в NetRequest
|
||||
merged.put("op", op);
|
||||
if (requestId != null) merged.put("requestId", requestId);
|
||||
|
||||
// Добавляем все поля из payload внутрь
|
||||
merged.setAll((ObjectNode) payloadNode);
|
||||
|
||||
// 4) Маппим в конкретный класс NetRequest
|
||||
Net_Request request;
|
||||
try {
|
||||
request = JSON_MAPPER.treeToValue(merged, reqClass);
|
||||
} catch (Exception mapErr) {
|
||||
// Важно: вот это часто “теряется”, если не логировать отдельно
|
||||
log.error("❌ JSON map error (op={}, requestId={}, login={}, sessionId={}): merged={}",
|
||||
op, safe(requestId), ctxLogin, ctxSessionId, shorten(merged.toString(), 1200), mapErr);
|
||||
|
||||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||||
op,
|
||||
requestId,
|
||||
WireCodes.Status.BAD_REQUEST,
|
||||
"BAD_REQUEST_FORMAT",
|
||||
NetExceptionResponseFactory.detailedMessage(
|
||||
"Некорректный формат запроса: не удалось распарсить поля payload",
|
||||
mapErr
|
||||
)
|
||||
);
|
||||
|
||||
String out = writeResponse(err);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// DEBUG: нормализованный запрос (уже распарсен)
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("REQ OBJ (login={}, sessionId={}, op={}, requestId={}): {}",
|
||||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(safeToString(request), 1200));
|
||||
}
|
||||
|
||||
// 5) Вызываем хэндлер
|
||||
Net_Response response;
|
||||
try {
|
||||
response = handler.handle(request, ctx);
|
||||
} catch (Exception handlerError) {
|
||||
// ✅ Вот тут как раз и должны “появляться ошибки в логере”
|
||||
log.error("💥 Handler error (op={}, requestId={}, login={}, sessionId={})",
|
||||
op, safe(requestId), ctxLogin, ctxSessionId, handlerError);
|
||||
|
||||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||||
op,
|
||||
requestId,
|
||||
WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_HANDLER_ERROR",
|
||||
NetExceptionResponseFactory.detailedMessage(
|
||||
"Неожиданная ошибка при обработке операции: " + op,
|
||||
handlerError
|
||||
)
|
||||
);
|
||||
|
||||
String out = writeResponse(err);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// На всякий случай: если хэндлер не выставил op/requestId
|
||||
if (response.getOp() == null) response.setOp(op);
|
||||
if (response.getRequestId() == null) response.setRequestId(requestId);
|
||||
|
||||
// 6) Универсальная сборка ответа
|
||||
String out = writeResponse(response);
|
||||
|
||||
// DEBUG: ответ ушёл
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("RESP OBJ (login={}, sessionId={}, op={}, requestId={}, status={}): {}",
|
||||
ctxLogin, ctxSessionId, safe(op), safe(requestId), response.getStatus(), shorten(safeToString(response), 1200));
|
||||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}, status={}): {}",
|
||||
ctxLogin, ctxSessionId, safe(op), safe(requestId), response.getStatus(), shorten(out, 1200));
|
||||
}
|
||||
|
||||
return out;
|
||||
|
||||
} catch (Exception e) {
|
||||
// ✅ Любая неожиданная ошибка парсинга/обработки — в лог
|
||||
log.error("❌ JSON processing error (op={}, requestId={}, login={}, sessionId={})",
|
||||
safe(op), safe(requestId), safe(ctxLogin), safe(ctxSessionId), e);
|
||||
|
||||
Net_Exception_Response err = NetExceptionResponseFactory.error(
|
||||
op != null ? op : "Unknown",
|
||||
requestId,
|
||||
WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера", e)
|
||||
);
|
||||
|
||||
String out = writeResponse(err);
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}",
|
||||
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static String getTextOrNull(JsonNode node, String field) {
|
||||
if (node == null || !node.has(field) || node.get(field).isNull()) return null;
|
||||
return node.get(field).asText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Унифицированная сериализация любого NetResponse в формат:
|
||||
* {
|
||||
* "op": ...,
|
||||
* "requestId": ...,
|
||||
* "status": ...,
|
||||
* "ok": true|false,
|
||||
* "payload": { ... }
|
||||
* }
|
||||
*/
|
||||
private static String writeResponse(Net_Response response) {
|
||||
try {
|
||||
// Конвертируем полный объект ответа в ObjectNode
|
||||
ObjectNode full = JSON_MAPPER.convertValue(response, ObjectNode.class);
|
||||
|
||||
// То, что должно остаться наверху:
|
||||
String op = full.hasNonNull("op") ? full.get("op").asText() : null;
|
||||
String requestId = full.hasNonNull("requestId") ? full.get("requestId").asText() : null;
|
||||
int status = full.hasNonNull("status") ? full.get("status").asInt() : 0;
|
||||
boolean ok = status >= 200 && status < 300;
|
||||
|
||||
String error = null;
|
||||
if (!ok) {
|
||||
if (full.hasNonNull("error")) error = full.get("error").asText();
|
||||
else if (full.hasNonNull("code")) error = full.get("code").asText();
|
||||
}
|
||||
|
||||
String message = null;
|
||||
if (!ok && full.hasNonNull("message")) {
|
||||
message = full.get("message").asText();
|
||||
}
|
||||
|
||||
// Удаляем базовые поля и payload из "полного" объекта,
|
||||
// всё остальное отправляем внутрь payload.
|
||||
full.remove("op");
|
||||
full.remove("requestId");
|
||||
full.remove("status");
|
||||
full.remove("ok");
|
||||
full.remove("error");
|
||||
full.remove("code");
|
||||
if (!ok) full.remove("message");
|
||||
full.remove("payload");
|
||||
|
||||
ObjectNode root = JSON_MAPPER.createObjectNode();
|
||||
if (op != null) root.put("op", op); else root.putNull("op");
|
||||
if (requestId != null) root.put("requestId", requestId); else root.putNull("requestId");
|
||||
root.put("status", status);
|
||||
root.put("ok", ok);
|
||||
if (!ok) {
|
||||
if (error != null) root.put("error", error); else root.putNull("error");
|
||||
if (message != null) root.put("message", message); else root.putNull("message");
|
||||
}
|
||||
|
||||
// payload — это всё, что осталось от full (может быть пустым объектом {})
|
||||
root.set("payload", full);
|
||||
|
||||
return JSON_MAPPER.writeValueAsString(root);
|
||||
|
||||
} catch (Exception e) {
|
||||
// Совсем аварийный случай — сериализация ответа сломалась.
|
||||
log.error("❌ Response serialization error (op={}, requestId={})",
|
||||
safe(response != null ? response.getOp() : null),
|
||||
safe(response != null ? response.getRequestId() : null),
|
||||
e);
|
||||
|
||||
return "{\"op\":\"" + safe(response != null ? response.getOp() : null) +
|
||||
"\",\"requestId\":\"" + safe(response != null ? response.getRequestId() : null) +
|
||||
"\",\"status\":" + (response != null ? response.getStatus() : 500) +
|
||||
",\"ok\":false" +
|
||||
",\"error\":\"SERIALIZATION_ERROR\"" +
|
||||
",\"message\":\"Ошибка сериализации ответа\"" +
|
||||
",\"payload\":{}}";
|
||||
}
|
||||
}
|
||||
|
||||
private static String safe(String s) {
|
||||
return s != null ? s : "";
|
||||
}
|
||||
|
||||
private static String shorten(String s, int max) {
|
||||
if (s == null) return "";
|
||||
if (s.length() <= max) return s;
|
||||
return s.substring(0, Math.max(0, max)) + "...(+" + (s.length() - max) + " chars)";
|
||||
}
|
||||
|
||||
private static String safeToString(Object o) {
|
||||
if (o == null) return "null";
|
||||
try {
|
||||
// Чтобы не плодить огромные логи и не утыкаться в циклические ссылки —
|
||||
// логируем как JSON, если возможно.
|
||||
return JSON_MAPPER.writeValueAsString(o);
|
||||
} catch (Exception ignore) {
|
||||
return String.valueOf(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4548
File diff suppressed because it is too large
Load Diff
SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/concat_to_file.sh
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTFILE="all_files.txt"
|
||||
|
||||
# очищаем или создаём файл
|
||||
: > "$OUTFILE"
|
||||
|
||||
# собрать только *.java файлы и вывести их содержимое в файл
|
||||
find . -type f -name "*.java" | sort | while read -r f; do
|
||||
cat "$f" >> "$OUTFILE"
|
||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
||||
done
|
||||
|
||||
# скопировать весь файл в буфер обмена (Wayland)
|
||||
wl-copy < "$OUTFILE"
|
||||
|
||||
echo "Готово!"
|
||||
echo "Все .java файлы собраны в $OUTFILE"
|
||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package server.logic.ws_protocol.JSON.debug;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Потокобезопасный in-memory буфер debug-логов.
|
||||
*/
|
||||
public final class DebugRunLogBuffer {
|
||||
|
||||
public static final class Entry {
|
||||
public long ts;
|
||||
public String level;
|
||||
public String runId;
|
||||
public String source;
|
||||
public String sessionId;
|
||||
public String login;
|
||||
public String message;
|
||||
public String details;
|
||||
}
|
||||
|
||||
private static final DebugRunLogBuffer INSTANCE = new DebugRunLogBuffer(5_000);
|
||||
|
||||
public static DebugRunLogBuffer getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private final int capacity;
|
||||
private final Deque<Entry> entries = new ArrayDeque<>();
|
||||
|
||||
private DebugRunLogBuffer(int capacity) {
|
||||
this.capacity = Math.max(100, capacity);
|
||||
}
|
||||
|
||||
public synchronized String newRunId() {
|
||||
return "dbg-" + UUID.randomUUID();
|
||||
}
|
||||
|
||||
public synchronized void clear() {
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
public synchronized void add(String level, String runId, String source, String sessionId, String login, String message, String details) {
|
||||
Entry e = new Entry();
|
||||
e.ts = System.currentTimeMillis();
|
||||
e.level = safe(level, "info");
|
||||
e.runId = safe(runId, "");
|
||||
e.source = safe(source, "server");
|
||||
e.sessionId = safe(sessionId, "");
|
||||
e.login = safe(login, "");
|
||||
e.message = safe(message, "");
|
||||
e.details = safe(details, "");
|
||||
|
||||
entries.addLast(e);
|
||||
while (entries.size() > capacity) {
|
||||
entries.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized List<Entry> tail(int limit, String runIdFilter) {
|
||||
int capped = Math.max(1, Math.min(limit, 1000));
|
||||
String filter = safe(runIdFilter, "");
|
||||
|
||||
List<Entry> out = new ArrayList<>(capped);
|
||||
Object[] arr = entries.toArray();
|
||||
for (int i = arr.length - 1; i >= 0 && out.size() < capped; i--) {
|
||||
Entry e = (Entry) arr[i];
|
||||
if (!filter.isBlank() && !filter.equals(e.runId)) continue;
|
||||
out.add(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String safe(String value, String fallback) {
|
||||
String s = value == null ? "" : value.trim();
|
||||
return s.isBlank() ? fallback : s;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package server.logic.ws_protocol.JSON.entyties;
|
||||
|
||||
/**
|
||||
* Базовый класс для всех событий (event).
|
||||
* Общие поля: op и payload.
|
||||
*.
|
||||
* Формат JSON (event):
|
||||
* {
|
||||
* "op": "...",
|
||||
* "payload": { ... }
|
||||
* }
|
||||
*/
|
||||
public abstract class Net_Event {
|
||||
|
||||
/** Имя операции / события (op). */
|
||||
private String op;
|
||||
|
||||
/**
|
||||
* Произвольные данные.
|
||||
* В JSON это поле "payload".
|
||||
*/
|
||||
private Object payload;
|
||||
|
||||
// --- getters / setters ---
|
||||
|
||||
public String getOp() {
|
||||
return op;
|
||||
}
|
||||
|
||||
public void setOp(String op) {
|
||||
this.op = op;
|
||||
}
|
||||
|
||||
public Object getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
public void setPayload(Object payload) {
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package server.logic.ws_protocol.JSON.entyties;
|
||||
|
||||
/**
|
||||
* Ответ с ошибкой (любой отказ).
|
||||
*.
|
||||
* В wire-формате error/message поднимаются на верхний уровень,
|
||||
* а payload остаётся объектом.
|
||||
*/
|
||||
public class Net_Exception_Response extends Net_Response {
|
||||
|
||||
private String code;
|
||||
private String message;
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package server.logic.ws_protocol.JSON.entyties;
|
||||
|
||||
/**
|
||||
* Базовый класс для всех запросов (client → server).
|
||||
*.
|
||||
* Наследуется от NetEvent и добавляет requestId.
|
||||
*.
|
||||
* Формат JSON (request):
|
||||
* {
|
||||
* "op": "...",
|
||||
* "requestId": "...",
|
||||
* "payload": { ... }
|
||||
* }
|
||||
*/
|
||||
public abstract class Net_Request extends Net_Event {
|
||||
|
||||
/** Идентификатор запроса, чтобы связать запрос и ответ. */
|
||||
private String requestId;
|
||||
|
||||
// --- getters / setters ---
|
||||
|
||||
public String getRequestId() {
|
||||
return requestId;
|
||||
}
|
||||
|
||||
public void setRequestId(String requestId) {
|
||||
this.requestId = requestId;
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package server.logic.ws_protocol.JSON.entyties;
|
||||
|
||||
/**
|
||||
* Базовый класс для всех ответов (server → client).
|
||||
*.
|
||||
* Наследуется от NetRequest и добавляет status.
|
||||
*.
|
||||
* Формат JSON (response):
|
||||
* {
|
||||
* "op": "...",
|
||||
* "requestId": "...",
|
||||
* "status": 200,
|
||||
* "ok": true,
|
||||
* "payload": { ... } // и для успеха, и для ошибки
|
||||
* }
|
||||
*/
|
||||
public abstract class Net_Response extends Net_Request {
|
||||
|
||||
/** Статус результата (200 — успех, любое другое значение — ошибка). */
|
||||
private int status;
|
||||
|
||||
// --- getters / setters ---
|
||||
|
||||
public int getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(int status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public boolean isOk() {
|
||||
return status >= 200 && status < 300;
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package server.logic.ws_protocol.JSON.entyties;
|
||||
|
||||
/**
|
||||
* Базовый класс для всех событий (event).
|
||||
* Общие поля: op и payload.
|
||||
*.
|
||||
* Формат JSON (event):
|
||||
* {
|
||||
* "op": "...",
|
||||
* "payload": { ... }
|
||||
* }
|
||||
*/
|
||||
public abstract class Net_Event {
|
||||
|
||||
/** Имя операции / события (op). */
|
||||
private String op;
|
||||
|
||||
/**
|
||||
* Произвольные данные.
|
||||
* В JSON это поле "payload".
|
||||
*/
|
||||
private Object payload;
|
||||
|
||||
// --- getters / setters ---
|
||||
|
||||
public String getOp() {
|
||||
return op;
|
||||
}
|
||||
|
||||
public void setOp(String op) {
|
||||
this.op = op;
|
||||
}
|
||||
|
||||
public Object getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
public void setPayload(Object payload) {
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
package server.logic.ws_protocol.JSON.entyties;
|
||||
|
||||
/**
|
||||
* Ответ с ошибкой (любой отказ).
|
||||
*.
|
||||
* В payload будет:
|
||||
* {
|
||||
* "code": "...",
|
||||
* "message": "..."
|
||||
* }
|
||||
*/
|
||||
public class Net_Exception_Response extends Net_Response {
|
||||
|
||||
private String code;
|
||||
private String message;
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
package server.logic.ws_protocol.JSON.entyties;
|
||||
|
||||
/**
|
||||
* Базовый класс для всех запросов (client → server).
|
||||
*.
|
||||
* Наследуется от NetEvent и добавляет requestId.
|
||||
*.
|
||||
* Формат JSON (request):
|
||||
* {
|
||||
* "op": "...",
|
||||
* "requestId": "...",
|
||||
* "payload": { ... }
|
||||
* }
|
||||
*/
|
||||
public abstract class Net_Request extends Net_Event {
|
||||
|
||||
/** Идентификатор запроса, чтобы связать запрос и ответ. */
|
||||
private String requestId;
|
||||
|
||||
// --- getters / setters ---
|
||||
|
||||
public String getRequestId() {
|
||||
return requestId;
|
||||
}
|
||||
|
||||
public void setRequestId(String requestId) {
|
||||
this.requestId = requestId;
|
||||
}
|
||||
}
|
||||
|
||||
package server.logic.ws_protocol.JSON.entyties;
|
||||
|
||||
/**
|
||||
* Базовый класс для всех ответов (server → client).
|
||||
*.
|
||||
* Наследуется от NetRequest и добавляет status.
|
||||
*.
|
||||
* Формат JSON (response):
|
||||
* {
|
||||
* "op": "...",
|
||||
* "requestId": "...",
|
||||
* "status": 200,
|
||||
* "payload": { ... } // и для успеха, и для ошибки
|
||||
* }
|
||||
*/
|
||||
public abstract class Net_Response extends Net_Request {
|
||||
|
||||
/** Статус результата (200 — успех, любое другое значение — ошибка). */
|
||||
private int status;
|
||||
|
||||
// --- getters / setters ---
|
||||
|
||||
public int getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(int status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public boolean isOk() {
|
||||
return status == 200;
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTFILE="all_files.txt"
|
||||
|
||||
# очищаем или создаём файл
|
||||
: > "$OUTFILE"
|
||||
|
||||
# собрать только *.java файлы и вывести их содержимое в файл
|
||||
find . -type f -name "*.java" | sort | while read -r f; do
|
||||
cat "$f" >> "$OUTFILE"
|
||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
||||
done
|
||||
|
||||
# скопировать весь файл в буфер обмена (Wayland)
|
||||
wl-copy < "$OUTFILE"
|
||||
|
||||
echo "Готово!"
|
||||
echo "Все .java файлы собраны в $OUTFILE"
|
||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package server.logic.ws_protocol.JSON.handlers;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Общий интерфейс для всех JSON-хэндлеров.
|
||||
*/
|
||||
public interface JsonMessageHandler {
|
||||
|
||||
/**
|
||||
* Обработать запрос и вернуть ответ.
|
||||
*
|
||||
* @param request распарсенный запрос
|
||||
* @param ctx контекст текущего WebSocket-соединения
|
||||
*/
|
||||
Net_Response handle(Net_Request request, ConnectionContext ctx) throws Exception;
|
||||
}
|
||||
+3475
File diff suppressed because it is too large
Load Diff
+106
@@ -0,0 +1,106 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.auth;
|
||||
|
||||
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.auth.entyties.Net_AuthChallenge_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.SolanaUsersDAO;
|
||||
import shine.db.entities.SolanaUserEntry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
|
||||
/**
|
||||
* AuthChallenge (v2) — шаг 1 создания новой сессии.
|
||||
*
|
||||
* Логика авторизации (v2):
|
||||
* - Создание новой сессии возможно ТОЛЬКО через deviceKey пользователя.
|
||||
* - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге:
|
||||
* CreateAuthSession(..., signature(deviceKey, AUTH_CREATE_SESSION:...))
|
||||
*
|
||||
* Что делает:
|
||||
* 1) Проверяет login.
|
||||
* 2) Находит пользователя (solana_users).
|
||||
* 3) Пишет solanaUser в ctx, ставит AUTH_STATUS_AUTH_IN_PROGRESS.
|
||||
* 4) Генерирует authNonce (base64url(32)) и сохраняет в ctx.authNonce.
|
||||
*/
|
||||
public class Net_AuthChallenge_Handler implements JsonMessageHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_AuthChallenge_Handler.class);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||
|
||||
Net_AuthChallenge_Request req = (Net_AuthChallenge_Request) baseReq;
|
||||
|
||||
String login = req.getLogin();
|
||||
if (login == null || login.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
WireCodes.Status.BAD_REQUEST,
|
||||
"EMPTY_LOGIN",
|
||||
"Пустой логин"
|
||||
);
|
||||
}
|
||||
|
||||
// Если по этому соединению уже есть залогиненный пользователь — не даём повторную авторификацию
|
||||
if (ctx.getLogin() != null) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
WireCodes.Status.BAD_REQUEST,
|
||||
"ALREADY_AUTHED",
|
||||
"Попытка повторной авторификации для уже заданного login=" + ctx.getLogin()
|
||||
);
|
||||
}
|
||||
|
||||
SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login);
|
||||
if (solanaUserEntry == null) {
|
||||
try {
|
||||
solanaUserEntry = SolanaUserPdaImportService.findOrImportByLogin(login);
|
||||
if (solanaUserEntry != null) {
|
||||
log.info("AuthChallenge: пользователь {} импортирован из Solana PDA", solanaUserEntry.getLogin());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("AuthChallenge: ошибка lazy-import пользователя {} из Solana", login, e);
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
WireCodes.Status.SERVER_DATA_ERROR,
|
||||
"SOLANA_IMPORT_FAILED",
|
||||
"Ошибка проверки пользователя в Solana"
|
||||
);
|
||||
}
|
||||
}
|
||||
if (solanaUserEntry == null) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req,
|
||||
WireCodes.Status.UNVERIFIED,
|
||||
"UNKNOWN_USER",
|
||||
"Пользователь с таким логином не найден"
|
||||
);
|
||||
}
|
||||
|
||||
ctx.setSolanaUser(solanaUserEntry);
|
||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS);
|
||||
|
||||
byte[] buf = new byte[32];
|
||||
RANDOM.nextBytes(buf);
|
||||
String authNonce = Base64Ws.encode(buf);
|
||||
|
||||
ctx.setAuthNonce(authNonce);
|
||||
|
||||
Net_AuthChallenge_Response resp = new Net_AuthChallenge_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setAuthNonce(authNonce);
|
||||
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user