channels ux cleanup and create-flow recovery

This commit is contained in:
DrygMira
2026-04-14 02:08:44 +03:00
parent 07e57b8563
commit 126b4ba3a1
22 changed files with 2322 additions and 664 deletions
@@ -3,8 +3,7 @@ package blockchain;
import blockchain.body.*;
/**
* Парсер body выбирает класс по header: type/subType/version,
* потому что bodyBytes больше НЕ содержат type/subType/version.
* Parser for body record by header type/subType/version.
*/
public final class BodyRecordParser {
@@ -15,25 +14,26 @@ public final class BodyRecordParser {
int t = type & 0xFFFF;
int v = version & 0xFFFF;
int st = subType & 0xFFFF;
// TECH supports Header v1 and CreateChannel v1/v2.
if (t == (CreateChannelBody.TYPE & 0xFFFF)) {
if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF) && v == (HeaderBody.VER & 0xFFFF)) {
return new HeaderBody(subType, version, bodyBytes).check();
}
if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)
&& (v == (CreateChannelBody.VER & 0xFFFF) || v == (CreateChannelBody.VER2 & 0xFFFF))) {
return new CreateChannelBody(subType, version, bodyBytes).check();
}
throw new IllegalArgumentException(
String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
);
}
int key = (t << 16) | v;
BodyRecord r = switch (key) {
case 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);
@@ -47,16 +47,16 @@ public final class BodyRecordParser {
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
}
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
case UserParamBody.KEY -> new UserParamBody(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)
t, v, st
));
};
return r.check();
}
}
}
@@ -6,56 +6,54 @@ import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.regex.Pattern;
import java.util.Objects;
/**
* CreateChannelBody — TECH сообщение создания канала.
* TECH body for create channel.
*
* 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)
* v1 body bytes:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [1] channelNameLen (uint8)
* [N] channelName UTF-8 (^[A-Za-z0-9_]+$)
* [1] channelNameLen
* [N] channelName UTF-8
*
* Важно:
* - канал "0" зарезервирован (создаётся по умолчанию от HEADER), создавать его нельзя.
* v2 body bytes:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [1] channelNameLen
* [N] channelName UTF-8
* [2] channelDescriptionLen
* [M] channelDescription UTF-8 (0..200 bytes)
*/
public final class CreateChannelBody implements BodyRecord, BodyHasLine {
public static final short TYPE = 0;
public static final short VER = 1;
public static final short VER = 1;
public static final short VER2 = 2;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public static final int KEY_V2 = ((TYPE & 0xFFFF) << 16) | (VER2 & 0xFFFF);
public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL;
private static final byte[] ZERO32 = new byte[32];
private static final int MIN_NAME_LENGTH = 3;
private static final int MAX_NAME_LENGTH = 32;
private static final Pattern ALLOWED_NAME_PATTERN =
Pattern.compile("^[\\p{IsLatin}\\p{IsCyrillic}0-9 _-]+$");
private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
public final short subType; // из header
public final short version; // из header
public final short subType;
public final short version;
// line
public final int lineCode;
public final int prevLineNumber;
public final byte[] prevLineHash32; // 32
public final byte[] prevLineHash32;
public final int thisLineNumber;
// payload
public final String channelName;
public final String channelDescription;
public CreateChannelBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
@@ -63,14 +61,14 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("CreateChannelBody version must be 1, got=" + (this.version & 0xFFFF));
int ver = this.version & 0xFFFF;
if (ver != (VER & 0xFFFF) && ver != (VER2 & 0xFFFF)) {
throw new IllegalArgumentException("CreateChannelBody version must be 1 or 2, got=" + ver);
}
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");
}
@@ -78,7 +76,6 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32];
@@ -88,16 +85,44 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0");
if (bb.remaining() != nameLen) {
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());
if (ver == (VER2 & 0xFFFF)) {
if (bb.remaining() < 2) {
throw new IllegalArgumentException("CreateChannelBody v2 missing channelDescriptionLen");
}
int descriptionLen = Short.toUnsignedInt(bb.getShort());
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
}
if (bb.remaining() != descriptionLen) {
throw new IllegalArgumentException("CreateChannelBody v2 tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
}
if (descriptionLen == 0) {
this.channelDescription = "";
} else {
byte[] descriptionBytes = new byte[descriptionLen];
bb.get(descriptionBytes);
this.channelDescription = normalizeDescription(new String(descriptionBytes, StandardCharsets.UTF_8));
}
if (bb.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
return;
}
this.channelDescription = "";
if (bb.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
}
public CreateChannelBody(int lineCode,
@@ -105,11 +130,30 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
byte[] prevLineHash32,
int thisLineNumber,
String channelName) {
this(lineCode, prevLineNumber, prevLineHash32, thisLineNumber, channelName, "", VER);
}
public CreateChannelBody(int lineCode,
int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
String channelName,
String channelDescription) {
this(lineCode, prevLineNumber, prevLineHash32, thisLineNumber, channelName, channelDescription, VER2);
}
private CreateChannelBody(int lineCode,
int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
String channelName,
String channelDescription,
short version) {
Objects.requireNonNull(channelName, "channelName == null");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
this.subType = SUBTYPE;
this.version = VER;
this.version = version;
this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber;
@@ -117,32 +161,42 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
this.thisLineNumber = thisLineNumber;
this.channelName = channelName;
this.channelDescription = channelDescription == null ? "" : channelDescription;
}
@Override
public CreateChannelBody check() {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF))
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)");
}
String normalizedName = normalizeDisplayName(channelName);
if (normalizedName.isEmpty())
if (normalizedName.isEmpty()) {
throw new IllegalArgumentException("channelName is blank");
int cpLen = normalizedName.codePointCount(0, normalizedName.length());
// Backward compatibility for historical blocks:
// strict create-channel rules are enforced in AddBlock handler (ChannelNameRules),
// but parser-level check must allow legacy channel names during bootstrap/replay.
if (cpLen > MAX_NAME_LENGTH)
throw new IllegalArgumentException("channelName length must be <=32");
}
// tech-line: prev обязателен (минимум HEADER=0)
if (prevLineNumber < 0)
int cpLen = normalizedName.codePointCount(0, normalizedName.length());
if (cpLen > MAX_NAME_LENGTH) {
throw new IllegalArgumentException("channelName length must be <=32");
}
String normalizedDescription = normalizeDescription(channelDescription);
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
}
if (prevLineNumber < 0) {
throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody");
if (prevLineHash32 == null || prevLineHash32.length != 32)
}
if (prevLineHash32 == null || prevLineHash32.length != 32) {
throw new IllegalArgumentException("prevLineHash32 invalid");
if (thisLineNumber <= 0)
}
if (thisLineNumber <= 0) {
throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody");
}
return this;
}
@@ -152,17 +206,28 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
return value.trim().replaceAll("\\s+", " ");
}
private static String normalizeDescription(String value) {
if (value == null) return "";
return value.trim().replaceAll("\\s+", " ");
}
@Override
public byte[] toBytes() {
byte[] nameUtf8 = channelName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255)
byte[] nameUtf8 = normalizeDisplayName(channelName).getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("channelName utf8 len must be 1..255");
}
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length;
boolean isV2 = (version & 0xFFFF) == (VER2 & 0xFFFF);
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
}
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + (isV2 ? 2 + descriptionUtf8.length : 0);
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);
@@ -170,12 +235,27 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
if (isV2) {
bb.putShort((short) (descriptionUtf8.length & 0xFFFF));
if (descriptionUtf8.length > 0) {
bb.put(descriptionUtf8);
}
}
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; }
@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; }
}