SHA256
385 lines
16 KiB
Java
385 lines
16 KiB
Java
package blockchain.body;
|
|
|
|
import blockchain.MsgSubType;
|
|
|
|
import java.nio.ByteBuffer;
|
|
import java.nio.ByteOrder;
|
|
import java.nio.charset.CharacterCodingException;
|
|
import java.nio.charset.CodingErrorAction;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.util.Arrays;
|
|
import java.util.Objects;
|
|
|
|
/**
|
|
* TextLineBody — type=1, ver=1.
|
|
*
|
|
* subType:
|
|
* - POST (10)
|
|
* - EDIT_POST (11)
|
|
* - REPOST (50)
|
|
* - CHANNEL_META (90)
|
|
* - ENTRYPOINT (100)
|
|
* - EXERCISE (110)
|
|
* - SERVICE (120)
|
|
* - COURSE (130)
|
|
*
|
|
* Формат bodyBytes (BigEndian):
|
|
*
|
|
* POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
|
|
* [4] lineCode
|
|
* [4] prevLineNumber
|
|
* [32] prevLineHash32
|
|
* [4] thisLineNumber
|
|
* [2] textLenBytes (uint16)
|
|
* [N] text UTF-8
|
|
*
|
|
* EDIT_POST:
|
|
* [4] lineCode
|
|
* [4] prevLineNumber
|
|
* [32] prevLineHash32
|
|
* [4] thisLineNumber
|
|
* [4] toBlockGlobalNumber (int32)
|
|
* [32] toBlockHash32
|
|
* [2] textLenBytes (uint16)
|
|
* [N] text UTF-8
|
|
*
|
|
* REPOST:
|
|
* [4] lineCode
|
|
* [4] prevLineNumber
|
|
* [32] prevLineHash32
|
|
* [4] thisLineNumber
|
|
* [1] toBlockchainNameLen (uint8)
|
|
* [N] toBlockchainName UTF-8
|
|
* [4] toBlockGlobalNumber (int32)
|
|
* [32] toBlockHash32
|
|
* [2] textLenBytes (uint16)
|
|
* [N] text UTF-8
|
|
*/
|
|
public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarget {
|
|
|
|
public static final short TYPE = 1;
|
|
public static final short VER = 1;
|
|
|
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
|
|
|
public final short subType; // из header
|
|
public final short version; // из header (=1)
|
|
|
|
// line
|
|
public final int lineCode;
|
|
public final int prevLineNumber;
|
|
public final byte[] prevLineHash32; // 32 (может быть нули)
|
|
public final int thisLineNumber;
|
|
|
|
// target (для EDIT_POST / REPOST)
|
|
public final String toBlockchainName; // nullable для POST/EDIT_POST
|
|
public final Integer toBlockGlobalNumber; // nullable для POST
|
|
public final byte[] toBlockHash32; // nullable для POST
|
|
|
|
// text
|
|
public final String message;
|
|
|
|
/* ====================== parse from bytes ====================== */
|
|
|
|
public TextLineBody(short subType, short version, byte[] bodyBytes) {
|
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
|
|
|
this.subType = subType;
|
|
this.version = version;
|
|
|
|
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
|
throw new IllegalArgumentException("TextLineBody version must be 1, got=" + (this.version & 0xFFFF));
|
|
}
|
|
|
|
int st = this.subType & 0xFFFF;
|
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
|
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st);
|
|
}
|
|
|
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
|
|
|
// минимум line + textLen(2)
|
|
ensureMin(bb, (4 + 4 + 32 + 4) + 2, "TextLineBody too short");
|
|
|
|
this.lineCode = bb.getInt();
|
|
this.prevLineNumber = bb.getInt();
|
|
|
|
this.prevLineHash32 = new byte[32];
|
|
bb.get(this.prevLineHash32);
|
|
|
|
this.thisLineNumber = bb.getInt();
|
|
|
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|
// нужен target
|
|
ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target");
|
|
int tgtNum = bb.getInt();
|
|
byte[] tgtHash = new byte[32];
|
|
bb.get(tgtHash);
|
|
|
|
this.toBlockchainName = null;
|
|
this.toBlockGlobalNumber = tgtNum;
|
|
this.toBlockHash32 = tgtHash;
|
|
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPOST missing target");
|
|
int nameLen = Byte.toUnsignedInt(bb.get());
|
|
if (nameLen <= 0) throw new IllegalArgumentException("REPOST toBlockchainNameLen is 0");
|
|
ensureMin(bb, nameLen + 4 + 32 + 2, "REPOST payload too short");
|
|
byte[] nameBytes = new byte[nameLen];
|
|
bb.get(nameBytes);
|
|
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
|
this.toBlockGlobalNumber = bb.getInt();
|
|
this.toBlockHash32 = new byte[32];
|
|
bb.get(this.toBlockHash32);
|
|
} else {
|
|
this.toBlockchainName = null;
|
|
this.toBlockGlobalNumber = null;
|
|
this.toBlockHash32 = null;
|
|
}
|
|
|
|
this.message = readStrictUtf8Len16(bb, "TextLineBody text",
|
|
st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF));
|
|
|
|
ensureNoTail(bb, "TextLineBody");
|
|
}
|
|
|
|
/* ====================== manual ctor ====================== */
|
|
|
|
public TextLineBody(int lineCode,
|
|
int prevLineNumber,
|
|
byte[] prevLineHash32,
|
|
int thisLineNumber,
|
|
short subType,
|
|
Integer toBlockGlobalNumber,
|
|
byte[] toBlockHash32,
|
|
String toBlockchainName,
|
|
String message) {
|
|
|
|
Objects.requireNonNull(message, "message == null");
|
|
|
|
int st = subType & 0xFFFF;
|
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
|
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META");
|
|
}
|
|
|
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
|
if (requiresNonBlankMessage(st) && message.isBlank()) {
|
|
throw new IllegalArgumentException("message is blank");
|
|
}
|
|
|
|
this.subType = subType;
|
|
this.version = VER;
|
|
|
|
this.lineCode = lineCode;
|
|
this.prevLineNumber = prevLineNumber;
|
|
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
|
this.thisLineNumber = thisLineNumber;
|
|
|
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
|
|
|
this.toBlockchainName = null;
|
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
|
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
|
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
|
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
|
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
|
|
|
this.toBlockchainName = toBlockchainName;
|
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
|
} else {
|
|
this.toBlockchainName = null;
|
|
this.toBlockGlobalNumber = null;
|
|
this.toBlockHash32 = null;
|
|
}
|
|
|
|
this.message = message;
|
|
}
|
|
|
|
@Override
|
|
public TextLineBody check() {
|
|
int st = subType & 0xFFFF;
|
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
|
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF))
|
|
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
|
|
|
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
|
if (prevLineHash32 == null || prevLineHash32.length != 32)
|
|
throw new IllegalArgumentException("prevLineHash32 invalid");
|
|
|
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|
if (message == null) throw new IllegalArgumentException("EDIT_POST message is null");
|
|
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
|
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
|
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
|
if (toBlockchainName != null)
|
|
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName");
|
|
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
|
if (message == null || message.isBlank())
|
|
throw new IllegalArgumentException("REPOST message is blank");
|
|
if (toBlockchainName == null || toBlockchainName.isBlank())
|
|
throw new IllegalArgumentException("REPOST toBlockchainName is blank");
|
|
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
|
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
|
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
|
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
|
|
} else {
|
|
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
|
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
|
|
} else if (requiresNonBlankMessage(st) && (message == null || message.isBlank())) {
|
|
throw new IllegalArgumentException("Text message is blank");
|
|
} else if (message == null) {
|
|
throw new IllegalArgumentException("Text message is null");
|
|
}
|
|
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
|
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
@Override
|
|
public byte[] toBytes() {
|
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
|
|
|
int st = subType & 0xFFFF;
|
|
if (requiresNonBlankMessage(st) && msgUtf8.length == 0) {
|
|
throw new IllegalArgumentException("Text payload is empty");
|
|
}
|
|
|
|
int cap;
|
|
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
|
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
|
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
|
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
|
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
|
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
|
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
|
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|
// EDIT_POST
|
|
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
|
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
|
|
cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
|
|
} else {
|
|
if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName");
|
|
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
|
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
|
throw new IllegalArgumentException("REPOST toBlockchainName utf8 len must be 1..255");
|
|
}
|
|
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPOST missing toBlockGlobalNumber");
|
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32");
|
|
cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length;
|
|
}
|
|
|
|
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
|
|
bb.putInt(lineCode);
|
|
bb.putInt(prevLineNumber);
|
|
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
|
bb.putInt(thisLineNumber);
|
|
|
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|
bb.putInt(toBlockGlobalNumber);
|
|
bb.put(toBlockHash32);
|
|
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
|
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
|
bb.put((byte) nameUtf8.length);
|
|
bb.put(nameUtf8);
|
|
bb.putInt(toBlockGlobalNumber);
|
|
bb.put(toBlockHash32);
|
|
}
|
|
|
|
bb.putShort((short) msgUtf8.length);
|
|
bb.put(msgUtf8);
|
|
|
|
return bb.array();
|
|
}
|
|
|
|
/* ====================== BodyHasLine ====================== */
|
|
@Override public int lineCode() { return lineCode; }
|
|
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
|
@Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); }
|
|
@Override public int lineSeq() { return thisLineNumber; }
|
|
|
|
/* ====================== BodyHasTarget ===================== */
|
|
@Override public String toBchName() { return toBlockchainName; }
|
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
|
|
|
/* ====================== helpers ====================== */
|
|
|
|
public boolean isEditPost() {
|
|
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
|
}
|
|
|
|
private static boolean requiresNonBlankMessage(int st) {
|
|
return st == (MsgSubType.TEXT_POST & 0xFFFF)
|
|
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
|
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
|
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
|
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
|
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
|
}
|
|
|
|
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
|
int len = Short.toUnsignedInt(bb.getShort());
|
|
if (len == 0) {
|
|
if (allowEmpty) return "";
|
|
throw new IllegalArgumentException(fieldName + " is empty");
|
|
}
|
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
|
|
|
byte[] bytes = new byte[len];
|
|
bb.get(bytes);
|
|
|
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
|
.onMalformedInput(CodingErrorAction.REPORT)
|
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
|
|
|
try {
|
|
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
|
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
|
return s;
|
|
} catch (CharacterCodingException e) {
|
|
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
|
}
|
|
}
|
|
|
|
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
|
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
|
}
|
|
|
|
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
|
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
|
}
|
|
}
|