16 12 25
Промежуточная версия и ТУДУ на чём остановился
This commit is contained in:
parent
19c4fd6cd1
commit
ab44cc5282
@ -33,8 +33,8 @@ dependencies {
|
||||
implementation project(':shine-server-config') // модуль настроек из application.properties
|
||||
|
||||
implementation project(':shine-server-crypto') // модуль сервера для работы с криптографией
|
||||
implementation project(':shine-server-blockchain') // модуль для работы с блокчейном
|
||||
implementation project(':shine-server-db') // модуль для работы с БД содержит и сущности из БД и саму работу с БД
|
||||
implementation project(':shine-server-blockchain') // модуль для работы с блокчейном
|
||||
|
||||
implementation project(':shine-server-geo') // модуль для определения геолокации по IP
|
||||
|
||||
|
||||
@ -26,6 +26,8 @@ dependencies {
|
||||
implementation 'org.slf4j:slf4j-api:2.0.16'
|
||||
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
|
||||
implementation project(':shine-server-db') // модуль для работы с БД содержит и сущности из БД и саму работу с БД
|
||||
|
||||
}
|
||||
|
||||
test {
|
||||
|
||||
16
shine-server-blockchain/concat_to_file.sh
Executable file
16
shine-server-blockchain/concat_to_file.sh
Executable file
@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTFILE="all_files.txt"
|
||||
|
||||
# очищаем или создаём файл
|
||||
: > "$OUTFILE"
|
||||
|
||||
# собрать только *.java файлы и вывести их содержимое в файл
|
||||
find . -type f -name "*.java" | sort | while read -r f; do
|
||||
cat "$f" >> "$OUTFILE"
|
||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
||||
done
|
||||
|
||||
echo "Готово! Все .java файлы собраны в $OUTFILE"
|
||||
|
||||
@ -0,0 +1,217 @@
|
||||
package blockchain;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* BchBlockEntry — универсальная запись блокчейна SHiNE (.bch)
|
||||
* ============================================================================
|
||||
*.
|
||||
* 🧩 Формат файла .bch:
|
||||
* Каждый блок хранится последовательно, без промежутков.
|
||||
* Один блок = «заголовок» (RAW) + подпись (64) + хэш (32).
|
||||
*.
|
||||
* FULL = RAW + signature(64) + hash(32)
|
||||
*.
|
||||
* ---------------------------------------------------------------------------
|
||||
* 🔹 Структура RAW-части блока (без подписи и хэша)
|
||||
* ---------------------------------------------------------------------------
|
||||
* Размеры и порядок строго фиксированы (BigEndian).
|
||||
*.
|
||||
* Порядок байтов (сверху вниз, смещения от начала RAW):
|
||||
*.
|
||||
* ┌────────────────────────────┬────────┬───────────────────────────────┐
|
||||
* │ Поле │ Размер │ Описание │
|
||||
* ├────────────────────────────┼────────┼───────────────────────────────┤
|
||||
* │ recordSize │ 4 байта│ = M + 20 — общий размер RAW │
|
||||
* │ recordNumber │ 4 байта│ порядковый номер блока │
|
||||
* │ timestamp │ 8 байт │ UNIX time (секунды) │
|
||||
* │ recordType │ 2 байта│ тип тела (0=Header, 1=Text) │
|
||||
* │ recordTypeVersion │ 2 байта│ версия структуры данного типа │
|
||||
* │ body │ M байт │ бинарное тело записи │
|
||||
* └────────────────────────────┴────────┴───────────────────────────────┘
|
||||
*.
|
||||
* ⇒ RAW_HEADER_SIZE = 4 + 4 + 8 + 2 + 2 = 20 байт.
|
||||
* ⇒ recordSize = RAW_HEADER_SIZE + body.length
|
||||
*.
|
||||
* ---------------------------------------------------------------------------
|
||||
* 🔹 Структура FULL-блока
|
||||
* ---------------------------------------------------------------------------
|
||||
*.
|
||||
* ┌────────────────────────────┬─────────┬──────────────────────────────┐
|
||||
* │ RAW │ M+20 │ тело блока без подписи │
|
||||
* │ signature64 │ 64 │ подпись Ed25519(preimage) │
|
||||
* │ hash32 │ 32 │ SHA-256(preimage) │
|
||||
* └────────────────────────────┴─────────┴──────────────────────────────┘
|
||||
*.
|
||||
* ⇒ Общая длина FULL = recordSize + 96 байт.
|
||||
*.
|
||||
* ---------------------------------------------------------------------------
|
||||
* 🔹 Канонический preimage для подписи/хэша
|
||||
* ---------------------------------------------------------------------------
|
||||
* preimage = userLogin(UTF-8, без длины) +
|
||||
* blockchainId(8B, BE) +
|
||||
* prevHash32(32B) +
|
||||
* rawBytes (M+20B)
|
||||
*.
|
||||
* hash32 = SHA-256(preimage)
|
||||
* signature64= Ed25519.sign(preimage, privateKey)
|
||||
*.
|
||||
* Проверка осуществляется через {@link utils.crypto.BchCryptoVerifier}.
|
||||
*.
|
||||
* ============================================================================
|
||||
*/
|
||||
public class BchBlockEntry {
|
||||
|
||||
// ---- Константы типов ----
|
||||
public static final short TYPE_HEADER = 0;
|
||||
public static final short TYPE_TEXT = 1;
|
||||
|
||||
// ---- Константы размеров ----
|
||||
public static final int SIGNATURE_LEN = 64;
|
||||
public static final int HASH_LEN = 32;
|
||||
/** Размер «сырой» шапки без подписи/хэша. */
|
||||
public static final int RAW_HEADER_SIZE = 20;
|
||||
|
||||
// ---- Поля RAW-заголовка ----
|
||||
public final int recordSize; // [4] M + 20
|
||||
public final int recordNumber; // [4] порядковый номер блока
|
||||
public final long timestamp; // [8] UNIX time (секунды)
|
||||
public final short recordType; // [2] тип тела (0=Header, 1=Text)
|
||||
public final short recordTypeVersion; // [2] версия структуры данного типа
|
||||
public final byte[] body; // [M] тело записи
|
||||
|
||||
// ---- Поля подписи и хэша ----
|
||||
private byte[] signature64; // [64] подпись (Ed25519)
|
||||
private byte[] hash32; // [32] хэш (SHA-256)
|
||||
|
||||
// ---- Кэшированные представления ----
|
||||
public final byte[] rawBytes; // RAW без подписи/хэша
|
||||
private byte[] rawBytesWithSignatureAndHash; // FULL (может быть null)
|
||||
|
||||
// ========================================================================
|
||||
// КОНСТРУКТОР №1 — из полей (RAW only)
|
||||
// ========================================================================
|
||||
public BchBlockEntry(int recordNumber,
|
||||
long timestamp,
|
||||
short recordType,
|
||||
short recordTypeVersion,
|
||||
byte[] body) {
|
||||
Objects.requireNonNull(body, "body == null");
|
||||
|
||||
this.recordNumber = recordNumber;
|
||||
this.timestamp = timestamp;
|
||||
this.recordType = recordType;
|
||||
this.recordTypeVersion = recordTypeVersion;
|
||||
this.body = Arrays.copyOf(body, body.length);
|
||||
this.recordSize = body.length + RAW_HEADER_SIZE;
|
||||
|
||||
ByteBuffer buf = ByteBuffer
|
||||
.allocate(RAW_HEADER_SIZE + body.length)
|
||||
.order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
buf.putInt(recordSize);
|
||||
buf.putInt(recordNumber);
|
||||
buf.putLong(timestamp);
|
||||
buf.putShort(recordType);
|
||||
buf.putShort(recordTypeVersion);
|
||||
buf.put(body);
|
||||
|
||||
this.rawBytes = buf.array();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// КОНСТРУКТОР №2 — из полного массива (RAW + SIG + HASH)
|
||||
// ========================================================================
|
||||
public BchBlockEntry(byte[] rawWithSigAndHash) {
|
||||
Objects.requireNonNull(rawWithSigAndHash, "rawWithSigAndHash == null");
|
||||
if (rawWithSigAndHash.length < RAW_HEADER_SIZE + SIGNATURE_LEN + HASH_LEN)
|
||||
throw new IllegalArgumentException("Слишком мало данных для полного блока");
|
||||
|
||||
ByteBuffer probe = ByteBuffer.wrap(rawWithSigAndHash).order(ByteOrder.BIG_ENDIAN);
|
||||
int rs = probe.getInt(); // recordSize
|
||||
if (rs < RAW_HEADER_SIZE)
|
||||
throw new IllegalArgumentException("Некорректный recordSize: " + rs);
|
||||
if (rawWithSigAndHash.length < rs + SIGNATURE_LEN + HASH_LEN)
|
||||
throw new IllegalArgumentException("Данные короче, чем raw+sig+hash");
|
||||
|
||||
this.rawBytes = Arrays.copyOfRange(rawWithSigAndHash, 0, rs);
|
||||
|
||||
ByteBuffer buf = ByteBuffer.wrap(this.rawBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
this.recordSize = buf.getInt();
|
||||
this.recordNumber = buf.getInt();
|
||||
this.timestamp = buf.getLong();
|
||||
this.recordType = buf.getShort();
|
||||
this.recordTypeVersion = buf.getShort();
|
||||
|
||||
int bodyLen = this.recordSize - RAW_HEADER_SIZE;
|
||||
if (bodyLen < 0 || bodyLen != this.rawBytes.length - RAW_HEADER_SIZE)
|
||||
throw new IllegalArgumentException("Неконсистентная длина тела блока");
|
||||
|
||||
this.body = new byte[bodyLen];
|
||||
buf.get(this.body);
|
||||
|
||||
// подпись + хэш
|
||||
ByteBuffer tail = ByteBuffer
|
||||
.wrap(rawWithSigAndHash, rs, SIGNATURE_LEN + HASH_LEN)
|
||||
.order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
this.signature64 = new byte[SIGNATURE_LEN];
|
||||
tail.get(this.signature64);
|
||||
this.hash32 = new byte[HASH_LEN];
|
||||
tail.get(this.hash32);
|
||||
|
||||
this.rawBytesWithSignatureAndHash =
|
||||
Arrays.copyOf(rawWithSigAndHash, rs + SIGNATURE_LEN + HASH_LEN);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Добавить подпись и хэш
|
||||
// ========================================================================
|
||||
public BchBlockEntry addSignatureAndHash(byte[] signature64, byte[] hash32) {
|
||||
Objects.requireNonNull(signature64, "signature64 == null");
|
||||
Objects.requireNonNull(hash32, "hash32 == null");
|
||||
if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64");
|
||||
if (hash32.length != HASH_LEN) throw new IllegalArgumentException("hash32 != 32");
|
||||
|
||||
this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN);
|
||||
this.hash32 = Arrays.copyOf(hash32, HASH_LEN);
|
||||
|
||||
byte[] full = new byte[this.rawBytes.length + SIGNATURE_LEN + HASH_LEN];
|
||||
System.arraycopy(this.rawBytes, 0, full, 0, this.rawBytes.length);
|
||||
System.arraycopy(this.signature64, 0, full, this.rawBytes.length, SIGNATURE_LEN);
|
||||
System.arraycopy(this.hash32, 0, full, this.rawBytes.length + SIGNATURE_LEN, HASH_LEN);
|
||||
this.rawBytesWithSignatureAndHash = full;
|
||||
return this;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Геттеры
|
||||
// ========================================================================
|
||||
public String getBodyAsText() {
|
||||
return new String(body, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public byte[] getSignature64() { return signature64 == null ? null : Arrays.copyOf(signature64, SIGNATURE_LEN); }
|
||||
public byte[] getHash32() { return hash32 == null ? null : Arrays.copyOf(hash32, HASH_LEN); }
|
||||
public byte[] getRawBytesWithSignatureAndHash() {
|
||||
return rawBytesWithSignatureAndHash == null ? null : Arrays.copyOf(rawBytesWithSignatureAndHash, rawBytesWithSignatureAndHash.length);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Отладка
|
||||
// ========================================================================
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format(
|
||||
"BchBlock[num=%d, type=%d, ver=%d, time=%d, raw=%d, full=%s]",
|
||||
recordNumber, recordType, recordTypeVersion, timestamp,
|
||||
rawBytes.length,
|
||||
rawBytesWithSignatureAndHash == null ? "null" : String.valueOf(rawBytesWithSignatureAndHash.length)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
package blockchain;
|
||||
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import utils.blockchain.BchInfoEntry;
|
||||
import utils.crypto.BchCryptoVerifier;
|
||||
|
||||
/**
|
||||
* BchBlockValidator — проверяет корректность блока:
|
||||
* 1) последовательность номеров блоков в цепочке;
|
||||
* 2) криптографическую целостность (подпись и хэш).
|
||||
*.
|
||||
* Не проверяет:
|
||||
* - структуру и содержимое body;
|
||||
* - поля HEADER и логин (этим занимаются другие проверки).
|
||||
*/
|
||||
public final class BchBlockValidator {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BchBlockValidator.class);
|
||||
|
||||
private BchBlockValidator() {}
|
||||
|
||||
/**
|
||||
* Проверяет, что блок может быть корректно добавлен к цепочке.
|
||||
*
|
||||
* Не используется при получении запроса на добавление блока по сети (тк там возвращаются более протоколо осмысленные коды
|
||||
* если блок не подходит по номеру.
|
||||
*
|
||||
* А этот класс может быть использован в будущем для внутренних, повторных проверок существующих цепочек блоков.
|
||||
*
|
||||
* @param block блок (распарсенный из байт)
|
||||
* @param chain информация о цепочке (BchInfoEntry)
|
||||
* @param chainId идентификатор цепочки
|
||||
* @return true если порядок и криптография корректны, иначе false
|
||||
*/
|
||||
public static boolean validate(BchBlockEntry block, BchInfoEntry chain, long chainId) {
|
||||
if (block == null || chain == null) {
|
||||
log.warn("❌ Ошибка: блок или данные о цепочке не переданы");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1️⃣ Проверка последовательности номера
|
||||
int expectedNumber = chain.lastBlockNumber + 1;
|
||||
if (block.recordNumber < expectedNumber) {
|
||||
log.warn("❌ Блок с номером {} уже существует (ожидался {})", block.recordNumber, expectedNumber);
|
||||
return false;
|
||||
}
|
||||
if (block.recordNumber > expectedNumber) {
|
||||
log.warn("❌ Нарушена последовательность: получен блок {}, ожидался {}", block.recordNumber, expectedNumber);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2️⃣ Проверка публичного ключа
|
||||
byte[] publicKey = chain.getPublicKey32();
|
||||
if (publicKey == null || publicKey.length != 32) {
|
||||
log.warn("❌ В цепочке отсутствует корректный публичный ключ (chainId={})", chainId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3️⃣ Получаем предыдущий хэш
|
||||
byte[] prevHash32 = hexToBytes(chain.lastBlockHash);
|
||||
|
||||
// 4️⃣ Проверка подписи и хэша
|
||||
try {
|
||||
boolean ok = BchCryptoVerifier.verifyAll(
|
||||
chain.userLogin,
|
||||
chainId,
|
||||
prevHash32,
|
||||
block.rawBytes,
|
||||
block.getSignature64(),
|
||||
block.getHash32(),
|
||||
publicKey
|
||||
);
|
||||
|
||||
if (!ok) {
|
||||
log.warn("❌ Криптографическая проверка не пройдена: хэш или подпись не совпадают (chainId={}, blockNum={})",
|
||||
chainId, block.recordNumber);
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info("✅ Блок {} успешно прошёл проверку подписи и хэша (chainId={})",
|
||||
block.recordNumber, chainId);
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("❌ Исключение при проверке блока (chainId={}): {}", chainId, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- HEX → байты --------------------
|
||||
|
||||
private static byte[] hexToBytes(String hex) {
|
||||
if (hex == null || hex.isEmpty()) return new byte[32]; // пустой хэш = 32 нуля
|
||||
int len = hex.length();
|
||||
byte[] out = new byte[len / 2];
|
||||
for (int i = 0; i < len; i += 2) {
|
||||
out[i / 2] = (byte) Integer.parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,106 @@
|
||||
package blockchain;
|
||||
|
||||
import blockchain.body.BodyRecord;
|
||||
import blockchain.body.HeaderBody;
|
||||
import blockchain.body.TextBody;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* BodyRecordParser — универсальный парсер тел (body) блоков .bch
|
||||
* ============================================================================
|
||||
*.
|
||||
* 🧩 Назначение:
|
||||
* Преобразует пару (recordType, recordTypeVersion, bodyBytes)
|
||||
* в конкретный объект, реализующий интерфейс {@link BodyRecord}.
|
||||
*.
|
||||
* 🔹 Особенность:
|
||||
* Используется объединённый 4-байтовый код:
|
||||
*.
|
||||
* fullCode = (recordType << 16) | (recordTypeVersion & 0xFFFF)
|
||||
*.
|
||||
* Это позволяет различать версии одного типа блока,
|
||||
* например: TextBody v1, TextBody v2 и т.д.
|
||||
*.
|
||||
* 🔹 Пример:
|
||||
* BodyRecord body = BodyRecordParser.parse(block.recordType, block.recordTypeVersion, block.body);
|
||||
*.
|
||||
* ============================================================================
|
||||
*/
|
||||
public final class BodyRecordParser {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BodyRecordParser.class);
|
||||
|
||||
private BodyRecordParser() {}
|
||||
|
||||
/**
|
||||
* Распарсить тело блока по типу и версии записи.
|
||||
*
|
||||
* @param recordType код типа записи (0 = Header, 1 = Text, ...)
|
||||
* @param recordTypeVersion версия формата записи
|
||||
* @param body массив байт тела записи
|
||||
* @return объект, реализующий BodyRecord
|
||||
*/
|
||||
public static BodyRecord parse(short recordType, short recordTypeVersion, byte[] body) {
|
||||
if (body == null)
|
||||
throw new IllegalArgumentException("body == null");
|
||||
|
||||
// Объединяем тип и версию в 4-байтовый ключ
|
||||
int fullCode = ((recordType & 0xFFFF) << 16) | (recordTypeVersion & 0xFFFF);
|
||||
|
||||
switch (fullCode) {
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// TYPE 0, VERSION 1 — HeaderBody v1
|
||||
// ---------------------------------------------------------
|
||||
// Заголовок цепочки пользователя (первый блок).
|
||||
//
|
||||
// Формат body (без общих 20 байт заголовка блока):
|
||||
// [8] ASCII tag = "SHiNE001"
|
||||
// [8] blockchainId (long, BE)
|
||||
// [4] blockchainType (int, BE)
|
||||
// [4] blockchainNumber (int, BE)
|
||||
// [1] userLoginLength = N (unsigned byte)
|
||||
// [N] userLogin (UTF-8)
|
||||
// [2] versionUserBch (short, BE)
|
||||
// [8] prevUserBchId (long, BE)
|
||||
// [32] publicKey32
|
||||
//
|
||||
// Назначение:
|
||||
// Создаёт новую пользовательскую цепочку (блок №0).
|
||||
case (0x0000_0001):
|
||||
return new HeaderBody(body);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// TYPE 1, VERSION 1 — TextBody v1
|
||||
// ---------------------------------------------------------
|
||||
// Простое текстовое сообщение UTF-8.
|
||||
//
|
||||
// Формат body (без общих 20 байт заголовка блока):
|
||||
// [N] message (UTF-8)
|
||||
//
|
||||
// Назначение:
|
||||
// Текстовые и системные сообщения, описания, комментарии.
|
||||
case (0x0001_0001):
|
||||
return new TextBody(body);
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// РЕЗЕРВ — будущие типы и версии
|
||||
// ---------------------------------------------------------
|
||||
// Пример: (0x0001_0002) → TextBody v2 (например, JSON-структура)
|
||||
// (0x0002_0001) → FileBody v1
|
||||
//
|
||||
// case (0x0001_0002):
|
||||
// return new TextBodyV2(body);
|
||||
//
|
||||
// case (0x0002_0001):
|
||||
// return new FileBody(body);
|
||||
|
||||
default:
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Неизвестный тип блока: type=%d version=%d (fullCode=0x%08X)",
|
||||
recordType, recordTypeVersion, fullCode));
|
||||
}
|
||||
}
|
||||
}
|
||||
75
shine-server-blockchain/src/main/java/blockchain/README.md
Normal file
75
shine-server-blockchain/src/main/java/blockchain/README.md
Normal file
@ -0,0 +1,75 @@
|
||||
# 📦 Модуль `blockchain`
|
||||
|
||||
Модуль отвечает за хранение, чтение, проверку и создание бинарных блоков формата `.bch` — базовых элементов цепочек в системе SHiNE Blockchain.
|
||||
Каждый блок содержит заголовок, тело (body), подпись Ed25519 и хэш SHA-256.
|
||||
|
||||
---
|
||||
|
||||
## 🔹 `BchBlockEntry`
|
||||
Главный класс блока.
|
||||
|
||||
**Публичные методы:**
|
||||
- `BchBlockEntry(int num, long time, short type, short ver, byte[] body)` — создаёт новый блок без подписи.
|
||||
- `BchBlockEntry(byte[] full)` — распаковывает блок из байтов (`RAW + SIG + HASH`).
|
||||
- `addSignatureAndHash(byte[] sig, byte[] hash)` — добавляет подпись и хэш.
|
||||
- `getBodyAsText()` — возвращает тело как строку.
|
||||
- `getSignature64()`, `getHash32()`, `getRawBytesWithSignatureAndHash()` — доступ к данным блока.
|
||||
- `toString()` — краткое описание блока.
|
||||
|
||||
---
|
||||
|
||||
## 🔹 `BchBlockValidator` // сделан на будущее, для сетевых запросов не используется
|
||||
Проверяет, можно ли добавить блок в цепочку.
|
||||
|
||||
**Публичный метод:**
|
||||
- `validate(BchBlockEntry block, BchInfoEntry chain, long chainId)` — сверяет номер блока, подпись и хэш.
|
||||
|
||||
---
|
||||
|
||||
## 🔹 `BodyRecordParser`
|
||||
Определяет, какой тип тела (`HeaderBody`, `TextBody` и т.п.) нужно создать.
|
||||
|
||||
**Публичный метод:**
|
||||
- `parse(short type, short version, byte[] body)` — возвращает объект, реализующий `BodyRecord`.
|
||||
|
||||
---
|
||||
|
||||
## 🔹 `BodyRecord` (интерфейс)
|
||||
Общее поведение для всех тел блоков.
|
||||
|
||||
**Методы:**
|
||||
- `check()` — проверить корректность данных.
|
||||
- `toBytes()` — сериализация обратно в байты (по умолчанию не реализована).
|
||||
|
||||
---
|
||||
|
||||
## 🔹 `HeaderBody`
|
||||
Тело первого блока цепочки (тип 0).
|
||||
Содержит логин, ID цепочки и публичный ключ пользователя.
|
||||
|
||||
**Публичные методы:**
|
||||
- `HeaderBody(byte[] body)` — парсинг из байтов.
|
||||
- `HeaderBody(long id, String login, …)` — создание нового заголовка.
|
||||
- `check()` — валидация логина и ключа.
|
||||
- `toBytes()` — сериализация в байты.
|
||||
- `toString()` — краткое описание полей.
|
||||
|
||||
---
|
||||
|
||||
## 🔹 `TextBody`
|
||||
Простое текстовое сообщение (тип 1).
|
||||
|
||||
**Публичные методы:**
|
||||
- `TextBody(byte[] body)` — парсинг из байтов.
|
||||
- `TextBody(String msg)` — создание нового текстового блока.
|
||||
- `check()` — проверка, что текст не пуст.
|
||||
- `toBytes()` — вернуть текст как UTF-8-массив.
|
||||
- `toString()` — короткое текстовое описание.
|
||||
|
||||
---
|
||||
|
||||
💡 Вся логика создания, подписи и проверки блоков построена вокруг этих классов.
|
||||
`BchBlockEntry` — контейнер блока,
|
||||
`HeaderBody` / `TextBody` — содержимое,
|
||||
`BodyRecordParser` — выбор нужного типа,
|
||||
`BchBlockValidator` — контроль целостности.
|
||||
@ -0,0 +1,19 @@
|
||||
package blockchain.body;
|
||||
|
||||
/**
|
||||
* Общий интерфейс для всех тел (body) блоков.
|
||||
*.
|
||||
* Каждый тип тела реализует:
|
||||
* - check() — проверку корректности данных
|
||||
* - toBytes() — опциональную сериализацию обратно в байты
|
||||
*/
|
||||
public interface BodyRecord {
|
||||
|
||||
/** Проверить корректность содержимого. */
|
||||
BodyRecord check();
|
||||
|
||||
/** (опционально) Сериализация тела обратно в байты. */
|
||||
default byte[] toBytes() {
|
||||
throw new UnsupportedOperationException("toBytes() не реализован");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package blockchain.body;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
|
||||
/**
|
||||
* BodyRecordParser_new — общий фабричный парсер body для нового формата.
|
||||
*
|
||||
* Правило совместимости (строгое):
|
||||
* - если (type, version) неизвестны → кидаем IllegalArgumentException
|
||||
*/
|
||||
public final class BodyRecordParser_new {
|
||||
|
||||
private BodyRecordParser_new() {}
|
||||
|
||||
public static BodyRecord_new parse(byte[] bodyBytes) {
|
||||
if (bodyBytes == null) throw new IllegalArgumentException("bodyBytes == null");
|
||||
if (bodyBytes.length < 4) throw new IllegalArgumentException("bodyBytes too short (<4)");
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
short type = bb.getShort();
|
||||
short ver = bb.getShort();
|
||||
|
||||
// Строгое сопоставление type+version → класс
|
||||
int key = ((type & 0xFFFF) << 16) | (ver & 0xFFFF);
|
||||
|
||||
return switch (key) {
|
||||
case 0x0000_0001 -> new HeaderBody_new(bodyBytes); // type=0, ver=1
|
||||
case 0x0001_0001 -> new TextBody_new(bodyBytes); // type=1, ver=1
|
||||
default -> throw new IllegalArgumentException(String.format(
|
||||
"Unknown body type/version: type=%d ver=%d (key=0x%08X)",
|
||||
(type & 0xFFFF), (ver & 0xFFFF), key
|
||||
));
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package blockchain.body;
|
||||
|
||||
/**
|
||||
* BodyRecord_new — общий контракт для всех типов body (тела блока).
|
||||
*
|
||||
* Идея:
|
||||
* - На каждый тип body (Header, Text, File, ...) — отдельный класс.
|
||||
* - Десериализация из байтов делается КОНСТРУКТОРОМ:
|
||||
* new XxxBody_new(byte[] bodyBytes)
|
||||
* (конструктор обязан распарсить байты или кинуть IllegalArgumentException).
|
||||
*
|
||||
* - Валидация делается методом check().
|
||||
* check() должен:
|
||||
* - вернуть this, если всё корректно
|
||||
* - кинуть IllegalArgumentException, если данные некорректны
|
||||
*
|
||||
* - Сериализация обратно в байты делается методом toBytes().
|
||||
*
|
||||
* - type() и version() — это идентификаторы формата body.
|
||||
* Они должны быть константами для класса (например TYPE=1, VERSION=1).
|
||||
*/
|
||||
public interface BodyRecord_new {
|
||||
|
||||
/** Код типа записи (совпадает с recordType в BchBlockEntry). */
|
||||
short type();
|
||||
|
||||
/** Версия формата записи (совпадает с recordTypeVersion в BchBlockEntry). */
|
||||
short version();
|
||||
|
||||
/** Проверить корректность содержимого и вернуть этот объект (или кинуть исключение). */
|
||||
BodyRecord_new check();
|
||||
|
||||
/**
|
||||
* Сериализовать тело записи в байты (ровно то, что кладётся в block.body).
|
||||
* Важно: НЕ включает общий заголовок блока (recordNumber/timestamp/type/version).
|
||||
*/
|
||||
byte[] toBytes();
|
||||
}
|
||||
@ -0,0 +1,191 @@
|
||||
package blockchain.body;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* HeaderBody — тело записи типа 0 (заглавие блокчейна)
|
||||
* ============================================================================
|
||||
*.
|
||||
* 🧩 Назначение:
|
||||
* Первый блок каждой пользовательской цепочки (.bch) — это "заголовок".
|
||||
* Он хранит базовую информацию о владельце, версии и публичном ключе.
|
||||
*.
|
||||
* Этот блок всегда имеет:
|
||||
* • recordType = 0
|
||||
* • recordNumber = 0
|
||||
* • recordTypeVersion = 1
|
||||
*.
|
||||
* ----------------------------------------------------------------------------
|
||||
* 🔹 Формат body (без общих 20 байт заголовка блока BchBlock)
|
||||
*.
|
||||
* | Смещение | Размер | Поле | Формат | Описание |
|
||||
* |-----------|--------|--------------------|---------|-----------|
|
||||
* | 0x00 | 8 | tag | ASCII | Статическая сигнатура "SHiNE001" |
|
||||
* | 0x08 | 8 | blockchainId | long BE | Уникальный идентификатор цепочки |
|
||||
* | 0x10 | 1 | userLoginLength=N | uint8 | Длина логина пользователя |
|
||||
* | 0x11 | N | userLogin | UTF-8 | Логин пользователя |
|
||||
* | 0x11+N | 4 | blockchainType | int BE | Зарезервировано (всегда 0) |
|
||||
* | 0x15+N | 4 | blockchainNumber | int BE | Зарезервировано (всегда 0) |
|
||||
* | 0x19+N | 2 | versionUserBch | short BE| Версия формата (всегда 1) |
|
||||
* | 0x1B+N | 8 | prevUserBchId | long BE | Зарезервировано (всегда 0) |
|
||||
* | 0x23+N | 32 | publicKey32 | raw | Публичный ключ (Ed25519, 32 байта) |
|
||||
*.
|
||||
* ----------------------------------------------------------------------------
|
||||
* 💡 Пример структуры в байтах:
|
||||
*.
|
||||
* 0000: 53 48 69 4E 45 30 30 31 "SHiNE001"
|
||||
* 0008: 00 00 00 00 01 23 45 67 blockchainId
|
||||
* 0010: 05 userLoginLength = 5
|
||||
* 0011: 41 69 64 61 72 userLogin = "Aidar"
|
||||
* 0016: 00 00 00 00 blockchainType = 0
|
||||
* 001A: 00 00 00 00 blockchainNumber = 0
|
||||
* 001E: 00 01 versionUserBch = 1
|
||||
* 0020: 00 00 00 00 00 00 00 00 prevUserBchId = 0
|
||||
* 0028: [32 байта публичного ключа]
|
||||
*.
|
||||
* ----------------------------------------------------------------------------
|
||||
* 📘 Замечания:
|
||||
* • Поля blockchainType, blockchainNumber, versionUserBch, prevUserBchId
|
||||
* зарезервированы для будущего расширения формата.
|
||||
* • На данный момент все они принимают фиксированные значения:
|
||||
* blockchainType = 0
|
||||
* blockchainNumber = 0
|
||||
* versionUserBch = 1
|
||||
* prevUserBchId = 0
|
||||
*.
|
||||
* ============================================================================
|
||||
*/
|
||||
public final class HeaderBody implements BodyRecord {
|
||||
|
||||
public static final short TYPE = 0;
|
||||
public static final String TAG = "SHiNE001";
|
||||
public static final int PUBKEY_LEN = 32;
|
||||
|
||||
public final String tag; // всегда "SHiNE001"
|
||||
public final long blockchainId;
|
||||
public final String userLogin; // UTF-8
|
||||
public final int blockchainType; // пока 0
|
||||
public final int blockchainNumber; // пока 0
|
||||
public final short versionUserBch; // пока 1
|
||||
public final long prevUserBchId; // пока 0
|
||||
public final byte[] publicKey32; // 32 байта
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Конструктор №1 — из массива байт (для парсинга существующего блока)
|
||||
// ------------------------------------------------------------
|
||||
public HeaderBody(byte[] body) {
|
||||
Objects.requireNonNull(body, "body == null");
|
||||
if (body.length < 8 + 8 + 1 + 2 + 4 + 4 + 8 + 32)
|
||||
throw new IllegalArgumentException("HeaderBody слишком короткое");
|
||||
|
||||
ByteBuffer buf = ByteBuffer.wrap(body).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
// [8] тег
|
||||
byte[] tagBytes = new byte[8];
|
||||
buf.get(tagBytes);
|
||||
String tag = new String(tagBytes, StandardCharsets.US_ASCII);
|
||||
if (!TAG.equals(tag))
|
||||
throw new IllegalArgumentException("Неверный тег: " + tag);
|
||||
this.tag = tag;
|
||||
|
||||
// [8] blockchainId
|
||||
this.blockchainId = buf.getLong();
|
||||
|
||||
// [1] длина логина
|
||||
int loginLen = Byte.toUnsignedInt(buf.get());
|
||||
if (loginLen == 0 || buf.remaining() < loginLen + 4 + 4 + 2 + 8 + 32)
|
||||
throw new IllegalArgumentException("Некорректная длина логина");
|
||||
|
||||
// [N] логин
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
buf.get(loginBytes);
|
||||
this.userLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
|
||||
// Остальные поля
|
||||
this.blockchainType = buf.getInt();
|
||||
this.blockchainNumber = buf.getInt();
|
||||
this.versionUserBch = buf.getShort();
|
||||
this.prevUserBchId = buf.getLong();
|
||||
|
||||
this.publicKey32 = new byte[PUBKEY_LEN];
|
||||
buf.get(this.publicKey32);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Конструктор №2 — из параметров (для создания нового заголовка)
|
||||
// ------------------------------------------------------------
|
||||
public HeaderBody(long blockchainId, String userLogin,
|
||||
int blockchainType, int blockchainNumber,
|
||||
short versionUserBch, long prevUserBchId,
|
||||
byte[] publicKey32) {
|
||||
|
||||
Objects.requireNonNull(userLogin, "userLogin == null");
|
||||
Objects.requireNonNull(publicKey32, "publicKey32 == null");
|
||||
|
||||
if (publicKey32.length != PUBKEY_LEN)
|
||||
throw new IllegalArgumentException("Публичный ключ должен состоять из 32 байт");
|
||||
|
||||
this.tag = TAG;
|
||||
this.blockchainId = blockchainId;
|
||||
this.userLogin = userLogin;
|
||||
this.blockchainType = blockchainType;
|
||||
this.blockchainNumber = blockchainNumber;
|
||||
this.versionUserBch = versionUserBch;
|
||||
this.prevUserBchId = prevUserBchId;
|
||||
this.publicKey32 = Arrays.copyOf(publicKey32, PUBKEY_LEN);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Проверка и сериализация
|
||||
// ------------------------------------------------------------
|
||||
@Override
|
||||
public HeaderBody check() {
|
||||
if (userLogin == null || userLogin.isBlank())
|
||||
throw new IllegalArgumentException("Логин не может быть пустым");
|
||||
if (!userLogin.matches("^[A-Za-z0-9_]+$"))
|
||||
throw new IllegalArgumentException("Логин может содержать только латиницу, цифры и _");
|
||||
if (publicKey32 == null || publicKey32.length != PUBKEY_LEN)
|
||||
throw new IllegalArgumentException("Публичный ключ должен быть 32 байта");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] loginUtf8 = userLogin.getBytes(StandardCharsets.UTF_8);
|
||||
if (loginUtf8.length > 255)
|
||||
throw new IllegalArgumentException("Логин слишком длинный (>255 байт)");
|
||||
|
||||
int cap = 8 + 8 + 1 + loginUtf8.length + 4 + 4 + 2 + 8 + 32;
|
||||
ByteBuffer buf = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
buf.put(TAG.getBytes(StandardCharsets.US_ASCII)); // [8]
|
||||
buf.putLong(blockchainId); // [8]
|
||||
buf.put((byte) loginUtf8.length); // [1]
|
||||
buf.put(loginUtf8); // [N]
|
||||
buf.putInt(blockchainType); // [4]
|
||||
buf.putInt(blockchainNumber); // [4]
|
||||
buf.putShort(versionUserBch); // [2]
|
||||
buf.putLong(prevUserBchId); // [8]
|
||||
buf.put(publicKey32); // [32]
|
||||
|
||||
return buf.array();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "HeaderBody{" +
|
||||
"id=" + blockchainId +
|
||||
", login='" + userLogin + '\'' +
|
||||
", type=" + blockchainType +
|
||||
", num=" + blockchainNumber +
|
||||
", ver=" + versionUserBch +
|
||||
", prev=" + prevUserBchId +
|
||||
", pubkey32=" + Arrays.toString(Arrays.copyOf(publicKey32, 4)) + "..." +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
package blockchain.body;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* HeaderBody_new — type=0, version=1.
|
||||
*
|
||||
* Полный bodyBytes:
|
||||
* [2] type=0
|
||||
* [2] version=1
|
||||
* [payload...]
|
||||
*
|
||||
* Payload (как у текущего HeaderBody):
|
||||
* [8] tag ASCII "SHiNE001"
|
||||
* [8] blockchainId (long BE)
|
||||
* [1] loginLength=N (uint8)
|
||||
* [N] userLogin UTF-8
|
||||
* [4] blockchainType (int BE) (резерв)
|
||||
* [4] blockchainNumber (int BE) (резерв)
|
||||
* [2] versionUserBch (short BE) (резерв)
|
||||
* [8] prevUserBchId (long BE) (резерв)
|
||||
* [32] publicKey32 (raw)
|
||||
*/
|
||||
public final class HeaderBody_new implements BodyRecord_new {
|
||||
|
||||
public static final short TYPE = 0;
|
||||
public static final short VER = 1;
|
||||
|
||||
public static final String TAG = "SHiNE001";
|
||||
public static final int PUBKEY_LEN = 32;
|
||||
|
||||
public final String tag; // "SHiNE001"
|
||||
public final long blockchainId;
|
||||
public final String userLogin;
|
||||
public final int blockchainType;
|
||||
public final int blockchainNumber;
|
||||
public final short versionUserBch;
|
||||
public final long prevUserBchId;
|
||||
public final byte[] publicKey32;
|
||||
|
||||
/**
|
||||
* Десериализация из полного bodyBytes (ВКЛЮЧАЯ первые 4 байта type/version).
|
||||
*/
|
||||
public HeaderBody_new(byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
if (bodyBytes.length < 4) throw new IllegalArgumentException("HeaderBody_new too short");
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
short type = bb.getShort();
|
||||
short ver = bb.getShort();
|
||||
if (type != TYPE || ver != VER)
|
||||
throw new IllegalArgumentException("Not HeaderBody_new: type=" + type + " ver=" + ver);
|
||||
|
||||
// Теперь bb стоит на payload
|
||||
if (bb.remaining() < 8 + 8 + 1 + 4 + 4 + 2 + 8 + 32)
|
||||
throw new IllegalArgumentException("Header payload too short");
|
||||
|
||||
byte[] tagBytes = new byte[8];
|
||||
bb.get(tagBytes);
|
||||
String t = new String(tagBytes, StandardCharsets.US_ASCII);
|
||||
if (!TAG.equals(t)) throw new IllegalArgumentException("Bad tag: " + t);
|
||||
this.tag = t;
|
||||
|
||||
this.blockchainId = bb.getLong();
|
||||
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0 || bb.remaining() < loginLen + 4 + 4 + 2 + 8 + 32)
|
||||
throw new IllegalArgumentException("Bad login length");
|
||||
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.userLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
|
||||
this.blockchainType = bb.getInt();
|
||||
this.blockchainNumber = bb.getInt();
|
||||
this.versionUserBch = bb.getShort();
|
||||
this.prevUserBchId = bb.getLong();
|
||||
|
||||
this.publicKey32 = new byte[PUBKEY_LEN];
|
||||
bb.get(this.publicKey32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание “вручную” (для генерации первого блока).
|
||||
*/
|
||||
public HeaderBody_new(long blockchainId,
|
||||
String userLogin,
|
||||
int blockchainType,
|
||||
int blockchainNumber,
|
||||
short versionUserBch,
|
||||
long prevUserBchId,
|
||||
byte[] publicKey32) {
|
||||
|
||||
Objects.requireNonNull(userLogin, "userLogin == null");
|
||||
Objects.requireNonNull(publicKey32, "publicKey32 == null");
|
||||
if (publicKey32.length != PUBKEY_LEN)
|
||||
throw new IllegalArgumentException("publicKey32 must be 32 bytes");
|
||||
|
||||
this.tag = TAG;
|
||||
this.blockchainId = blockchainId;
|
||||
this.userLogin = userLogin;
|
||||
this.blockchainType = blockchainType;
|
||||
this.blockchainNumber = blockchainNumber;
|
||||
this.versionUserBch = versionUserBch;
|
||||
this.prevUserBchId = prevUserBchId;
|
||||
this.publicKey32 = Arrays.copyOf(publicKey32, PUBKEY_LEN);
|
||||
}
|
||||
|
||||
@Override public short type() { return TYPE; }
|
||||
@Override public short version() { return VER; }
|
||||
|
||||
@Override
|
||||
public HeaderBody_new check() {
|
||||
if (userLogin == null || userLogin.isBlank())
|
||||
throw new IllegalArgumentException("Login is blank");
|
||||
if (!userLogin.matches("^[A-Za-z0-9_]+$"))
|
||||
throw new IllegalArgumentException("Login must match ^[A-Za-z0-9_]+$");
|
||||
if (publicKey32 == null || publicKey32.length != PUBKEY_LEN)
|
||||
throw new IllegalArgumentException("publicKey32 must be 32 bytes");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] loginUtf8 = userLogin.getBytes(StandardCharsets.UTF_8);
|
||||
if (loginUtf8.length > 255)
|
||||
throw new IllegalArgumentException("Login too long (>255 bytes)");
|
||||
|
||||
int payloadCap = 8 + 8 + 1 + loginUtf8.length + 4 + 4 + 2 + 8 + 32;
|
||||
int cap = 4 + payloadCap;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
// [type/version]
|
||||
bb.putShort(TYPE);
|
||||
bb.putShort(VER);
|
||||
|
||||
// payload
|
||||
bb.put(TAG.getBytes(StandardCharsets.US_ASCII)); // [8]
|
||||
bb.putLong(blockchainId); // [8]
|
||||
bb.put((byte) loginUtf8.length); // [1]
|
||||
bb.put(loginUtf8); // [N]
|
||||
bb.putInt(blockchainType); // [4]
|
||||
bb.putInt(blockchainNumber); // [4]
|
||||
bb.putShort(versionUserBch); // [2]
|
||||
bb.putLong(prevUserBchId); // [8]
|
||||
bb.put(publicKey32); // [32]
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package blockchain.body;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* TextBody — тело записи типа 1 (простое текстовое сообщение).
|
||||
*.
|
||||
* Формат body:
|
||||
* [N] message (UTF-8)
|
||||
*.
|
||||
* Тело полностью состоит из UTF-8-строки без каких-либо метаданных.
|
||||
*/
|
||||
public final class TextBody implements BodyRecord {
|
||||
|
||||
public static final short TYPE = 1;
|
||||
|
||||
public final String message;
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Конструктор №1 — из массива байт (для парсинга)
|
||||
// ------------------------------------------------------------
|
||||
public TextBody(byte[] body) {
|
||||
Objects.requireNonNull(body, "body == null");
|
||||
if (body.length == 0)
|
||||
throw new IllegalArgumentException("Тело текстового сообщения пустое");
|
||||
|
||||
// строгая проверка валидности UTF-8
|
||||
var decoder = StandardCharsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
var chars = decoder.decode(ByteBuffer.wrap(body));
|
||||
this.message = chars.toString();
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IllegalArgumentException("Тело не является корректным UTF-8", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Конструктор №2 — из строки (для создания нового сообщения)
|
||||
// ------------------------------------------------------------
|
||||
public TextBody(String message) {
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
if (message.isBlank())
|
||||
throw new IllegalArgumentException("Текст сообщения не может быть пустым");
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Проверка и сериализация
|
||||
// ------------------------------------------------------------
|
||||
@Override
|
||||
public TextBody check() {
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("Текст сообщения не может быть пустым");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
return message.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TextBody{" +
|
||||
"len=" + message.length() +
|
||||
", msg='" + (message.length() > 60 ? message.substring(0, 57) + "..." : message) + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package blockchain.body;
|
||||
|
||||
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.Objects;
|
||||
|
||||
/**
|
||||
* TextBody_new — type=1, version=1.
|
||||
*
|
||||
* Полный bodyBytes:
|
||||
* [2] type=1
|
||||
* [2] version=1
|
||||
* [payload...]
|
||||
*
|
||||
* Payload:
|
||||
* UTF-8 bytes (N>0)
|
||||
*/
|
||||
public final class TextBody_new implements BodyRecord_new {
|
||||
|
||||
public static final short TYPE = 1;
|
||||
public static final short VER = 1;
|
||||
|
||||
public final String message;
|
||||
|
||||
/** Десериализация из полного bodyBytes (включая type/version). */
|
||||
public TextBody_new(byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
if (bodyBytes.length < 5) // минимум: 4 байта type/ver + 1 байт текста
|
||||
throw new IllegalArgumentException("TextBody_new too short");
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
short type = bb.getShort();
|
||||
short ver = bb.getShort();
|
||||
if (type != TYPE || ver != VER)
|
||||
throw new IllegalArgumentException("Not TextBody_new: type=" + type + " ver=" + ver);
|
||||
|
||||
byte[] payload = new byte[bb.remaining()];
|
||||
bb.get(payload);
|
||||
|
||||
// строгая проверка UTF-8
|
||||
var decoder = StandardCharsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
this.message = decoder.decode(ByteBuffer.wrap(payload)).toString();
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IllegalArgumentException("Text payload is not valid UTF-8", e);
|
||||
}
|
||||
|
||||
if (this.message.isBlank())
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
}
|
||||
|
||||
/** Создание из строки. */
|
||||
public TextBody_new(String message) {
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
if (message.isBlank())
|
||||
throw new IllegalArgumentException("message is blank");
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
@Override public short type() { return TYPE; }
|
||||
@Override public short version() { return VER; }
|
||||
|
||||
@Override
|
||||
public TextBody_new check() {
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] msg = message.getBytes(StandardCharsets.UTF_8);
|
||||
if (msg.length == 0)
|
||||
throw new IllegalArgumentException("Text payload is empty");
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(4 + msg.length).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.putShort(TYPE);
|
||||
bb.putShort(VER);
|
||||
bb.put(msg);
|
||||
return bb.array();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,143 @@
|
||||
package blockchain_new;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* BchBlockEntry_new — универсальный блок нового формата.
|
||||
*
|
||||
* RAW (BigEndian):
|
||||
* [4] recordSize (int) = RAW + signature + hash
|
||||
* [4] recordNumber (int) глобальный номер блока
|
||||
* [8] timestamp (long) unix seconds
|
||||
* [2] line (short)
|
||||
* [4] lineNumber (int)
|
||||
* [N] bodyBytes (body, начинается с [type][version])
|
||||
*
|
||||
* TAIL:
|
||||
* [64] signature64 (Ed25519)
|
||||
* [32] hash32 (SHA-256)
|
||||
*/
|
||||
public final class BchBlockEntry_new {
|
||||
|
||||
public static final int SIGNATURE_LEN = 64;
|
||||
public static final int HASH_LEN = 32;
|
||||
|
||||
/** Размер фиксированного RAW-заголовка без body */
|
||||
public static final int RAW_HEADER_SIZE = 4 + 4 + 8 + 2 + 4;
|
||||
|
||||
// --- RAW ---
|
||||
public final int recordSize;
|
||||
public final int recordNumber;
|
||||
public final long timestamp;
|
||||
public final short line;
|
||||
public final int lineNumber;
|
||||
public final byte[] bodyBytes;
|
||||
|
||||
// --- TAIL ---
|
||||
private final byte[] signature64;
|
||||
private final byte[] hash32;
|
||||
|
||||
// --- cached ---
|
||||
private final byte[] fullBytes;
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Конструктор из байт ========================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
public BchBlockEntry_new(byte[] fullBytes) {
|
||||
Objects.requireNonNull(fullBytes, "fullBytes == null");
|
||||
if (fullBytes.length < RAW_HEADER_SIZE + SIGNATURE_LEN + HASH_LEN)
|
||||
throw new IllegalArgumentException("Block too short");
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
this.recordSize = bb.getInt();
|
||||
if (recordSize != fullBytes.length)
|
||||
throw new IllegalArgumentException("recordSize mismatch");
|
||||
|
||||
this.recordNumber = bb.getInt();
|
||||
this.timestamp = bb.getLong();
|
||||
this.line = bb.getShort();
|
||||
this.lineNumber = bb.getInt();
|
||||
|
||||
int bodyLen = recordSize - RAW_HEADER_SIZE - SIGNATURE_LEN - HASH_LEN;
|
||||
if (bodyLen <= 0)
|
||||
throw new IllegalArgumentException("Invalid body length");
|
||||
|
||||
this.bodyBytes = new byte[bodyLen];
|
||||
bb.get(this.bodyBytes);
|
||||
|
||||
this.signature64 = new byte[SIGNATURE_LEN];
|
||||
bb.get(this.signature64);
|
||||
|
||||
this.hash32 = new byte[HASH_LEN];
|
||||
bb.get(this.hash32);
|
||||
|
||||
this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length);
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Конструктор сборки ============================ */
|
||||
/* ===================================================================== */
|
||||
|
||||
public BchBlockEntry_new(int recordNumber,
|
||||
long timestamp,
|
||||
short line,
|
||||
int lineNumber,
|
||||
byte[] bodyBytes,
|
||||
byte[] signature64,
|
||||
byte[] hash32) {
|
||||
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
Objects.requireNonNull(signature64, "signature64 == null");
|
||||
Objects.requireNonNull(hash32, "hash32 == null");
|
||||
|
||||
if (signature64.length != SIGNATURE_LEN)
|
||||
throw new IllegalArgumentException("signature64 != 64");
|
||||
if (hash32.length != HASH_LEN)
|
||||
throw new IllegalArgumentException("hash32 != 32");
|
||||
|
||||
this.recordNumber = recordNumber;
|
||||
this.timestamp = timestamp;
|
||||
this.line = line;
|
||||
this.lineNumber = lineNumber;
|
||||
this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length);
|
||||
this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN);
|
||||
this.hash32 = Arrays.copyOf(hash32, HASH_LEN);
|
||||
|
||||
this.recordSize =
|
||||
RAW_HEADER_SIZE +
|
||||
bodyBytes.length +
|
||||
SIGNATURE_LEN +
|
||||
HASH_LEN;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(recordSize).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.putInt(recordSize);
|
||||
bb.putInt(recordNumber);
|
||||
bb.putLong(timestamp);
|
||||
bb.putShort(line);
|
||||
bb.putInt(lineNumber);
|
||||
bb.put(bodyBytes);
|
||||
bb.put(this.signature64);
|
||||
bb.put(this.hash32);
|
||||
|
||||
this.fullBytes = bb.array();
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
|
||||
public byte[] getSignature64() {
|
||||
return Arrays.copyOf(signature64, SIGNATURE_LEN);
|
||||
}
|
||||
|
||||
public byte[] getHash32() {
|
||||
return Arrays.copyOf(hash32, HASH_LEN);
|
||||
}
|
||||
|
||||
public byte[] toBytes() {
|
||||
return Arrays.copyOf(fullBytes, fullBytes.length);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
package blockchain_new;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class BchCryptoVerifier_new {
|
||||
|
||||
private static final byte[] DOMAIN = "SHiNE".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
private BchCryptoVerifier_new() {}
|
||||
|
||||
/**
|
||||
* preimage =
|
||||
* "SHiNE" +
|
||||
* [1] loginLen + loginBytes +
|
||||
* prevGlobalHash32 +
|
||||
* prevLineHash32 +
|
||||
* rawBytes
|
||||
*/
|
||||
public static byte[] buildPreimage(String userLogin,
|
||||
byte[] prevGlobalHash32,
|
||||
byte[] prevLineHash32,
|
||||
byte[] rawBytes) {
|
||||
|
||||
Objects.requireNonNull(userLogin, "userLogin == null");
|
||||
Objects.requireNonNull(prevGlobalHash32, "prevGlobalHash32 == null");
|
||||
Objects.requireNonNull(prevLineHash32, "prevLineHash32 == null");
|
||||
Objects.requireNonNull(rawBytes, "rawBytes == null");
|
||||
|
||||
if (prevGlobalHash32.length != 32 || prevLineHash32.length != 32)
|
||||
throw new IllegalArgumentException("hash len != 32");
|
||||
|
||||
byte[] loginBytes = userLogin.getBytes(StandardCharsets.UTF_8);
|
||||
if (loginBytes.length > 255)
|
||||
throw new IllegalArgumentException("login >255 bytes");
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(
|
||||
DOMAIN.length +
|
||||
1 + loginBytes.length +
|
||||
32 + 32 +
|
||||
rawBytes.length
|
||||
).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
bb.put(DOMAIN);
|
||||
bb.put((byte) loginBytes.length);
|
||||
bb.put(loginBytes);
|
||||
bb.put(prevGlobalHash32);
|
||||
bb.put(prevLineHash32);
|
||||
bb.put(rawBytes);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
public static byte[] sha256(byte[] data) {
|
||||
try {
|
||||
MessageDigest d = MessageDigest.getInstance("SHA-256");
|
||||
return d.digest(data);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: сюда подключается твой Ed25519 util
|
||||
public static boolean verifySignature(byte[] hash32,
|
||||
byte[] signature64,
|
||||
byte[] publicKey32) {
|
||||
// TODO: Ed25519.verify(hash32, signature64, publicKey32)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package utils.blockchain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* BchInfoEntry — данные об одной цепочке блокчейна.
|
||||
* Используется менеджером BchInfoManager.
|
||||
*/
|
||||
public final class BchInfoEntry {
|
||||
|
||||
@JsonProperty("blockchainId")
|
||||
public final long blockchainId;
|
||||
|
||||
@JsonProperty("userLogin")
|
||||
public final String userLogin;
|
||||
|
||||
@JsonProperty("publicKeyBase64")
|
||||
public final String publicKeyBase64;
|
||||
|
||||
@JsonProperty("blockchainSizeLimit")
|
||||
public final int blockchainSizeLimit;
|
||||
|
||||
@JsonProperty("blockchainSize")
|
||||
public final int blockchainSize;
|
||||
|
||||
@JsonProperty("lastBlockNumber")
|
||||
public final int lastBlockNumber;
|
||||
|
||||
@JsonProperty("lastBlockHash")
|
||||
public final String lastBlockHash;
|
||||
|
||||
@JsonCreator
|
||||
public BchInfoEntry(
|
||||
@JsonProperty("blockchainId") long blockchainId,
|
||||
@JsonProperty("userLogin") String userLogin,
|
||||
@JsonProperty("publicKeyBase64") String publicKeyBase64,
|
||||
@JsonProperty("blockchainSizeLimit") int blockchainSizeLimit,
|
||||
@JsonProperty("blockchainSize") int blockchainSize,
|
||||
@JsonProperty("lastBlockNumber") int lastBlockNumber,
|
||||
@JsonProperty("lastBlockHash") String lastBlockHash
|
||||
) {
|
||||
this.blockchainId = blockchainId;
|
||||
this.userLogin = userLogin == null ? "" : userLogin;
|
||||
this.publicKeyBase64 = publicKeyBase64;
|
||||
this.blockchainSizeLimit = blockchainSizeLimit;
|
||||
this.blockchainSize = blockchainSize;
|
||||
this.lastBlockNumber = lastBlockNumber;
|
||||
this.lastBlockHash = lastBlockHash == null ? "" : lastBlockHash;
|
||||
}
|
||||
|
||||
/** Публичный ключ в бинарном виде (32 байта) или null, если битый. */
|
||||
public byte[] getPublicKey32() {
|
||||
try {
|
||||
byte[] raw = Base64.getDecoder().decode(publicKeyBase64);
|
||||
return (raw != null && raw.length == 32) ? raw : null;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,178 @@
|
||||
package utils.blockchain;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* BchInfoManager — Singleton.
|
||||
*.
|
||||
* Держит в памяти информацию обо всех блокчейнах.
|
||||
* Сейчас читает и пишет JSON на диск (data/blockchain_info.json).
|
||||
* В будущем можно заменить на SQL без изменений в остальном коде.
|
||||
*/
|
||||
public final class BchInfoManager {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BchInfoManager.class);
|
||||
|
||||
private static final String FILE_NAME = "blockchain_info.json";
|
||||
private static final Path DATA_DIR = Paths.get("data");
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private static BchInfoManager instance;
|
||||
|
||||
/** blockchainId → запись о цепочке */
|
||||
private final Map<Long, BchInfoEntry> records = new LinkedHashMap<>();
|
||||
private final Path path = DATA_DIR.resolve(FILE_NAME);
|
||||
|
||||
private BchInfoManager() {
|
||||
ensureDataDir();
|
||||
load();
|
||||
}
|
||||
|
||||
public static synchronized BchInfoManager getInstance() {
|
||||
if (instance == null) instance = new BchInfoManager();
|
||||
return instance;
|
||||
}
|
||||
|
||||
// ========== API ==========
|
||||
|
||||
/** Создать новую цепочку (после первого HEADER-блока). */
|
||||
public synchronized void addBlockchain(long blockchainId,
|
||||
String userLogin,
|
||||
byte[] publicKey32,
|
||||
int blockchainSizeLimit) {
|
||||
if (publicKey32 == null || publicKey32.length != 32)
|
||||
throw new IllegalArgumentException("publicKey32 must be 32 bytes");
|
||||
if (records.containsKey(blockchainId))
|
||||
throw new IllegalArgumentException("blockchain already exists: " + blockchainId);
|
||||
|
||||
BchInfoEntry entry = new BchInfoEntry(
|
||||
blockchainId,
|
||||
userLogin,
|
||||
Base64.getEncoder().encodeToString(publicKey32),
|
||||
blockchainSizeLimit,
|
||||
0, 0, ""
|
||||
);
|
||||
|
||||
records.put(blockchainId, entry);
|
||||
log.info("Добавлен блокчейн id={} login='{}' (лимит {})", blockchainId, userLogin, blockchainSizeLimit);
|
||||
save();
|
||||
}
|
||||
|
||||
/** Обновить состояние после добавления нового блока. */
|
||||
public synchronized void updateBlockchainState(long blockchainId,
|
||||
int lastBlockNumber,
|
||||
String lastBlockHash,
|
||||
int blockchainSize) {
|
||||
BchInfoEntry prev = getEntryOrThrow(blockchainId);
|
||||
|
||||
BchInfoEntry updated = new BchInfoEntry(
|
||||
prev.blockchainId,
|
||||
prev.userLogin,
|
||||
prev.publicKeyBase64,
|
||||
prev.blockchainSizeLimit,
|
||||
blockchainSize,
|
||||
lastBlockNumber,
|
||||
lastBlockHash
|
||||
);
|
||||
|
||||
records.put(blockchainId, updated);
|
||||
log.info("Обновлено состояние id={} lastNum={} hash={} size={}",
|
||||
blockchainId, lastBlockNumber, lastBlockHash, blockchainSize);
|
||||
save();
|
||||
}
|
||||
|
||||
/** Получить полную информацию по blockchainId. */
|
||||
public synchronized BchInfoEntry getBchInfo(long blockchainId) {
|
||||
return records.get(blockchainId);
|
||||
}
|
||||
|
||||
/** Быстро проверить существование цепочки. */
|
||||
public synchronized boolean exists(long blockchainId) {
|
||||
return records.containsKey(blockchainId);
|
||||
}
|
||||
|
||||
/** id → userLogin (для поиска пользователей). */
|
||||
public synchronized Map<Long, String> getAllLoginsSnapshot() {
|
||||
Map<Long, String> copy = new LinkedHashMap<>(records.size());
|
||||
for (var e : records.entrySet()) {
|
||||
copy.put(e.getKey(), e.getValue().userLogin);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
// ========== private ==========
|
||||
|
||||
private BchInfoEntry getEntryOrThrow(long blockchainId) {
|
||||
BchInfoEntry e = records.get(blockchainId);
|
||||
if (e == null) throw new IllegalStateException("Блокчейн с id=" + blockchainId + " не найден.");
|
||||
return e;
|
||||
}
|
||||
|
||||
private void ensureDataDir() {
|
||||
try {
|
||||
if (!Files.exists(DATA_DIR)) {
|
||||
Files.createDirectories(DATA_DIR);
|
||||
log.info("Создана директория данных: {}", DATA_DIR);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось создать директорию хранения: " + DATA_DIR, e);
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void load() {
|
||||
if (!Files.exists(path)) {
|
||||
save();
|
||||
log.info("Создан JSON-хранилище: {}", path);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
byte[] json = Files.readAllBytes(path);
|
||||
if (json.length == 0) return;
|
||||
|
||||
Map<String, BchInfoEntry> map = MAPPER.readValue(
|
||||
json,
|
||||
MAPPER.getTypeFactory().constructMapType(Map.class, String.class, BchInfoEntry.class)
|
||||
);
|
||||
|
||||
records.clear();
|
||||
for (var e : map.entrySet()) {
|
||||
try {
|
||||
long id = Long.parseLong(e.getKey());
|
||||
records.put(id, e.getValue());
|
||||
} catch (NumberFormatException nfe) {
|
||||
log.warn("Пропущен некорректный ключ '{}' в JSON", e.getKey());
|
||||
}
|
||||
}
|
||||
log.info("Загружено {} записей из {}", records.size(), path);
|
||||
} catch (IOException e) {
|
||||
log.error("Ошибка загрузки {}", path, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Атомарная запись JSON через .tmp + ATOMIC_MOVE */
|
||||
private synchronized void save() {
|
||||
try {
|
||||
Map<String, BchInfoEntry> map = new LinkedHashMap<>();
|
||||
for (var e : records.entrySet())
|
||||
map.put(String.valueOf(e.getKey()), e.getValue());
|
||||
|
||||
byte[] json = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsBytes(map);
|
||||
|
||||
Path tmp = path.resolveSibling(FILE_NAME + ".tmp");
|
||||
Files.write(tmp, json, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
|
||||
log.debug("Сохранено {} записей в {}", records.size(), path);
|
||||
} catch (IOException e) {
|
||||
log.error("Ошибка сохранения {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
# utils.blockchain
|
||||
|
||||
Хранит состояние всех цепочек и их владельцев (логин, ключ, хэш, размер).
|
||||
|
||||
---
|
||||
|
||||
## Классы
|
||||
|
||||
### `BchInfoManager`
|
||||
Singleton-менеджер всех известных цепочек.
|
||||
Хранит карту `blockchainId → BchInfoEntry` в файле `data/blockchain_info.json`.
|
||||
Обновляется после каждого добавления блока.
|
||||
|
||||
Главные методы:
|
||||
- `getInstance()` — получить менеджер.
|
||||
- `addBlockchain(id, login, key32, limit)` — создать новую цепочку (первый блок).
|
||||
- `updateBlockchainState(id, num, hash, size)` — обновить состояние после блока.
|
||||
- `getBchInfo(id)` — получить всю запись (`BchInfoEntry`) по цепочке.
|
||||
- `getAllLoginsSnapshot()` — карта `id → login` для поиска.
|
||||
|
||||
---
|
||||
|
||||
### `BchInfoEntry`
|
||||
Сущность одной цепочки:
|
||||
- `blockchainId`
|
||||
- `userLogin`
|
||||
- `publicKeyBase64`
|
||||
- `blockchainSizeLimit`
|
||||
- `blockchainSize`
|
||||
- `lastBlockNumber`
|
||||
- `lastBlockHash`
|
||||
|
||||
Метод:
|
||||
- `getPublicKey32()` — вернуть ключ в бинарном виде.
|
||||
|
||||
---
|
||||
|
||||
## Итого
|
||||
- Менеджер хранит метаданные всех цепочек.
|
||||
- Сущность описывает одну цепочку.
|
||||
- Сейчас всё лежит в JSON, позже можно заменить на SQL без изменений кода.
|
||||
@ -0,0 +1,24 @@
|
||||
# TODO
|
||||
|
||||
1. Проверка перед созданием нового блокчейна
|
||||
|
||||
Сейчас:
|
||||
- Метод `BlockchainIdInfo.addBlockchain(...)` просто добавляет новую цепочку (blockchainId + userLogin + publicKey32) в локальное хранилище.
|
||||
- Любой первый блок (HEADER, recordNumber = 0) с валидной подписью автоматически создаёт новую запись.
|
||||
|
||||
Что нужно сделать в будущем:
|
||||
- Перед созданием новой цепочки проверять, что этот пользователь реально зарегистрирован в системе и имеет право открыть блокчейн.
|
||||
- Проверять, что `userLogin` и `publicKey32` совпадают с тем, что у нас уже привязано к этому пользователю.
|
||||
- Если пользователь не найден или ключ не совпадает — отказ, цепочку не создавать.
|
||||
|
||||
Идея: `handleAddBlock(...)` должен вызывать будущий Auth/Users сервис до `addBlockchain(...)`.
|
||||
|
||||
|
||||
2. Перенос хранения из файлов в базу SQL
|
||||
|
||||
Сейчас:
|
||||
- Блоки пишутся в файл `data/<blockchainId>.bch` через `FileStoreUtil`.
|
||||
- Метаданные по цепочке (логин, публичный ключ, последний номер блока, последний hash, размер и т.д.) хранятся в `data/blockchain_id_info.json` через `BlockchainIdInfo`.
|
||||
|
||||
Что нужно сделать:
|
||||
- Убрать файловое хранение и перейти на SQL.
|
||||
@ -0,0 +1,185 @@
|
||||
package utils.files;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* ===============================================================
|
||||
* FileStoreUtil — синглтон-утилита для записи/дозаписи/чтения файлов.
|
||||
* ---------------------------------------------------------------
|
||||
* Где хранит:
|
||||
* • Все файлы размещаются в внешней папке DATA_DIR = "data" (в корне запуска).
|
||||
* Папка создаётся автоматически при первом обращении.
|
||||
*.
|
||||
* Что умеет:
|
||||
* • newFile(String fileName, byte[] data)
|
||||
* - создаёт/переписывает файл с именем fileName и записывает data.
|
||||
* • addDataToFile(String fileName, byte[] data)
|
||||
* - дописывает data в конец файла (создаст файл, если его ещё нет).
|
||||
* • readAllDataFromFile(String fileName)
|
||||
* - читает весь файл целиком и возвращает содержимое в виде byte[].
|
||||
*.
|
||||
* Обёртки под «блокчейны»:
|
||||
* • newBlockchain(long blockchainId, byte[] data)
|
||||
* • addDataToBlockchain(long blockchainId, byte[] data)
|
||||
* • readAllDataFromBlockchain(long blockchainId)
|
||||
* - те же операции, но имя файла формируется из blockchainId и расширения ".bch".
|
||||
*.
|
||||
* Безопасность имён:
|
||||
* • Внутри утилиты есть простая валидация имени файла: запрещены разделители путей,
|
||||
* чтобы исключить выход из каталога data (path traversal).
|
||||
*.
|
||||
* Совместимость: Java 17.
|
||||
* ===============================================================
|
||||
*/
|
||||
public final class FileStoreUtil {
|
||||
|
||||
/** Базовая папка для хранения всех файлов (создаётся автоматически). */
|
||||
public static final String DATA_DIR_NAME = "data";
|
||||
/** Расширение файлов «блокчейнов». */
|
||||
public static final String BLOCKCHAIN_FILE_EXTENSION = ".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;
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// ОБЩИЕ МЕТОДЫ РАБОТЫ С ФАЙЛОМ
|
||||
// ===============================================================
|
||||
|
||||
/**
|
||||
* Создать/переписать файл и записать в него массив байт.
|
||||
* @param fileName имя файла (без каталогов)
|
||||
* @param data содержимое
|
||||
* @throws IllegalArgumentException при неверном имени или null-данных
|
||||
* @throws IllegalStateException при ошибках ввода/вывода
|
||||
*/
|
||||
public void newFile(String fileName, byte[] data) {
|
||||
Objects.requireNonNull(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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Дозаписать массив байт в конец файла (создаст файл, если отсутствует).
|
||||
* @param fileName имя файла (без каталогов)
|
||||
* @param data добавляемые данные
|
||||
* @throws IllegalArgumentException при неверном имени или null-данных
|
||||
* @throws IllegalStateException при ошибках ввода/вывода
|
||||
*/
|
||||
public void addDataToFile(String fileName, byte[] data) {
|
||||
Objects.requireNonNull(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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Прочитать весь файл в память и вернуть как byte[].
|
||||
* @param fileName имя файла (без каталогов)
|
||||
* @return содержимое файла
|
||||
* @throws IllegalStateException если файл не существует или ошибка ввода/вывода
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// ОБЁРТКИ ДЛЯ «БЛОКЧЕЙН-ФАЙЛОВ»
|
||||
// ===============================================================
|
||||
|
||||
/**
|
||||
* Обёртка над newFile: имя формируется из blockchainId + ".bch".
|
||||
*/
|
||||
public void newBlockchain(long blockchainId, byte[] data) {
|
||||
String fileName = buildBlockchainFileName(blockchainId);
|
||||
newFile(fileName, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обёртка над addDataToFile: имя формируется из blockchainId + ".bch".
|
||||
*/
|
||||
public void addDataToBlockchain(long blockchainId, byte[] data) {
|
||||
String fileName = buildBlockchainFileName(blockchainId);
|
||||
addDataToFile(fileName, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обёртка над readAllDataFromFile: имя формируется из blockchainId + ".bch".
|
||||
*/
|
||||
public byte[] readAllDataFromBlockchain(long blockchainId) {
|
||||
String fileName = buildBlockchainFileName(blockchainId);
|
||||
return readAllDataFromFile(fileName);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// ВСПОМОГАТЕЛЬНЫЕ
|
||||
// ===============================================================
|
||||
|
||||
private void ensureDataDirExists() {
|
||||
try {
|
||||
if (!Files.exists(dataDirPath)) {
|
||||
Files.createDirectories(dataDirPath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось создать директорию хранения: " + dataDirPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасно собрать путь внутри каталога data, запретив подстановку каталогов в имени файла.
|
||||
*/
|
||||
private Path resolveSafe(String fileName) {
|
||||
validateSimpleFileName(fileName);
|
||||
return dataDirPath.resolve(fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Простейшая валидация имени файла:
|
||||
* • запретить разделители путей и возврат на родительский каталог.
|
||||
*/
|
||||
private void validateSimpleFileName(String fileName) {
|
||||
Objects.requireNonNull(fileName, "Имя файла не должно быть null");
|
||||
if (fileName.isBlank()) {
|
||||
throw new IllegalArgumentException("Имя файла не должно быть пустым");
|
||||
}
|
||||
if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
|
||||
throw new IllegalArgumentException("Недопустимое имя файла: " + fileName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Построить имя «блокчейн-файла» из идентификатора и расширения .bch.
|
||||
* Пример: 12345 → "12345.bch"
|
||||
*/
|
||||
private String buildBlockchainFileName(long blockchainId) {
|
||||
return Long.toString(blockchainId) + BLOCKCHAIN_FILE_EXTENSION;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package utils.files;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* ===============================================================
|
||||
* FileStoreUtilSelfTest — запускаемый тест утилиты FileStoreUtil.
|
||||
* ---------------------------------------------------------------
|
||||
* Сценарий:
|
||||
* 1) Создаём «блокчейн-файл» для id=20251021 с начальными данными.
|
||||
* 2) Дозаписываем ещё порцию данных.
|
||||
* 3) Читаем целиком и печатаем длину + превью.
|
||||
*.
|
||||
* Ожидаемый итог:
|
||||
* • В папке "data" появится файл "20251021.bch"
|
||||
* • В консоли будет длина содержимого и небольшой превью-дамп.
|
||||
* ===============================================================
|
||||
*/
|
||||
public class FileStoreUtilSelfTest {
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println("=== FileStoreUtil self-test ===");
|
||||
|
||||
FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
|
||||
long blockchainId = 20251021L;
|
||||
|
||||
byte[] part1 = "Hello ".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] part2 = "Blockchain!".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
// 1) создаём новый файл для «блокчейна»
|
||||
fs.newBlockchain(blockchainId, part1);
|
||||
|
||||
// 2) дозаписываем данные
|
||||
fs.addDataToBlockchain(blockchainId, part2);
|
||||
|
||||
// 3) читаем всё содержимое и показываем превью
|
||||
byte[] all = fs.readAllDataFromBlockchain(blockchainId);
|
||||
System.out.println("Total bytes read: " + all.length);
|
||||
System.out.println("Preview (UTF-8): " + new String(all, StandardCharsets.UTF_8));
|
||||
|
||||
// небольшой hex-дамп первых 32 байт (для наглядности)
|
||||
System.out.println("Preview (HEX 32B): " + toHexPreview(all, 32));
|
||||
|
||||
System.out.println("✅ Self-test passed (файл: data/" + blockchainId + FileStoreUtil.BLOCKCHAIN_FILE_EXTENSION + ")");
|
||||
}
|
||||
|
||||
private static String toHexPreview(byte[] data, int max) {
|
||||
int n = Math.min(data.length, max);
|
||||
StringBuilder sb = new StringBuilder(n * 2);
|
||||
for (int i = 0; i < n; i++) {
|
||||
sb.append(String.format("%02X", data[i]));
|
||||
if (i + 1 < n) sb.append(' ');
|
||||
}
|
||||
if (data.length > n) sb.append(" ...");
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
27
shine-server-blockchain/src/main/java/utils/files/README.md
Normal file
27
shine-server-blockchain/src/main/java/utils/files/README.md
Normal file
@ -0,0 +1,27 @@
|
||||
# utils.files
|
||||
|
||||
Хранение блокчейнов в виде файлов в папке `data/`.
|
||||
|
||||
---
|
||||
|
||||
## FileStoreUtil
|
||||
Singleton для чтения и записи `.bch` файлов.
|
||||
|
||||
- `newBlockchain(id, data)` — создать новый файл `data/<id>.bch`
|
||||
- `addDataToBlockchain(id, data)` — добавить байты в конец файла
|
||||
- `readAllDataFromBlockchain(id)` — прочитать весь файл как массив байт
|
||||
|
||||
Каждый файл содержит последовательность полных блоков `[RAW][signature64][hash32]...`
|
||||
|
||||
---
|
||||
|
||||
## FileStoreUtilSelfTest
|
||||
Тест: создаёт, дописывает и читает файл, чтобы проверить корректность.
|
||||
|
||||
---
|
||||
|
||||
Пока используется как временное файловое хранилище.
|
||||
В будущем всё это уйдёт в SQL.
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
В будущем всё это уйдёт в SQL.
|
||||
|
||||
|
||||
|
||||
TODO И проработать вот эту проблему
|
||||
|
||||
есть вариант тто при врнезапном жёстком завершении приложения, может дописаться в конец файла только половина записи и это будет жёсткой ошибкой
|
||||
|
||||
14
shine-server-blockchain/src/main/java/utils/search/README.md
Normal file
14
shine-server-blockchain/src/main/java/utils/search/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# utils.search
|
||||
|
||||
Поиск пользователей по логину среди зарегистрированных блокчейнов.
|
||||
|
||||
---
|
||||
|
||||
## UserSearchService
|
||||
Singleton-сервис для поиска первых 5 логинов, содержащих подстроку (без учёта регистра).
|
||||
|
||||
Основные методы:
|
||||
- `searchFirst5(String query)` — вернуть список `Pair(id, login)`
|
||||
- `packPair(Pair p)` — упаковать пару в бинарный ответ `[8]id + [1]len + [len]login`
|
||||
|
||||
Используется сервером в операции `SEARCH_USERS (op=30)` для ответа клиенту.
|
||||
@ -0,0 +1,70 @@
|
||||
package utils.search;
|
||||
|
||||
|
||||
import utils.blockchain.BchInfoManager;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* UserSearchService — поиск первых 5 пользователей по подстроке логина (без учёта регистра).
|
||||
*/
|
||||
public final class UserSearchService {
|
||||
|
||||
private static final UserSearchService INSTANCE = new UserSearchService();
|
||||
private UserSearchService() {}
|
||||
public static UserSearchService getInstance() { return INSTANCE; }
|
||||
|
||||
/** Результат одной пары: id + исходный login (с родным регистром). */
|
||||
public static final class Pair {
|
||||
public final long blockchainId;
|
||||
public final String userLogin;
|
||||
public Pair(long blockchainId, String userLogin) {
|
||||
this.blockchainId = blockchainId;
|
||||
this.userLogin = userLogin;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти первые до 5 логинов, содержащих подстроку (case-insensitive).
|
||||
*/
|
||||
public List<Pair> searchFirst5(String query) {
|
||||
String q = (query == null ? "" : query).toLowerCase(Locale.ROOT).trim();
|
||||
List<Pair> out = new ArrayList<>(5);
|
||||
if (q.isEmpty()) return out;
|
||||
|
||||
// берём снапшот id→login
|
||||
Map<Long, String> all = BchInfoManager.getInstance().getAllLoginsSnapshot();
|
||||
|
||||
for (var e : all.entrySet()) {
|
||||
if (out.size() >= 5) break;
|
||||
String login = e.getValue() == null ? "" : e.getValue();
|
||||
if (login.toLowerCase(Locale.ROOT).contains(q)) {
|
||||
out.add(new Pair(e.getKey(), login));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Упаковка пары в байтовый формат ответа: [8] id + [1] L + [L] login UTF-8 (L<=255)
|
||||
public static byte[] packPair(Pair p) {
|
||||
byte[] loginUtf8 = (p.userLogin == null ? "" : p.userLogin).getBytes(StandardCharsets.UTF_8);
|
||||
int L = Math.min(loginUtf8.length, 255);
|
||||
byte[] b = new byte[8 + 1 + L];
|
||||
// beLong
|
||||
b[0]=(byte)((p.blockchainId>>>56)&0xFF);
|
||||
b[1]=(byte)((p.blockchainId>>>48)&0xFF);
|
||||
b[2]=(byte)((p.blockchainId>>>40)&0xFF);
|
||||
b[3]=(byte)((p.blockchainId>>>32)&0xFF);
|
||||
b[4]=(byte)((p.blockchainId>>>24)&0xFF);
|
||||
b[5]=(byte)((p.blockchainId>>>16)&0xFF);
|
||||
b[6]=(byte)((p.blockchainId>>>8 )&0xFF);
|
||||
b[7]=(byte)((p.blockchainId )&0xFF);
|
||||
b[8]=(byte)L;
|
||||
System.arraycopy(loginUtf8, 0, b, 9, L);
|
||||
return b;
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ import java.sql.Statement;
|
||||
* - active_sessions
|
||||
* - users_params
|
||||
* - ip_geo_cache
|
||||
* - blockchain_state (MVP: одна таблица, линии 0..7 внутри строки)
|
||||
*/
|
||||
public class DatabaseInitializer {
|
||||
|
||||
@ -151,6 +152,50 @@ public class DatabaseInitializer {
|
||||
CREATE INDEX IF NOT EXISTS idx_ip_geo_cache_updated_at
|
||||
ON ip_geo_cache (updated_at_ms);
|
||||
""");
|
||||
|
||||
// 5. blockchain_state (MVP)
|
||||
// TODO: позже можно вынести линии в отдельную таблицу blockchain_line_state и убрать "широкую" схему.
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS blockchain_state (
|
||||
blockchain_id INTEGER NOT NULL PRIMARY KEY,
|
||||
user_login TEXT NOT NULL,
|
||||
public_key_base64 TEXT NOT NULL,
|
||||
size_limit INTEGER NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
last_global_number INTEGER NOT NULL,
|
||||
last_global_hash TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
|
||||
-- Линии 0..7 (MVP: максимум 8 линий)
|
||||
line0_last_number INTEGER NOT NULL,
|
||||
line0_last_hash TEXT NOT NULL,
|
||||
line1_last_number INTEGER NOT NULL,
|
||||
line1_last_hash TEXT NOT NULL,
|
||||
line2_last_number INTEGER NOT NULL,
|
||||
line2_last_hash TEXT NOT NULL,
|
||||
line3_last_number INTEGER NOT NULL,
|
||||
line3_last_hash TEXT NOT NULL,
|
||||
line4_last_number INTEGER NOT NULL,
|
||||
line4_last_hash TEXT NOT NULL,
|
||||
line5_last_number INTEGER NOT NULL,
|
||||
line5_last_hash TEXT NOT NULL,
|
||||
line6_last_number INTEGER NOT NULL,
|
||||
line6_last_hash TEXT NOT NULL,
|
||||
line7_last_number INTEGER NOT NULL,
|
||||
line7_last_hash TEXT NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
// Индексы под быстрые проверки/поиск
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_blockchain_state_user_login
|
||||
ON blockchain_state (user_login);
|
||||
""");
|
||||
|
||||
st.executeUpdate("""
|
||||
CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at
|
||||
ON blockchain_state (updated_at_ms);
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,173 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
|
||||
import java.sql.*;
|
||||
|
||||
/**
|
||||
* DAO для таблицы blockchain_state.
|
||||
* 1 строка = 1 blockchainId, линии 0..7 в колонках.
|
||||
*/
|
||||
public final class BlockchainStateDAO {
|
||||
|
||||
private static volatile BlockchainStateDAO instance;
|
||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||
|
||||
private BlockchainStateDAO() {}
|
||||
|
||||
public static BlockchainStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (BlockchainStateDAO.class) {
|
||||
if (instance == null) instance = new BlockchainStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public BlockchainStateEntry getByBlockchainId(long blockchainId) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
blockchain_id,
|
||||
user_login,
|
||||
public_key_base64,
|
||||
size_limit,
|
||||
size_bytes,
|
||||
last_global_number,
|
||||
last_global_hash,
|
||||
line0_last_number, line0_last_hash,
|
||||
line1_last_number, line1_last_hash,
|
||||
line2_last_number, line2_last_hash,
|
||||
line3_last_number, line3_last_hash,
|
||||
line4_last_number, line4_last_hash,
|
||||
line5_last_number, line5_last_hash,
|
||||
line6_last_number, line6_last_hash,
|
||||
line7_last_number, line7_last_hash,
|
||||
updated_at_ms
|
||||
FROM blockchain_state
|
||||
WHERE blockchain_id = ?
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = db.getConnection().prepareStatement(sql)) {
|
||||
ps.setLong(1, blockchainId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UPSERT: если строки нет — вставка, если есть — обновление.
|
||||
* Это один вызов из кода, и один SQL.
|
||||
*/
|
||||
public void upsert(BlockchainStateEntry e) throws SQLException {
|
||||
long now = System.currentTimeMillis();
|
||||
if (e.getUpdatedAtMs() <= 0) e.setUpdatedAtMs(now);
|
||||
|
||||
String sql = """
|
||||
INSERT INTO blockchain_state (
|
||||
blockchain_id,
|
||||
user_login,
|
||||
public_key_base64,
|
||||
size_limit,
|
||||
size_bytes,
|
||||
last_global_number,
|
||||
last_global_hash,
|
||||
line0_last_number, line0_last_hash,
|
||||
line1_last_number, line1_last_hash,
|
||||
line2_last_number, line2_last_hash,
|
||||
line3_last_number, line3_last_hash,
|
||||
line4_last_number, line4_last_hash,
|
||||
line5_last_number, line5_last_hash,
|
||||
line6_last_number, line6_last_hash,
|
||||
line7_last_number, line7_last_hash,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
?,?,?,?,?,?,?,
|
||||
?,?,
|
||||
?,?,
|
||||
?,?,
|
||||
?,?,
|
||||
?,?,
|
||||
?,?,
|
||||
?,?,
|
||||
?,?,
|
||||
?
|
||||
)
|
||||
ON CONFLICT(blockchain_id)
|
||||
DO UPDATE SET
|
||||
user_login = excluded.user_login,
|
||||
public_key_base64 = excluded.public_key_base64,
|
||||
size_limit = excluded.size_limit,
|
||||
size_bytes = excluded.size_bytes,
|
||||
last_global_number = excluded.last_global_number,
|
||||
last_global_hash = excluded.last_global_hash,
|
||||
|
||||
line0_last_number = excluded.line0_last_number,
|
||||
line0_last_hash = excluded.line0_last_hash,
|
||||
line1_last_number = excluded.line1_last_number,
|
||||
line1_last_hash = excluded.line1_last_hash,
|
||||
line2_last_number = excluded.line2_last_number,
|
||||
line2_last_hash = excluded.line2_last_hash,
|
||||
line3_last_number = excluded.line3_last_number,
|
||||
line3_last_hash = excluded.line3_last_hash,
|
||||
line4_last_number = excluded.line4_last_number,
|
||||
line4_last_hash = excluded.line4_last_hash,
|
||||
line5_last_number = excluded.line5_last_number,
|
||||
line5_last_hash = excluded.line5_last_hash,
|
||||
line6_last_number = excluded.line6_last_number,
|
||||
line6_last_hash = excluded.line6_last_hash,
|
||||
line7_last_number = excluded.line7_last_number,
|
||||
line7_last_hash = excluded.line7_last_hash,
|
||||
|
||||
updated_at_ms = excluded.updated_at_ms
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = db.getConnection().prepareStatement(sql)) {
|
||||
|
||||
int i = 1;
|
||||
ps.setLong(i++, e.getBlockchainId());
|
||||
ps.setString(i++, nn(e.getUserLogin()));
|
||||
ps.setString(i++, nn(e.getPublicKeyBase64()));
|
||||
ps.setInt(i++, e.getSizeLimit());
|
||||
ps.setInt(i++, e.getSizeBytes());
|
||||
ps.setInt(i++, e.getLastGlobalNumber());
|
||||
ps.setString(i++, nn(e.getLastGlobalHash()));
|
||||
|
||||
for (int line = 0; line < 8; line++) {
|
||||
ps.setInt(i++, e.getLastLineNumber(line));
|
||||
ps.setString(i++, nn(e.getLastLineHash(line)));
|
||||
}
|
||||
|
||||
ps.setLong(i++, e.getUpdatedAtMs());
|
||||
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private BlockchainStateEntry mapRow(ResultSet rs) throws SQLException {
|
||||
BlockchainStateEntry e = new BlockchainStateEntry();
|
||||
e.setBlockchainId(rs.getLong("blockchain_id"));
|
||||
e.setUserLogin(rs.getString("user_login"));
|
||||
e.setPublicKeyBase64(rs.getString("public_key_base64"));
|
||||
|
||||
e.setSizeLimit(rs.getInt("size_limit"));
|
||||
e.setSizeBytes(rs.getInt("size_bytes"));
|
||||
|
||||
e.setLastGlobalNumber(rs.getInt("last_global_number"));
|
||||
e.setLastGlobalHash(rs.getString("last_global_hash"));
|
||||
|
||||
for (int line = 0; line < 8; line++) {
|
||||
e.setLastLineNumber(line, rs.getInt("line" + line + "_last_number"));
|
||||
e.setLastLineHash(line, rs.getString("line" + line + "_last_hash"));
|
||||
}
|
||||
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
return e;
|
||||
}
|
||||
|
||||
private static String nn(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
package shine.db.entities;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Агрегатная сущность текущего состояния блокчейна.
|
||||
* 1 строка = 1 blockchainId, плюс состояние линий 0..7.
|
||||
*/
|
||||
public final class BlockchainStateEntry {
|
||||
|
||||
private long blockchainId;
|
||||
|
||||
private String userLogin;
|
||||
private String publicKeyBase64;
|
||||
|
||||
private int sizeLimit;
|
||||
private int sizeBytes;
|
||||
|
||||
private int lastGlobalNumber;
|
||||
private String lastGlobalHash; // HEX(64) либо пустая строка для "нулевого"
|
||||
|
||||
/** line 0..7 */
|
||||
private final int[] lastLineNumbers = new int[8];
|
||||
/** line 0..7 */
|
||||
private final String[] lastLineHashes = new String[8];
|
||||
|
||||
private long updatedAtMs;
|
||||
|
||||
public BlockchainStateEntry() {
|
||||
// по умолчанию хэши пустые (как "0")
|
||||
for (int i = 0; i < 8; i++) lastLineHashes[i] = "";
|
||||
this.lastGlobalHash = "";
|
||||
}
|
||||
|
||||
// --- удобный конструктор (если хочешь) ---
|
||||
public BlockchainStateEntry(long blockchainId,
|
||||
String userLogin,
|
||||
String publicKeyBase64,
|
||||
int sizeLimit,
|
||||
int sizeBytes,
|
||||
int lastGlobalNumber,
|
||||
String lastGlobalHash,
|
||||
int[] lastLineNumbers,
|
||||
String[] lastLineHashes,
|
||||
long updatedAtMs) {
|
||||
this.blockchainId = blockchainId;
|
||||
this.userLogin = userLogin;
|
||||
this.publicKeyBase64 = publicKeyBase64;
|
||||
this.sizeLimit = sizeLimit;
|
||||
this.sizeBytes = sizeBytes;
|
||||
this.lastGlobalNumber = lastGlobalNumber;
|
||||
this.lastGlobalHash = lastGlobalHash == null ? "" : lastGlobalHash;
|
||||
|
||||
if (lastLineNumbers != null) {
|
||||
if (lastLineNumbers.length != 8) throw new IllegalArgumentException("lastLineNumbers must be len=8");
|
||||
System.arraycopy(lastLineNumbers, 0, this.lastLineNumbers, 0, 8);
|
||||
}
|
||||
if (lastLineHashes != null) {
|
||||
if (lastLineHashes.length != 8) throw new IllegalArgumentException("lastLineHashes must be len=8");
|
||||
for (int i = 0; i < 8; i++) this.lastLineHashes[i] = lastLineHashes[i] == null ? "" : lastLineHashes[i];
|
||||
} else {
|
||||
for (int i = 0; i < 8; i++) this.lastLineHashes[i] = "";
|
||||
}
|
||||
|
||||
this.updatedAtMs = updatedAtMs;
|
||||
}
|
||||
|
||||
// --- getters / setters ---
|
||||
|
||||
public long getBlockchainId() { return blockchainId; }
|
||||
public void setBlockchainId(long blockchainId) { this.blockchainId = blockchainId; }
|
||||
|
||||
public String getUserLogin() { return userLogin; }
|
||||
public void setUserLogin(String userLogin) { this.userLogin = userLogin; }
|
||||
|
||||
public String getPublicKeyBase64() { return publicKeyBase64; }
|
||||
public void setPublicKeyBase64(String publicKeyBase64) { this.publicKeyBase64 = publicKeyBase64; }
|
||||
|
||||
public int getSizeLimit() { return sizeLimit; }
|
||||
public void setSizeLimit(int sizeLimit) { this.sizeLimit = sizeLimit; }
|
||||
|
||||
public int getSizeBytes() { return sizeBytes; }
|
||||
public void setSizeBytes(int sizeBytes) { this.sizeBytes = sizeBytes; }
|
||||
|
||||
public int getLastGlobalNumber() { return lastGlobalNumber; }
|
||||
public void setLastGlobalNumber(int lastGlobalNumber) { this.lastGlobalNumber = lastGlobalNumber; }
|
||||
|
||||
public String getLastGlobalHash() { return lastGlobalHash; }
|
||||
public void setLastGlobalHash(String lastGlobalHash) { this.lastGlobalHash = lastGlobalHash == null ? "" : lastGlobalHash; }
|
||||
|
||||
/** line in [0..7] */
|
||||
public int getLastLineNumber(int line) {
|
||||
checkLine(line);
|
||||
return lastLineNumbers[line];
|
||||
}
|
||||
public void setLastLineNumber(int line, int value) {
|
||||
checkLine(line);
|
||||
lastLineNumbers[line] = value;
|
||||
}
|
||||
|
||||
/** line in [0..7] */
|
||||
public String getLastLineHash(int line) {
|
||||
checkLine(line);
|
||||
return lastLineHashes[line];
|
||||
}
|
||||
public void setLastLineHash(int line, String value) {
|
||||
checkLine(line);
|
||||
lastLineHashes[line] = value == null ? "" : value;
|
||||
}
|
||||
|
||||
public int[] getLastLineNumbersCopy() {
|
||||
return Arrays.copyOf(lastLineNumbers, 8);
|
||||
}
|
||||
public String[] getLastLineHashesCopy() {
|
||||
return Arrays.copyOf(lastLineHashes, 8);
|
||||
}
|
||||
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||
|
||||
private static void checkLine(int line) {
|
||||
if (line < 0 || line > 7) throw new IllegalArgumentException("line must be 0..7");
|
||||
}
|
||||
}
|
||||
160
src/TODO.txt
160
src/TODO.txt
@ -1 +1,159 @@
|
||||
Сделать потом что бы на каждую сессию стояло время последнего подключения и откуда оно было - но видимо это уже в свойства запихивать надо.
|
||||
Конспект: что обсуждали и где остановились
|
||||
1) Новый формат блока (идея)
|
||||
|
||||
Мы решили усложнить структуру блока, добавив линии (line) и номер сообщения в линии (lineNumber), чтобы блоки могли принадлежать разным потокам внутри одной цепочки.
|
||||
|
||||
line — short (2 байта), диапазон для MVP: 0..7 (8 линий).
|
||||
lineNumber — int (4 байта).
|
||||
|
||||
Логика:
|
||||
|
||||
Есть общий порядок блоков (глобальная цепочка по recordNumber), он всегда последовательный.
|
||||
|
||||
Параллельно есть “линии”: у каждой линии свой последовательный lineNumber.
|
||||
|
||||
Блок №0 (Header) всегда line=0, lineNumber=0.
|
||||
|
||||
Для первого блока в каждой линии prevLineHash32 = 32 нуля.
|
||||
|
||||
2) Два предыдущих хэша (для валидации связности)
|
||||
|
||||
Добавляем:
|
||||
|
||||
prevGlobalHash32 — хэш предыдущего блока по общему порядку.
|
||||
|
||||
prevLineHash32 — хэш предыдущего блока в этой линии.
|
||||
|
||||
Важно: prevLineHash32 не храним в файле блокчейна. Сервер при проверке получает его, “прокручивая” цепочку с начала (а при отдаче клиенту линии — передаём отдельно).
|
||||
|
||||
3) Новый preimage, хэш и подпись
|
||||
|
||||
Решили изменить криптосхему:
|
||||
|
||||
preimage:
|
||||
|
||||
константа "SHiNE"
|
||||
|
||||
[1 байт длины логина] + loginBytes(UTF-8)
|
||||
|
||||
prevGlobalHash32 (32)
|
||||
|
||||
prevLineHash32 (32)
|
||||
|
||||
rawBytes
|
||||
|
||||
hash32 = SHA-256(preimage)
|
||||
|
||||
signature64 = Ed25519.sign(hash32, privateKey)
|
||||
(то есть подписываем хэш, а не весь preimage)
|
||||
|
||||
4) recordType и recordTypeVersion
|
||||
|
||||
Мы хотели убрать recordType и recordTypeVersion из общего заголовка блока и перенести их в “область body”, чтобы каждая реализация body сама добавляла/читала первые 4 байта:
|
||||
|
||||
recordType (2 байта)
|
||||
|
||||
recordTypeVersion (2 байта)
|
||||
|
||||
То есть body при сериализации выглядит так:
|
||||
[type(2)][version(2)][payload...]
|
||||
|
||||
А общий блок остаётся “универсальным”.
|
||||
|
||||
5) Правило совместимости версий
|
||||
|
||||
Для MVP решили строго:
|
||||
|
||||
если (type,version) известны — парсим,
|
||||
|
||||
иначе — кидаем ошибку.
|
||||
Без fallback “если нет v2, бери v1”.
|
||||
|
||||
6) Процесс приёма блока по сети (серверный pipeline)
|
||||
|
||||
Обсуждали последовательность:
|
||||
|
||||
проверить, что номер блока подходит (ожидаемый recordNumber)
|
||||
|
||||
проверить криптографию (хэш/подпись)
|
||||
|
||||
распарсить body в объект
|
||||
|
||||
вызвать check() у объекта body (структурная валидация)
|
||||
|
||||
TODO: добавить запись в БД (для быстрых поисков/индексов)
|
||||
|
||||
дописать блок в файл
|
||||
|
||||
TODO: продумать блокировки/конкуренцию (чтобы два потока не дописали один и тот же блок)
|
||||
|
||||
Мы решили: пока не внедряем сложные флаги “dirty” и логику восстановления при падениях — ставим большой TODO.
|
||||
|
||||
7) БД: решили сделать MVP проще
|
||||
|
||||
Изначально обсуждали 2 таблицы:
|
||||
|
||||
blockchain_state
|
||||
|
||||
blockchain_line_state
|
||||
|
||||
Но для прототипа решили:
|
||||
|
||||
одна таблица, максимум 8 линий (0..7), колонки для каждой линии (lineX_last_number, lineX_last_hash и т.п.)
|
||||
|
||||
одна сущность-агрегат (названия с суффиксом Entry)
|
||||
|
||||
одно DAO, один запрос на чтение/сохранение текущего состояния.
|
||||
|
||||
8) Требования по именам
|
||||
|
||||
Сущности называем *Entry (например BlockchainStateEntry).
|
||||
|
||||
Больше не используем суффикс _new или New в названиях для DAO/Entry (для дальнейшего кода).
|
||||
|
||||
(Ранее “_new” использовали для классов формата блоков — но на этапе БД решили не добавлять.)
|
||||
|
||||
9) Что уже есть в проекте
|
||||
|
||||
Есть модуль SQLite:
|
||||
|
||||
DatabaseInitializer создаёт таблицы: solana_users, active_sessions, users_params, ip_geo_cache.
|
||||
|
||||
DAO: ActiveSessionsDAO, SolanaUsersDAO, UserParamsDAO, IpGeoCacheDAO.
|
||||
|
||||
Entities: ActiveSession, SolanaUser, UserParam, IpGeoCacheEntry.
|
||||
|
||||
SqliteDbController — синглтон с Connection, автосоздание БД если файла нет.
|
||||
|
||||
10) Где остановились
|
||||
|
||||
Остановились на том, что пользователь прислал актуальный DatabaseInitializer и попросил:
|
||||
|
||||
дальше добавить новую таблицу под состояние блокчейнов (MVP: одна таблица с линиями 0..7),
|
||||
|
||||
написать:
|
||||
|
||||
SQL создания таблицы в DatabaseInitializer
|
||||
|
||||
BlockchainStateEntry
|
||||
|
||||
BlockchainStateDAO
|
||||
|
||||
(и перечислить, что ещё нужно будет для интеграции в обработчик добавления блока — но туда пока ставим TODO)
|
||||
|
||||
Если хочешь, в следующий раз ты можешь просто написать так:
|
||||
|
||||
“Продолжаем с конспекта ниже” (вставляешь этот текст)
|
||||
|
||||
“Вот текущий код файлов: …” (скидываешь актуальные версии)
|
||||
И я сразу продолжу: добавлю таблицу + Entry + DAO + методы чтения/апдейта одним запросом (UPSERT), и отмечу точки интеграции в обработчик добавления блока.
|
||||
|
||||
И да — спасибо 🙂 Но “больше тебя” я не делаю: ты задаёшь направление и держишь картину системы в голове, а я ускоряю сборку деталей и помогаю не наделать скрытых дыр. Это хорошая связка.
|
||||
|
||||
Что бы я предложил улучшить в следующий шаг (без усложнений):
|
||||
|
||||
сразу решить формат хранения хэшей в SQLite: BLOB(32) или TEXT hex(64) (я бы выбрал BLOB — быстрее и меньше).
|
||||
|
||||
выбрать один стиль UPSERT: INSERT ... ON CONFLICT(blockchain_id) DO UPDATE SET ...
|
||||
|
||||
добавить индекс по user_login (для поиска), если он будет нужен.
|
||||
Loading…
Reference in New Issue
Block a user