SHiNE-server/shine-server-blockchain/all_files.txt
2026-03-18 22:28:13 +03:00

2885 lines
114 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}
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);
}
}
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();
}
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();
}
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();
}
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
*
* 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);
}
@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; }
}
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;
/**
* CreateChannelBody — TECH сообщение создания канала.
*
* type=0, ver=1 (в заголовке блока)
* subType=MsgSubType.TECH_CREATE_CHANNEL (=1)
*
* Это сообщение идёт по ТЕХ-ЛИНИИ (hasLine):
* - prevLineNumber/hash указывают на предыдущее TECH-сообщение (HEADER или прошлый CREATE_CHANNEL)
* - thisLineNumber: 1,2,3... (тех-нумерация)
*
* bodyBytes (BigEndian), новый формат line-prefix:
* [4] lineCode (для TECH линии обычно 0)
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [1] channelNameLen (uint8)
* [N] channelName UTF-8 (^[A-Za-z0-9_]+$)
*
* Важно:
* - канал "0" зарезервирован (создаётся по умолчанию от HEADER), создавать его нельзя.
*/
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;
private static final byte[] ZERO32 = new byte[32];
public final short subType; // из header
public final short version; // из header
// line
public final int lineCode;
public final int prevLineNumber;
public final byte[] prevLineHash32; // 32
public final int thisLineNumber;
// payload
public final String channelName;
public CreateChannelBody(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("CreateChannelBody version must be 1, got=" + (this.version & 0xFFFF));
}
if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF));
}
// минимум: lineCode(4) + line(4+32+4) + nameLen(1) + name(1)
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1) {
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) {
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);
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) {
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;
}
@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)");
if (channelName == null || channelName.isBlank())
throw new IllegalArgumentException("channelName is blank");
if (!channelName.matches("^[A-Za-z0-9_]+$"))
throw new IllegalArgumentException("channelName must match ^[A-Za-z0-9_]+$");
if ("0".equals(channelName))
throw new IllegalArgumentException("channelName \"0\" is reserved");
// tech-line: prev обязателен (минимум HEADER=0)
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;
}
@Override
public byte[] toBytes() {
byte[] nameUtf8 = channelName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255)
throw new IllegalArgumentException("channelName utf8 len must be 1..255");
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length;
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);
return bb.array();
}
/* ====================== 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; }
}
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);
}
}
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
*
* 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 ((this.subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF)) {
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 ((subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF))
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; }
}
package blockchain;
import blockchain.body.*;
/**
* Парсер body выбирает класс по header: type/subType/version,
* потому что bodyBytes больше НЕ содержат 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 key = (t << 16) | v;
BodyRecord r = switch (key) {
case HeaderBody.KEY -> {
int st = subType & 0xFFFF;
if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF)) {
yield new HeaderBody(subType, version, bodyBytes);
}
if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)) {
yield new CreateChannelBody(subType, version, bodyBytes);
}
throw new IllegalArgumentException("Unknown TECH subType for type=0 ver=1: subType=" + st);
}
// TEXT type=1 ver=1: выбираем класс по subType
case TextBody.KEY -> {
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_POST & 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, (subType & 0xFFFF)
));
};
return r.check();
}
}
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);
}
}
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)
*
* Формат 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
*/
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)
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)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST, 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.toBlockGlobalNumber = tgtNum;
this.toBlockHash32 = tgtHash;
} else {
this.toBlockGlobalNumber = null;
this.toBlockHash32 = null;
}
this.message = readStrictUtf8Len16(bb, "TextLineBody text");
ensureNoTail(bb, "TextLineBody");
}
/* ====================== manual ctor ====================== */
public TextLineBody(int lineCode,
int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
short subType,
Integer toBlockGlobalNumber,
byte[] toBlockHash32,
String message) {
Objects.requireNonNull(message, "message == null");
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST");
}
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (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.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else {
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))
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 (message == null || message.isBlank())
throw new IllegalArgumentException("Text message is blank");
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
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 (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 == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF;
int cap;
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
} else {
// 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;
}
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);
}
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 null; } // по ТЗ: не хранить
@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) {
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());
}
}
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");
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 (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 (message == null || message.isBlank())
throw new IllegalArgumentException("Text message is blank");
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 (toBlockchainName == null || toBlockchainName.isBlank())
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
} else {
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 == 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_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) {
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());
}
}
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; }
}
//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; // параметры профиля да надо
//}
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;
/* ===================== REACTION (msg_type=2) ===================== */
/** Лайк (LIKE). */
public static final short REACTION_LIKE = 1;
/* ===================== CONNECTION (msg_type=3) ===================== */
/** Добавить в друзья. */
public static final short CONNECTION_FRIEND = 10;
/** Удалить из друзей. */
public static final short CONNECTION_UNFRIEND = 11;
/** Добавить в контакты. */
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;
/* ===================== USER_PARAM (msg_type=4) ===================== */
/** Параметр профиля key/value (обе строки). */
public static final short USER_PARAM_TEXT_TEXT = 1;
}
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';
}
}
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);
}
}
}