From ab44cc5282eef003f7233d0f5d1082f05520e8e121d0f92772444a5503190899 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 16 Dec 2025 17:56:36 +0300 Subject: [PATCH] =?UTF-8?q?16=2012=2025=20=D0=9F=D1=80=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D0=B6=D1=83=D1=82=D0=BE=D1=87=D0=BD=D0=B0=D1=8F=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D1=8F=20=D0=B8=20=D0=A2=D0=A3=D0=94=D0=A3=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=87=D1=91=D0=BC=20=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B8=D0=BB=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- shine-server-blockchain/build.gradle | 2 + shine-server-blockchain/concat_to_file.sh | 16 ++ .../main/java/blockchain/BchBlockEntry.java | 217 ++++++++++++++++++ .../java/blockchain/BchBlockValidator.java | 103 +++++++++ .../java/blockchain/BodyRecordParser.java | 106 +++++++++ .../src/main/java/blockchain/README.md | 75 ++++++ .../main/java/blockchain/body/BodyRecord.java | 19 ++ .../blockchain/body/BodyRecordParser_new.java | 36 +++ .../java/blockchain/body/BodyRecord_new.java | 38 +++ .../main/java/blockchain/body/HeaderBody.java | 191 +++++++++++++++ .../java/blockchain/body/HeaderBody_new.java | 155 +++++++++++++ .../main/java/blockchain/body/TextBody.java | 77 +++++++ .../java/blockchain/body/TextBody_new.java | 89 +++++++ .../blockchain_new/BchBlockEntry_new.java | 143 ++++++++++++ .../blockchain_new/BchCryptoVerifier_new.java | 73 ++++++ .../java/utils/blockchain/BchInfoEntry.java | 62 +++++ .../java/utils/blockchain/BchInfoManager.java | 178 ++++++++++++++ .../src/main/java/utils/blockchain/README.md | 41 ++++ .../src/main/java/utils/blockchain/TODO.md | 24 ++ .../main/java/utils/files/FileStoreUtil.java | 185 +++++++++++++++ .../utils/files/FileStoreUtilSelfTest.java | 59 +++++ .../src/main/java/utils/files/README.md | 27 +++ .../src/main/java/utils/files/TODO.txt | 8 + .../src/main/java/utils/search/README.md | 14 ++ .../java/utils/search/UserSearchService.java | 70 ++++++ .../java/shine/db/DatabaseInitializer.java | 47 +++- .../java/shine/db/dao/BlockchainStateDAO.java | 173 ++++++++++++++ .../db/entities/BlockchainStateEntry.java | 124 ++++++++++ src/TODO.txt | 160 ++++++++++++- 30 files changed, 2511 insertions(+), 3 deletions(-) create mode 100755 shine-server-blockchain/concat_to_file.sh create mode 100644 shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java create mode 100644 shine-server-blockchain/src/main/java/blockchain/BchBlockValidator.java create mode 100644 shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java create mode 100644 shine-server-blockchain/src/main/java/blockchain/README.md create mode 100644 shine-server-blockchain/src/main/java/blockchain/body/BodyRecord.java create mode 100644 shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser_new.java create mode 100644 shine-server-blockchain/src/main/java/blockchain/body/BodyRecord_new.java create mode 100644 shine-server-blockchain/src/main/java/blockchain/body/HeaderBody.java create mode 100644 shine-server-blockchain/src/main/java/blockchain/body/HeaderBody_new.java create mode 100644 shine-server-blockchain/src/main/java/blockchain/body/TextBody.java create mode 100644 shine-server-blockchain/src/main/java/blockchain/body/TextBody_new.java create mode 100644 shine-server-blockchain/src/main/java/blockchain_new/BchBlockEntry_new.java create mode 100644 shine-server-blockchain/src/main/java/blockchain_new/BchCryptoVerifier_new.java create mode 100644 shine-server-blockchain/src/main/java/utils/blockchain/BchInfoEntry.java create mode 100644 shine-server-blockchain/src/main/java/utils/blockchain/BchInfoManager.java create mode 100644 shine-server-blockchain/src/main/java/utils/blockchain/README.md create mode 100644 shine-server-blockchain/src/main/java/utils/blockchain/TODO.md create mode 100644 shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java create mode 100644 shine-server-blockchain/src/main/java/utils/files/FileStoreUtilSelfTest.java create mode 100644 shine-server-blockchain/src/main/java/utils/files/README.md create mode 100644 shine-server-blockchain/src/main/java/utils/files/TODO.txt create mode 100644 shine-server-blockchain/src/main/java/utils/search/README.md create mode 100644 shine-server-blockchain/src/main/java/utils/search/UserSearchService.java create mode 100644 shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java create mode 100644 shine-server-db/src/main/java/shine/db/entities/BlockchainStateEntry.java diff --git a/build.gradle b/build.gradle index 0b9a85d..6a76791 100644 --- a/build.gradle +++ b/build.gradle @@ -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 diff --git a/shine-server-blockchain/build.gradle b/shine-server-blockchain/build.gradle index 0bf3de5..da41301 100644 --- a/shine-server-blockchain/build.gradle +++ b/shine-server-blockchain/build.gradle @@ -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 { diff --git a/shine-server-blockchain/concat_to_file.sh b/shine-server-blockchain/concat_to_file.sh new file mode 100755 index 0000000..901712c --- /dev/null +++ b/shine-server-blockchain/concat_to_file.sh @@ -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" + diff --git a/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java b/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java new file mode 100644 index 0000000..e629f23 --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java @@ -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) + ); + } +} diff --git a/shine-server-blockchain/src/main/java/blockchain/BchBlockValidator.java b/shine-server-blockchain/src/main/java/blockchain/BchBlockValidator.java new file mode 100644 index 0000000..515c246 --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/BchBlockValidator.java @@ -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; + } +} diff --git a/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java b/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java new file mode 100644 index 0000000..64d8f00 --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java @@ -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)); + } + } +} diff --git a/shine-server-blockchain/src/main/java/blockchain/README.md b/shine-server-blockchain/src/main/java/blockchain/README.md new file mode 100644 index 0000000..365a091 --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/README.md @@ -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` — контроль целостности. diff --git a/shine-server-blockchain/src/main/java/blockchain/body/BodyRecord.java b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecord.java new file mode 100644 index 0000000..ac8125f --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecord.java @@ -0,0 +1,19 @@ +package blockchain.body; + +/** + * Общий интерфейс для всех тел (body) блоков. + *. + * Каждый тип тела реализует: + * - check() — проверку корректности данных + * - toBytes() — опциональную сериализацию обратно в байты + */ +public interface BodyRecord { + + /** Проверить корректность содержимого. */ + BodyRecord check(); + + /** (опционально) Сериализация тела обратно в байты. */ + default byte[] toBytes() { + throw new UnsupportedOperationException("toBytes() не реализован"); + } +} diff --git a/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser_new.java b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser_new.java new file mode 100644 index 0000000..f5e847d --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecordParser_new.java @@ -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 + )); + }; + } +} \ No newline at end of file diff --git a/shine-server-blockchain/src/main/java/blockchain/body/BodyRecord_new.java b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecord_new.java new file mode 100644 index 0000000..68f3978 --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/body/BodyRecord_new.java @@ -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(); +} diff --git a/shine-server-blockchain/src/main/java/blockchain/body/HeaderBody.java b/shine-server-blockchain/src/main/java/blockchain/body/HeaderBody.java new file mode 100644 index 0000000..c6c51ba --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/body/HeaderBody.java @@ -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)) + "..." + + '}'; + } +} diff --git a/shine-server-blockchain/src/main/java/blockchain/body/HeaderBody_new.java b/shine-server-blockchain/src/main/java/blockchain/body/HeaderBody_new.java new file mode 100644 index 0000000..18e99e0 --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/body/HeaderBody_new.java @@ -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(); + } +} \ No newline at end of file diff --git a/shine-server-blockchain/src/main/java/blockchain/body/TextBody.java b/shine-server-blockchain/src/main/java/blockchain/body/TextBody.java new file mode 100644 index 0000000..2d06a45 --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/body/TextBody.java @@ -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) + '\'' + + '}'; + } +} diff --git a/shine-server-blockchain/src/main/java/blockchain/body/TextBody_new.java b/shine-server-blockchain/src/main/java/blockchain/body/TextBody_new.java new file mode 100644 index 0000000..2524f9c --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain/body/TextBody_new.java @@ -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(); + } +} \ No newline at end of file diff --git a/shine-server-blockchain/src/main/java/blockchain_new/BchBlockEntry_new.java b/shine-server-blockchain/src/main/java/blockchain_new/BchBlockEntry_new.java new file mode 100644 index 0000000..a69bbf2 --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain_new/BchBlockEntry_new.java @@ -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); + } +} \ No newline at end of file diff --git a/shine-server-blockchain/src/main/java/blockchain_new/BchCryptoVerifier_new.java b/shine-server-blockchain/src/main/java/blockchain_new/BchCryptoVerifier_new.java new file mode 100644 index 0000000..c48c843 --- /dev/null +++ b/shine-server-blockchain/src/main/java/blockchain_new/BchCryptoVerifier_new.java @@ -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; + } +} \ No newline at end of file diff --git a/shine-server-blockchain/src/main/java/utils/blockchain/BchInfoEntry.java b/shine-server-blockchain/src/main/java/utils/blockchain/BchInfoEntry.java new file mode 100644 index 0000000..c48a988 --- /dev/null +++ b/shine-server-blockchain/src/main/java/utils/blockchain/BchInfoEntry.java @@ -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; + } + } +} diff --git a/shine-server-blockchain/src/main/java/utils/blockchain/BchInfoManager.java b/shine-server-blockchain/src/main/java/utils/blockchain/BchInfoManager.java new file mode 100644 index 0000000..1772c53 --- /dev/null +++ b/shine-server-blockchain/src/main/java/utils/blockchain/BchInfoManager.java @@ -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 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 getAllLoginsSnapshot() { + Map 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 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 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); + } + } +} diff --git a/shine-server-blockchain/src/main/java/utils/blockchain/README.md b/shine-server-blockchain/src/main/java/utils/blockchain/README.md new file mode 100644 index 0000000..effe28f --- /dev/null +++ b/shine-server-blockchain/src/main/java/utils/blockchain/README.md @@ -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 без изменений кода. diff --git a/shine-server-blockchain/src/main/java/utils/blockchain/TODO.md b/shine-server-blockchain/src/main/java/utils/blockchain/TODO.md new file mode 100644 index 0000000..eddaef0 --- /dev/null +++ b/shine-server-blockchain/src/main/java/utils/blockchain/TODO.md @@ -0,0 +1,24 @@ +# TODO + +1. Проверка перед созданием нового блокчейна + +Сейчас: +- Метод `BlockchainIdInfo.addBlockchain(...)` просто добавляет новую цепочку (blockchainId + userLogin + publicKey32) в локальное хранилище. +- Любой первый блок (HEADER, recordNumber = 0) с валидной подписью автоматически создаёт новую запись. + +Что нужно сделать в будущем: +- Перед созданием новой цепочки проверять, что этот пользователь реально зарегистрирован в системе и имеет право открыть блокчейн. +- Проверять, что `userLogin` и `publicKey32` совпадают с тем, что у нас уже привязано к этому пользователю. +- Если пользователь не найден или ключ не совпадает — отказ, цепочку не создавать. + +Идея: `handleAddBlock(...)` должен вызывать будущий Auth/Users сервис до `addBlockchain(...)`. + + +2. Перенос хранения из файлов в базу SQL + +Сейчас: +- Блоки пишутся в файл `data/.bch` через `FileStoreUtil`. +- Метаданные по цепочке (логин, публичный ключ, последний номер блока, последний hash, размер и т.д.) хранятся в `data/blockchain_id_info.json` через `BlockchainIdInfo`. + +Что нужно сделать: +- Убрать файловое хранение и перейти на SQL. \ No newline at end of file diff --git a/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java b/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java new file mode 100644 index 0000000..51ebb71 --- /dev/null +++ b/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java @@ -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; + } +} diff --git a/shine-server-blockchain/src/main/java/utils/files/FileStoreUtilSelfTest.java b/shine-server-blockchain/src/main/java/utils/files/FileStoreUtilSelfTest.java new file mode 100644 index 0000000..928acf9 --- /dev/null +++ b/shine-server-blockchain/src/main/java/utils/files/FileStoreUtilSelfTest.java @@ -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(); + } +} diff --git a/shine-server-blockchain/src/main/java/utils/files/README.md b/shine-server-blockchain/src/main/java/utils/files/README.md new file mode 100644 index 0000000..7d5acf5 --- /dev/null +++ b/shine-server-blockchain/src/main/java/utils/files/README.md @@ -0,0 +1,27 @@ +# utils.files + +Хранение блокчейнов в виде файлов в папке `data/`. + +--- + +## FileStoreUtil +Singleton для чтения и записи `.bch` файлов. + +- `newBlockchain(id, data)` — создать новый файл `data/.bch` +- `addDataToBlockchain(id, data)` — добавить байты в конец файла +- `readAllDataFromBlockchain(id)` — прочитать весь файл как массив байт + +Каждый файл содержит последовательность полных блоков `[RAW][signature64][hash32]...` + +--- + +## FileStoreUtilSelfTest +Тест: создаёт, дописывает и читает файл, чтобы проверить корректность. + +--- + +Пока используется как временное файловое хранилище. +В будущем всё это уйдёт в SQL. + + + diff --git a/shine-server-blockchain/src/main/java/utils/files/TODO.txt b/shine-server-blockchain/src/main/java/utils/files/TODO.txt new file mode 100644 index 0000000..5dc6788 --- /dev/null +++ b/shine-server-blockchain/src/main/java/utils/files/TODO.txt @@ -0,0 +1,8 @@ +В будущем всё это уйдёт в SQL. + + + +TODO И проработать вот эту проблему + +есть вариант тто при врнезапном жёстком завершении приложения, может дописаться в конец файла только половина записи и это будет жёсткой ошибкой + diff --git a/shine-server-blockchain/src/main/java/utils/search/README.md b/shine-server-blockchain/src/main/java/utils/search/README.md new file mode 100644 index 0000000..00ef1a4 --- /dev/null +++ b/shine-server-blockchain/src/main/java/utils/search/README.md @@ -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)` для ответа клиенту. diff --git a/shine-server-blockchain/src/main/java/utils/search/UserSearchService.java b/shine-server-blockchain/src/main/java/utils/search/UserSearchService.java new file mode 100644 index 0000000..6a7812a --- /dev/null +++ b/shine-server-blockchain/src/main/java/utils/search/UserSearchService.java @@ -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 searchFirst5(String query) { + String q = (query == null ? "" : query).toLowerCase(Locale.ROOT).trim(); + List out = new ArrayList<>(5); + if (q.isEmpty()) return out; + + // берём снапшот id→login + Map 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; + } +} diff --git a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 47c5cce..aa3fef3 100644 --- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -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); + """); } } -} \ No newline at end of file +} diff --git a/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java b/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java new file mode 100644 index 0000000..564eb99 --- /dev/null +++ b/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java @@ -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; + } +} \ No newline at end of file diff --git a/shine-server-db/src/main/java/shine/db/entities/BlockchainStateEntry.java b/shine-server-db/src/main/java/shine/db/entities/BlockchainStateEntry.java new file mode 100644 index 0000000..79a180c --- /dev/null +++ b/shine-server-db/src/main/java/shine/db/entities/BlockchainStateEntry.java @@ -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"); + } +} \ No newline at end of file diff --git a/src/TODO.txt b/src/TODO.txt index 8108d57..83a1468 100644 --- a/src/TODO.txt +++ b/src/TODO.txt @@ -1 +1,159 @@ -Сделать потом что бы на каждую сессию стояло время последнего подключения и откуда оно было - но видимо это уже в свойства запихивать надо. \ No newline at end of file +Конспект: что обсуждали и где остановились +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 (для поиска), если он будет нужен. \ No newline at end of file