refactor: перенос серверных модулей в папку SHiNE-server

This commit is contained in:
AidarKC
2026-05-30 17:12:15 +04:00
parent 134e877b7c
commit 1b0e1cf1d4
312 changed files with 34 additions and 8 deletions
Binary file not shown.
+6
View File
@@ -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
View File
@@ -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();
}
@@ -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; }
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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; }
}
@@ -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;
}
@@ -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
View File
@@ -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
View File
@@ -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
+29
View File
@@ -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
View File
@@ -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;
}
}
}
}
}
@@ -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;
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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
View File
@@ -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
}
+20
View File
@@ -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
@@ -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,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;
}
}
@@ -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);
}
}
@@ -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 +
'}';
}
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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,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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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,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;
}
@@ -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