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
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);
}
}
}