SHA256
Compare commits
63
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
3281da7f5c | ||
|
|
08c7fd7676 | ||
|
|
9be4c4d3a5 | ||
|
|
f5698771e4 | ||
|
|
151a2c1754 | ||
|
|
13693a0a53 | ||
|
|
451280edd2 | ||
|
|
c997057a37 | ||
|
|
f8900e531a | ||
|
|
6e5b57fd7c | ||
|
|
408e474130 | ||
|
|
ef707ec217 | ||
|
|
8b12760dde | ||
|
|
cae64639bd | ||
|
|
be696d116b | ||
|
|
23969121e5 | ||
|
|
4da2d7129a | ||
|
|
2be5ad0b7d | ||
|
|
f44ab75c64 | ||
|
|
7aa05dc609 | ||
|
|
7b81d140ec | ||
|
|
1be1d56599 | ||
|
|
2df0a78eb2 | ||
|
|
c3253dceac | ||
|
|
ef068eca81 | ||
|
|
f9ae811702 | ||
|
|
ea0098d705 | ||
|
|
4991a146d5 | ||
|
|
849250bfa8 | ||
|
|
d83f1d4cce | ||
|
|
4c7e71f21f | ||
|
|
107f85b818 | ||
|
|
3a466b4c38 | ||
|
|
08663fa339 | ||
|
|
8e86872aa7 | ||
|
|
2d059e9ff5 | ||
|
|
9f77c54955 | ||
|
|
f827c3e493 | ||
|
|
ae2f2fac41 | ||
|
|
1f70d36e74 | ||
|
|
7d27bfdcaf | ||
|
|
023d61a1e9 | ||
|
|
85d90a7f95 | ||
|
|
e0295eebde | ||
|
|
ebc9143593 | ||
|
|
e7c8fd748c | ||
|
|
aff601f61a | ||
|
|
0c5089fa79 | ||
|
|
e5556f3706 | ||
|
|
09c7f8541e | ||
|
|
8eb2ef438c | ||
|
|
34abe14151 | ||
|
|
90e37c198f | ||
|
|
1373283947 | ||
|
|
065616a18b | ||
|
|
53cd55a2a0 | ||
|
|
079be37fff | ||
|
|
806a8c57d4 | ||
|
|
797e769cc3 | ||
|
|
822a72e246 | ||
|
|
58b564b385 | ||
|
|
7aa9e2f6a4 | ||
|
|
8801b93973 |
@@ -0,0 +1,30 @@
|
|||||||
|
-proc:none
|
||||||
|
-d
|
||||||
|
/tmp/javac-check
|
||||||
|
-sourcepath
|
||||||
|
SHiNE-server/shine-server-geo/src/main/java:SHiNE-server/shine-server-log/src/main/java:SHiNE-server/shine-server-solana-users-sync/src/main/java:SHiNE-server/shine-server-db/src/main/java:SHiNE-server/src/main/java:SHiNE-server/shine-server-archive/src/main/java:SHiNE-server/shine-server-blockchain/src/main/java:SHiNE-server/shine-server-crypto/src/main/java:SHiNE-server/shine-server-net-protocol/src/main/java:SHiNE-server/shine-server-config/src/main/java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/src/main/java/server/ws/WsServer.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java
|
||||||
|
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java
|
||||||
@@ -105,6 +105,7 @@ ESP32/**/*.a
|
|||||||
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
||||||
deploy/backup/archive/**
|
deploy/backup/archive/**
|
||||||
!deploy/backup/archive/.gitkeep
|
!deploy/backup/archive/.gitkeep
|
||||||
|
SHiNE-bundle-*.zip
|
||||||
|
|
||||||
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
||||||
.agents/
|
.agents/
|
||||||
|
|||||||
@@ -43,8 +43,9 @@
|
|||||||
- Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте.
|
- Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте.
|
||||||
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
|
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
|
||||||
- `docs/Solana_Architecture/README.md`
|
- `docs/Solana_Architecture/README.md`
|
||||||
- Документ формата пользовательской PDA-записи `shine_users` находится в:
|
- Документы формата пользовательской PDA-записи `shine_users`:
|
||||||
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
|
- текущий: `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.2.md`;
|
||||||
|
- legacy: `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`.
|
||||||
- Актуальная документация по серверному модулю синхронизации Solana users находится в:
|
- Актуальная документация по серверному модулю синхронизации Solana users находится в:
|
||||||
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
|
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
|
||||||
- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять:
|
- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять:
|
||||||
@@ -137,11 +138,8 @@
|
|||||||
- с какого сценария продолжать разработку;
|
- с какого сценария продолжать разработку;
|
||||||
- из какого коммита брать последнюю полную реализацию.
|
- из какого коммита брать последнюю полную реализацию.
|
||||||
|
|
||||||
## Коммуникация по новым задачам (обязательно)
|
## Коммуникация по новым задачам
|
||||||
- При получении нового задания сначала кратко пересказать задачу своими словами.
|
- При получении нового задания кратко подтвердить понимание задачи, если это помогает синхронизироваться.
|
||||||
- До начала реализации задать недостающие уточняющие вопросы (если они есть).
|
- До начала реализации задавать уточняющие вопросы только когда без ответа есть риск сделать не то или затронуть опасную область.
|
||||||
|
- Если задача понятна и не требует отдельного подтверждения по другим правилам проекта, переходить к реализации сразу.
|
||||||
- Если есть уместные идеи/улучшения — кратко предложить их; если полезных идей нет, ничего дополнительно не предлагать.
|
- Если есть уместные идеи/улучшения — кратко предложить их; если полезных идей нет, ничего дополнительно не предлагать.
|
||||||
- Добавлять краткую оценку фичи (насколько это полезно/удачно по мнению исполнителя).
|
|
||||||
- После этого обязательно запросить подтверждение от пользователя, что задача понята верно, и только после подтверждения переходить к реализации.
|
|
||||||
- Если вопросов нет, явно написать в формате: «Я всё понял, начинаю делать?» и ждать подтверждения.
|
|
||||||
- Без подтверждения пользователя реализацию не начинать.
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# SHiNE ANS-104 `test5590` patch
|
||||||
|
|
||||||
|
Этот архив содержит **только новые/изменённые файлы** плюс `DELETE_FILES.txt`.
|
||||||
|
|
||||||
|
## Что меняется
|
||||||
|
|
||||||
|
- Frame v1 без поддержки старого формата.
|
||||||
|
- Один пользовательский block = готовый Ed25519 ANS-104 DataItem.
|
||||||
|
- Test tag: `App=test5590`; channel tag: `c=<canonical slug>`.
|
||||||
|
- SHiNE `prevHash` остаётся `SHA-256(FrameV1Bytes)` и не зависит от Arweave ID.
|
||||||
|
- PostgreSQL — единственное локальное хранилище пользовательских blocks.
|
||||||
|
- Старый custom SHINE-ARCHIVE и filesystem blockchain storage удаляются.
|
||||||
|
- Каждые ~15 минут локальные pending DataItems собираются в standard ANS-104 bundle и идут через существующий native Arweave L1 transport.
|
||||||
|
- Другие серверы находят `App=test5590`, импортируют и не публикуют повторно.
|
||||||
|
- `ESP32/**` **не изменён**.
|
||||||
|
|
||||||
|
## Состав
|
||||||
|
|
||||||
|
- изменено/добавлено файлов: 46
|
||||||
|
- удалить файлов: 41
|
||||||
|
|
||||||
|
Применение и проверки: `docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md`.
|
||||||
|
|
||||||
|
## Проверки при сборке patch
|
||||||
|
|
||||||
|
- `node --check` для изменённых production JS: OK.
|
||||||
|
- `diff -qr` каталога `ESP32` относительно исходного архива: без изменений.
|
||||||
|
- Active Java refs на удалённые SHINE-ARCHIVE/file-recovery classes: отсутствуют.
|
||||||
|
- `./gradlew testClasses --offline --no-daemon`: не запущен до компиляции, потому что wrapper требует скачать Gradle 8.14 с `services.gradle.org`, а рабочая среда не имеет внешнего сетевого доступа. Полный Gradle test обязателен после применения patch в dev-среде.
|
||||||
@@ -52,113 +52,84 @@ function readStrU8(bytes, cursorRef) {
|
|||||||
|
|
||||||
function parseServerFieldsFromUserPda(dataBytes) {
|
function parseServerFieldsFromUserPda(dataBytes) {
|
||||||
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
|
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
|
||||||
if (bytes.length < 5) throw new Error('Некорректный формат PDA');
|
if (bytes.length < 9) throw new Error('Некорректный формат PDA');
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
const cursorRef = { value: 0 };
|
const cursorRef = { value: 0 };
|
||||||
|
const readU16 = () => { if (cursorRef.value + 2 > bytes.length) throw new Error('Повреждённый формат PDA'); const v = view.getUint16(cursorRef.value, true); cursorRef.value += 2; return v; };
|
||||||
|
const readU32 = () => { if (cursorRef.value + 4 > bytes.length) throw new Error('Повреждённый формат PDA'); const v = view.getUint32(cursorRef.value, true); cursorRef.value += 4; return v; };
|
||||||
|
const readU64 = () => { if (cursorRef.value + 8 > bytes.length) throw new Error('Повреждённый формат PDA'); const v = view.getBigUint64(cursorRef.value, true); cursorRef.value += 8; return v; };
|
||||||
const magic = new TextDecoder().decode(readBytes(bytes, cursorRef, 5));
|
const magic = new TextDecoder().decode(readBytes(bytes, cursorRef, 5));
|
||||||
if (magic !== 'SHiNE') throw new Error('Некорректный формат PDA');
|
if (magic !== 'SHiNE') throw new Error('Некорректный формат PDA');
|
||||||
cursorRef.value += 1; // format_major
|
const formatMajor = readU8(bytes, cursorRef);
|
||||||
cursorRef.value += 1; // format_minor
|
const formatMinor = readU8(bytes, cursorRef);
|
||||||
cursorRef.value += 2; // record_len
|
if (formatMajor !== 1 || formatMinor !== 2) throw new Error(`Неподдерживаемый формат PDA ${formatMajor}.${formatMinor}`);
|
||||||
|
const recordLen = readU16();
|
||||||
|
if (recordLen < 73 || recordLen > bytes.length) throw new Error('Некорректный record_len PDA');
|
||||||
cursorRef.value += 8; // created_at_ms
|
cursorRef.value += 8; // created_at_ms
|
||||||
cursorRef.value += 8; // updated_at_ms
|
cursorRef.value += 8; // updated_at_ms
|
||||||
cursorRef.value += 4; // record_number
|
cursorRef.value += 4; // record_number
|
||||||
cursorRef.value += 32; // prev_record_hash
|
cursorRef.value += 32; // prev_record_hash
|
||||||
readStrU8(bytes, cursorRef); // login
|
const login = readStrU8(bytes, cursorRef);
|
||||||
const blocksCount = readU8(bytes, cursorRef);
|
const blocksCount = readU8(bytes, cursorRef);
|
||||||
|
|
||||||
let isServer = false;
|
|
||||||
let serverAddress = '';
|
let serverAddress = '';
|
||||||
let accessServers = [];
|
let accessServers = [];
|
||||||
let recoveryKey32 = null;
|
|
||||||
let rootKey32 = null;
|
let rootKey32 = null;
|
||||||
let clientKey32 = null;
|
let clientKey32 = null;
|
||||||
let blockchainKey32 = null;
|
let blockchainKey32 = null;
|
||||||
let blockchainName = '';
|
let forkCount = 0;
|
||||||
let homeserverSessions = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < blocksCount; i += 1) {
|
for (let i = 0; i < blocksCount; i += 1) {
|
||||||
const blockType = readU8(bytes, cursorRef);
|
const blockType = readU8(bytes, cursorRef);
|
||||||
cursorRef.value += 1; // block_version
|
const blockVersion = readU8(bytes, cursorRef);
|
||||||
|
if (blockVersion !== 0) throw new Error('Неподдерживаемая версия блока PDA');
|
||||||
if (blockType === 0 || blockType === 1 || blockType === 2) {
|
if (blockType === 1 || blockType === 2) {
|
||||||
const key32 = readBytes(bytes, cursorRef, 32);
|
const key32 = readBytes(bytes, cursorRef, 32);
|
||||||
if (blockType === 0) recoveryKey32 = key32;
|
|
||||||
if (blockType === 1) rootKey32 = key32;
|
if (blockType === 1) rootKey32 = key32;
|
||||||
if (blockType === 2) clientKey32 = key32;
|
else clientKey32 = key32;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payloadLen = readU16();
|
||||||
|
const payloadEnd = cursorRef.value + payloadLen;
|
||||||
|
if (payloadEnd > recordLen - 64) throw new Error('Повреждённый variable block PDA');
|
||||||
|
|
||||||
if (blockType === 3) {
|
if (blockType === 3) {
|
||||||
const count = readU8(bytes, cursorRef);
|
forkCount = readU16();
|
||||||
for (let j = 0; j < count; j += 1) {
|
if (forkCount < 1) throw new Error('BlockchainRegistry пуст');
|
||||||
cursorRef.value += 1;
|
for (let j = 0; j < forkCount; j += 1) {
|
||||||
const currentBlockchainName = readStrU8(bytes, cursorRef);
|
blockchainKey32 = readBytes(bytes, cursorRef, 32);
|
||||||
const currentBlockchainKey32 = readBytes(bytes, cursorRef, 32);
|
readU64(); // created_at_ms fork
|
||||||
if (!blockchainKey32) {
|
readU32(); // paid_limit_bytes
|
||||||
blockchainKey32 = currentBlockchainKey32;
|
|
||||||
blockchainName = currentBlockchainName;
|
|
||||||
}
|
|
||||||
cursorRef.value += 8 + 8 + 4 + 32 + 64;
|
|
||||||
const arPresent = readU8(bytes, cursorRef);
|
|
||||||
if (arPresent === 1) readStrU8(bytes, cursorRef);
|
|
||||||
}
|
}
|
||||||
continue;
|
} else if (blockType === 30) {
|
||||||
}
|
const addressCount = readU8(bytes, cursorRef);
|
||||||
if (blockType === 30) {
|
if (addressCount !== 1) throw new Error('PDA 1.2 допускает ровно один адрес сервера');
|
||||||
isServer = readU8(bytes, cursorRef) === 1;
|
readU8(bytes, cursorRef); // address_format_type
|
||||||
if (isServer) {
|
readU8(bytes, cursorRef); // address_format_version
|
||||||
cursorRef.value += 1; // address_format_type
|
serverAddress = readStrU8(bytes, cursorRef);
|
||||||
cursorRef.value += 1; // address_format_version
|
} else if (blockType === 40) {
|
||||||
serverAddress = readStrU8(bytes, cursorRef);
|
|
||||||
const syncCount = readU8(bytes, cursorRef);
|
|
||||||
for (let j = 0; j < syncCount; j += 1) readStrU8(bytes, cursorRef);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (blockType === 40) {
|
|
||||||
const accessCount = readU8(bytes, cursorRef);
|
const accessCount = readU8(bytes, cursorRef);
|
||||||
|
if (accessCount > 1) throw new Error('PDA 1.2 допускает максимум один access server');
|
||||||
accessServers = [];
|
accessServers = [];
|
||||||
for (let j = 0; j < accessCount; j += 1) accessServers.push(readStrU8(bytes, cursorRef));
|
for (let j = 0; j < accessCount; j += 1) accessServers.push(readStrU8(bytes, cursorRef));
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (blockType === 50) {
|
cursorRef.value = payloadEnd;
|
||||||
cursorRef.value += 1;
|
|
||||||
const sessionsCount = readU8(bytes, cursorRef);
|
|
||||||
for (let j = 0; j < sessionsCount; j += 1) {
|
|
||||||
const sessionType = readU8(bytes, cursorRef);
|
|
||||||
const sessionVersion = readU8(bytes, cursorRef);
|
|
||||||
const sessionName = readStrU8(bytes, cursorRef);
|
|
||||||
const sessionPubKey32 = readBytes(bytes, cursorRef, 32);
|
|
||||||
if (sessionType === 100) {
|
|
||||||
homeserverSessions.push({
|
|
||||||
sessionType,
|
|
||||||
sessionVersion,
|
|
||||||
sessionName,
|
|
||||||
sessionPubKeyBase58: new PublicKey(sessionPubKey32).toBase58(),
|
|
||||||
sessionPubKeyB64: `ed25519/${btoa(String.fromCharCode(...sessionPubKey32))}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (blockType === 70) {
|
|
||||||
cursorRef.value += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw new Error(`Неизвестный блок PDA: ${blockType}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!rootKey32 || !clientKey32 || !blockchainKey32) throw new Error('PDA 1.2 не содержит обязательные ключи');
|
||||||
return {
|
return {
|
||||||
isServer,
|
isServer: Boolean(serverAddress),
|
||||||
serverAddress: normalizeHostLike(serverAddress),
|
serverAddress: normalizeHostLike(serverAddress),
|
||||||
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
|
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
|
||||||
publicKeys: {
|
publicKeys: {
|
||||||
recoveryKeyBase58: recoveryKey32 ? new PublicKey(recoveryKey32).toBase58() : '',
|
recoveryKeyBase58: '',
|
||||||
rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '',
|
rootKeyBase58: new PublicKey(rootKey32).toBase58(),
|
||||||
clientKeyBase58: clientKey32 ? new PublicKey(clientKey32).toBase58() : '',
|
clientKeyBase58: new PublicKey(clientKey32).toBase58(),
|
||||||
blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '',
|
blockchainKeyBase58: new PublicKey(blockchainKey32).toBase58(),
|
||||||
blockchainName,
|
blockchainName: `${normalizeServerLogin(login)}-${String(forkCount).padStart(3, '0')}`,
|
||||||
},
|
},
|
||||||
homeserverSessions,
|
homeserverSessions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+7
-15
@@ -18,15 +18,11 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
|
|||||||
|
|
||||||
## Настройка сервера в Solana (Solana PDA)
|
## Настройка сервера в Solana (Solana PDA)
|
||||||
|
|
||||||
Серверный аккаунт SHiNE регистрируется в Solana в виде `user_pda` с флагом `is_server=true`.
|
Серверный аккаунт SHiNE регистрируется как обычный `user_pda` формата 1.2 с присутствующим `ServerProfileBlock`. Отдельного `is_server` в PDA нет: наличие server profile означает, что аккаунт объявляет серверный endpoint.
|
||||||
В PDA хранятся:
|
|
||||||
|
|
||||||
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
|
PDA 1.2 сейчас допускает **один** адрес сервера (`address_format_type + address_format_version + address`). Бинарный блок использует count/array, чтобы будущая версия протокола могла увеличить лимит без смены структуры блока. Прямой список `sync_servers` удалён: пользовательские blockchain синхронизируются через Arweave.
|
||||||
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
|
|
||||||
с которыми синхронизируются пользовательские блокчейны;
|
|
||||||
- **корневой ключ** сервера (`root_key`).
|
|
||||||
|
|
||||||
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
|
Клиенты читают server PDA из Solana, получают опубликованный endpoint и подключаются к нему. Межсерверная доставка DM через access-server routing остаётся отдельным механизмом.
|
||||||
|
|
||||||
**Управление серверной PDA выполняется через Web-панель администратора:**
|
**Управление серверной PDA выполняется через Web-панель администратора:**
|
||||||
|
|
||||||
@@ -36,10 +32,9 @@ shine-UI/server-ui.html
|
|||||||
|
|
||||||
Страницы:
|
Страницы:
|
||||||
- `shine-UI/server-ui/create-server-pda.html` — первичная регистрация серверного аккаунта;
|
- `shine-UI/server-ui/create-server-pda.html` — первичная регистрация серверного аккаунта;
|
||||||
- `shine-UI/server-ui/update-server-pda.html` — обновление адреса или списка sync_servers.
|
- `shine-UI/server-ui/update-server-pda.html` — обновление адреса сервера.
|
||||||
|
|
||||||
Для регистрации нужен полный keyBundle (root + device + blockchain).
|
PDA 1.2 использует три постоянные роли ключей: `root` (cold recovery), `blockchain` (обычный authority и подпись блоков) и `client` (клиент/кошелёк). Обычный update авторизуется активным последним blockchain key; root используется для recovery и может менять root.
|
||||||
Для обновления — только root + device (blockchain-ключ не нужен).
|
|
||||||
|
|
||||||
Актуальные адреса программ Solana (devnet):
|
Актуальные адреса программ Solana (devnet):
|
||||||
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
|
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
|
||||||
@@ -47,12 +42,9 @@ shine-UI/server-ui.html
|
|||||||
|
|
||||||
Подробнее: `docs/Инициализация_Solana_регистрации/README.md`
|
Подробнее: `docs/Инициализация_Solana_регистрации/README.md`
|
||||||
|
|
||||||
## Синхронизация с партнёрскими серверами
|
## Синхронизация пользовательских blockchain
|
||||||
|
|
||||||
Сервер должен синхронизировать блоки пользовательских блокчейнов с
|
Прямой peer-to-peer blockchain sync через `sync_servers` отключён. Сервер восстанавливает и синхронизирует пользовательские записи через Arweave. Код старого peer-sync пока может оставаться как inert legacy, но не должен включаться в runtime.
|
||||||
серверами-партнёрами из `sync_servers`. DM между партнёрами не реплицируются:
|
|
||||||
они доставляются только на первый access-сервер получателя.
|
|
||||||
Детали: `docs/Blockchain/sync-between-servers.md`
|
|
||||||
|
|
||||||
## Деплой
|
## Деплой
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'shine'
|
||||||
|
version = '1.0.0'
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories { mavenCentral() }
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':shine-server-config')
|
||||||
|
implementation project(':shine-server-db')
|
||||||
|
implementation project(':shine-server-crypto')
|
||||||
|
implementation project(':shine-server-blockchain')
|
||||||
|
implementation project(':shine-server-net-protocol')
|
||||||
|
implementation project(':shine-server-solana-users-sync')
|
||||||
|
|
||||||
|
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
|
||||||
|
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
|
||||||
|
implementation 'org.slf4j:slf4j-api:2.0.16'
|
||||||
|
|
||||||
|
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
test { useJUnitPlatform() }
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package server.archive;
|
||||||
|
|
||||||
|
import blockchain.Ans104DataItem;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Minimal ANS-104 binary bundle writer/reader. */
|
||||||
|
public final class Ans104Bundle {
|
||||||
|
public record Entry(byte[] id32, byte[] rawDataItem) {}
|
||||||
|
|
||||||
|
private Ans104Bundle() {}
|
||||||
|
|
||||||
|
public static byte[] write(List<byte[]> rawItems) {
|
||||||
|
if (rawItems == null || rawItems.isEmpty()) throw new IllegalArgumentException("ANS-104 bundle is empty");
|
||||||
|
if (rawItems.size() > 1_000_000) throw new IllegalArgumentException("Too many ANS-104 items");
|
||||||
|
|
||||||
|
List<Ans104DataItem> parsed = new ArrayList<>(rawItems.size());
|
||||||
|
long total = 32L + 64L * rawItems.size();
|
||||||
|
for (byte[] raw : rawItems) {
|
||||||
|
Ans104DataItem item = new Ans104DataItem(raw);
|
||||||
|
if (!item.verifySignature()) throw new IllegalArgumentException("Invalid ANS-104 DataItem signature");
|
||||||
|
parsed.add(item);
|
||||||
|
total = Math.addExact(total, raw.length);
|
||||||
|
}
|
||||||
|
if (total > Integer.MAX_VALUE) throw new IllegalArgumentException("Bundle too large for in-memory publisher: " + total);
|
||||||
|
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream((int) total);
|
||||||
|
out.writeBytes(uint256LE(rawItems.size()));
|
||||||
|
for (int i = 0; i < rawItems.size(); i++) {
|
||||||
|
out.writeBytes(uint256LE(rawItems.get(i).length));
|
||||||
|
out.writeBytes(parsed.get(i).id32());
|
||||||
|
}
|
||||||
|
for (byte[] raw : rawItems) out.writeBytes(raw);
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Entry> read(byte[] bundle, int maxItems) {
|
||||||
|
if (bundle == null || bundle.length < 32) throw new IllegalArgumentException("ANS-104 bundle too short");
|
||||||
|
long countLong = readUint256AsLong(bundle, 0);
|
||||||
|
if (countLong <= 0 || countLong > Math.max(1, maxItems)) {
|
||||||
|
throw new IllegalArgumentException("Bad ANS-104 bundle item count: " + countLong);
|
||||||
|
}
|
||||||
|
int count = (int) countLong;
|
||||||
|
long headerLong = 32L + 64L * count;
|
||||||
|
if (headerLong > bundle.length) throw new IllegalArgumentException("ANS-104 bundle header truncated");
|
||||||
|
int header = (int) headerLong;
|
||||||
|
long[] sizes = new long[count];
|
||||||
|
byte[][] ids = new byte[count][];
|
||||||
|
int hp = 32;
|
||||||
|
long payloadBytes = 0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
sizes[i] = readUint256AsLong(bundle, hp); hp += 32;
|
||||||
|
ids[i] = Arrays.copyOfRange(bundle, hp, hp + 32); hp += 32;
|
||||||
|
if (sizes[i] <= 0 || sizes[i] > Integer.MAX_VALUE) throw new IllegalArgumentException("Bad DataItem size: " + sizes[i]);
|
||||||
|
payloadBytes = Math.addExact(payloadBytes, sizes[i]);
|
||||||
|
}
|
||||||
|
if (headerLong + payloadBytes != bundle.length) {
|
||||||
|
throw new IllegalArgumentException("ANS-104 bundle size mismatch: header=" + headerLong + " payload=" + payloadBytes + " actual=" + bundle.length);
|
||||||
|
}
|
||||||
|
List<Entry> out = new ArrayList<>(count);
|
||||||
|
int p = header;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
int len = (int) sizes[i];
|
||||||
|
byte[] raw = Arrays.copyOfRange(bundle, p, p + len);
|
||||||
|
p += len;
|
||||||
|
Ans104DataItem item = new Ans104DataItem(raw);
|
||||||
|
if (!Arrays.equals(ids[i], item.id32())) throw new IllegalArgumentException("Bundle entry ID mismatch at index " + i);
|
||||||
|
out.add(new Entry(ids[i], raw));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] find(byte[] bundle, byte[] wantedId32, int maxItems) {
|
||||||
|
if (wantedId32 == null || wantedId32.length != 32) throw new IllegalArgumentException("wanted ID must be 32 bytes");
|
||||||
|
for (Entry e : read(bundle, maxItems)) {
|
||||||
|
if (Arrays.equals(e.id32(), wantedId32)) return e.rawDataItem();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ANS-104 bundle integers are 32-byte unsigned little-endian values. */
|
||||||
|
static byte[] uint256LE(long value) {
|
||||||
|
if (value < 0) throw new IllegalArgumentException("negative uint256");
|
||||||
|
byte[] out = new byte[32];
|
||||||
|
long v = value;
|
||||||
|
for (int i = 0; i < 8; i++) { out[i] = (byte) (v & 0xff); v >>>= 8; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static long readUint256AsLong(byte[] data, int offset) {
|
||||||
|
if (offset < 0 || offset + 32 > data.length) throw new IllegalArgumentException("uint256 truncated");
|
||||||
|
for (int i = offset + 8; i < offset + 32; i++) {
|
||||||
|
if (data[i] != 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
|
||||||
|
}
|
||||||
|
long v = 0;
|
||||||
|
for (int i = 7; i >= 0; i--) v = (v << 8) | (data[offset + i] & 0xffL);
|
||||||
|
if (v < 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package server.archive;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/** Fixed-delay publisher. Empty cycles are no-ops. */
|
||||||
|
public final class ArweaveBlockPublisherScheduler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherScheduler.class);
|
||||||
|
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||||
|
private static ScheduledExecutorService executor;
|
||||||
|
|
||||||
|
private ArweaveBlockPublisherScheduler() {}
|
||||||
|
|
||||||
|
public static void startOrLog() {
|
||||||
|
ArweaveBlocksConfig cfg;
|
||||||
|
try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); }
|
||||||
|
catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; }
|
||||||
|
if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled (mode=none)"); return; }
|
||||||
|
if (!STARTED.compareAndSet(false,true)) return;
|
||||||
|
try {
|
||||||
|
ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg);
|
||||||
|
executor = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "shine-arweave-block-publisher"); t.setDaemon(true); return t;
|
||||||
|
});
|
||||||
|
Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } };
|
||||||
|
executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES);
|
||||||
|
log.info("Arweave user-block publisher enabled: mode={} interval={}m", cfg.publishMode(), cfg.publishIntervalMinutes());
|
||||||
|
} catch (Exception e) { STARTED.set(false); log.error("Arweave user-block publisher failed to start", e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void close() { ScheduledExecutorService e=executor; if(e!=null)e.shutdownNow(); }
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
package server.archive;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationCandidateBlocksDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Publishes locally-created signed user DataItems through the configured transport. */
|
||||||
|
public final class ArweaveBlockPublisherService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class);
|
||||||
|
|
||||||
|
private final ArweaveBlocksConfig cfg;
|
||||||
|
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||||
|
private final KeyRotationCandidateBlocksDAO candidateBlocksDAO = KeyRotationCandidateBlocksDAO.getInstance();
|
||||||
|
private final ArweaveL1Uploader arweaveUploader;
|
||||||
|
private final TurboDataItemUploader turboUploader;
|
||||||
|
|
||||||
|
public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) {
|
||||||
|
this.cfg = cfg;
|
||||||
|
this.arweaveUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.ARWEAVE ? new ArweaveL1Uploader(cfg) : null;
|
||||||
|
this.turboUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.TURBO ? new TurboDataItemUploader(cfg) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int runCycle() throws Exception {
|
||||||
|
if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0;
|
||||||
|
|
||||||
|
int maxItems = cfg.publishMaxItems();
|
||||||
|
int attempted = 0;
|
||||||
|
int published = 0;
|
||||||
|
|
||||||
|
// Ротация приоритетна: пользователь уже заблокирован для обычной записи и ждёт завершения копирования.
|
||||||
|
List<KeyRotationCandidateBlockEntry> rotationCandidates = candidateBlocksDAO.listPendingArweave(maxItems);
|
||||||
|
if (!rotationCandidates.isEmpty()) {
|
||||||
|
attempted += rotationCandidates.size();
|
||||||
|
published += switch (cfg.publishMode()) {
|
||||||
|
case TURBO -> publishTurboRotation(rotationCandidates);
|
||||||
|
case ARWEAVE -> publishDirectArweaveRotation(rotationCandidates);
|
||||||
|
case NONE -> 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
int remaining = Math.max(0, maxItems - attempted);
|
||||||
|
if (remaining == 0) return published;
|
||||||
|
|
||||||
|
List<BlockEntry> candidates = blocksDAO.listPendingArweave(remaining);
|
||||||
|
if (candidates.isEmpty()) return published;
|
||||||
|
published += switch (cfg.publishMode()) {
|
||||||
|
case TURBO -> publishTurbo(candidates);
|
||||||
|
case ARWEAVE -> publishDirectArweave(candidates);
|
||||||
|
case NONE -> 0;
|
||||||
|
};
|
||||||
|
return published;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int publishTurboRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
|
||||||
|
int published = 0;
|
||||||
|
Exception firstFailure = null;
|
||||||
|
for (KeyRotationCandidateBlockEntry e : candidates) {
|
||||||
|
byte[] raw = e.getBlockBytes();
|
||||||
|
byte[] id = e.getDataItemId();
|
||||||
|
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
|
||||||
|
try {
|
||||||
|
TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id);
|
||||||
|
candidateBlocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis());
|
||||||
|
published++;
|
||||||
|
log.debug("Turbo published key-rotation DataItem {} chain={} block={}",
|
||||||
|
result.dataItemId(), e.getCandidateBlockchainName(), e.getBlockNumber());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
if (firstFailure == null) firstFailure = ex;
|
||||||
|
log.warn("Turbo key-rotation publish failed: chain={} block={} bytes={} error={}",
|
||||||
|
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (published > 0) log.info("Published {} SHiNE key-rotation DataItems through Turbo", published);
|
||||||
|
if (published == 0 && firstFailure != null) throw firstFailure;
|
||||||
|
return published;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int publishTurbo(List<BlockEntry> candidates) throws Exception {
|
||||||
|
int published = 0;
|
||||||
|
Exception firstFailure = null;
|
||||||
|
for (BlockEntry e : candidates) {
|
||||||
|
byte[] raw = e.getBlockBytes();
|
||||||
|
byte[] id = e.getDataItemId();
|
||||||
|
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
|
||||||
|
try {
|
||||||
|
TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id);
|
||||||
|
blocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis());
|
||||||
|
published++;
|
||||||
|
log.debug("Turbo published SHiNE DataItem {} chain={} block={}", result.dataItemId(), e.getBchName(), e.getBlockNumber());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
if (firstFailure == null) firstFailure = ex;
|
||||||
|
log.warn("Turbo publish failed: chain={} block={} bytes={} error={}",
|
||||||
|
e.getBchName(), e.getBlockNumber(), raw.length, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (published > 0) log.info("Published {} SHiNE test DataItems through Turbo", published);
|
||||||
|
if (published == 0 && firstFailure != null) throw firstFailure;
|
||||||
|
return published;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int publishDirectArweaveRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
|
||||||
|
List<byte[]> items = new ArrayList<>();
|
||||||
|
List<byte[]> ids = new ArrayList<>();
|
||||||
|
long estimated = 32;
|
||||||
|
for (KeyRotationCandidateBlockEntry e : candidates) {
|
||||||
|
byte[] raw = e.getBlockBytes();
|
||||||
|
byte[] id = e.getDataItemId();
|
||||||
|
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
|
||||||
|
long next = estimated + 64L + raw.length;
|
||||||
|
if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break;
|
||||||
|
if (next > cfg.publishMaxBundleBytes()) {
|
||||||
|
log.error("One key-rotation DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}",
|
||||||
|
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.add(raw);
|
||||||
|
ids.add(id);
|
||||||
|
estimated = next;
|
||||||
|
}
|
||||||
|
if (items.isEmpty()) return 0;
|
||||||
|
|
||||||
|
byte[] bundle = Ans104Bundle.write(items);
|
||||||
|
List<ArweaveL1Uploader.Tag> rootTags = List.of(
|
||||||
|
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
|
||||||
|
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
|
||||||
|
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0")
|
||||||
|
);
|
||||||
|
ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags);
|
||||||
|
candidateBlocksDAO.markArweavePublished(ids, System.currentTimeMillis());
|
||||||
|
log.info("Published {} SHiNE key-rotation DataItems in direct Arweave root tx {} (bundle={} bytes)",
|
||||||
|
items.size(), result.txId(), bundle.length);
|
||||||
|
return items.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int publishDirectArweave(List<BlockEntry> candidates) throws Exception {
|
||||||
|
List<byte[]> items = new ArrayList<>();
|
||||||
|
List<byte[]> ids = new ArrayList<>();
|
||||||
|
long estimated = 32;
|
||||||
|
for (BlockEntry e : candidates) {
|
||||||
|
byte[] raw = e.getBlockBytes();
|
||||||
|
byte[] id = e.getDataItemId();
|
||||||
|
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
|
||||||
|
long next = estimated + 64L + raw.length;
|
||||||
|
if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break;
|
||||||
|
if (next > cfg.publishMaxBundleBytes()) {
|
||||||
|
log.error("One DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}",
|
||||||
|
e.getBchName(), e.getBlockNumber(), raw.length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.add(raw);
|
||||||
|
ids.add(id);
|
||||||
|
estimated = next;
|
||||||
|
}
|
||||||
|
if (items.isEmpty()) return 0;
|
||||||
|
|
||||||
|
byte[] bundle = Ans104Bundle.write(items);
|
||||||
|
List<ArweaveL1Uploader.Tag> rootTags = List.of(
|
||||||
|
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
|
||||||
|
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
|
||||||
|
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0")
|
||||||
|
);
|
||||||
|
ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags);
|
||||||
|
blocksDAO.markArweavePublished(ids, System.currentTimeMillis());
|
||||||
|
log.info("Published {} SHiNE test DataItems in direct Arweave root tx {} (bundle={} bytes)",
|
||||||
|
items.size(), result.txId(), bundle.length);
|
||||||
|
return items.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package server.archive;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
public final class ArweaveBlockSyncScheduler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncScheduler.class);
|
||||||
|
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||||
|
private static ScheduledExecutorService executor;
|
||||||
|
private ArweaveBlockSyncScheduler() {}
|
||||||
|
|
||||||
|
public static void startOrLog() {
|
||||||
|
ArweaveBlocksConfig cfg;
|
||||||
|
try { cfg = ArweaveBlocksConfig.load(); }
|
||||||
|
catch (Exception e) { log.error("Cannot read Arweave block sync config", e); return; }
|
||||||
|
if (!cfg.syncEnabled()) { log.info("Arweave user-block sync disabled"); return; }
|
||||||
|
if (!STARTED.compareAndSet(false,true)) return;
|
||||||
|
try {
|
||||||
|
ArweaveBlockSyncService service = new ArweaveBlockSyncService(cfg);
|
||||||
|
executor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t=new Thread(r,"shine-arweave-block-sync");t.setDaemon(true);return t; });
|
||||||
|
Runnable task=()->{try{service.runCycle();}catch(Exception e){log.error("Arweave block sync cycle failed",e);}};
|
||||||
|
executor.scheduleWithFixedDelay(task,0,cfg.syncIntervalMinutes(),TimeUnit.MINUTES);
|
||||||
|
log.info("Arweave user-block sync enabled: interval={}m gateway={}",cfg.syncIntervalMinutes(),cfg.syncGateway());
|
||||||
|
} catch (Exception e) { STARTED.set(false); log.error("Arweave user-block sync failed to start",e); }
|
||||||
|
}
|
||||||
|
public static void close(){ScheduledExecutorService e=executor;if(e!=null)e.shutdownNow();}
|
||||||
|
}
|
||||||
+252
@@ -0,0 +1,252 @@
|
|||||||
|
package server.archive;
|
||||||
|
|
||||||
|
import blockchain.Ans104DataItem;
|
||||||
|
import blockchain.BchBlockEntry;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||||
|
import shine.db.dao.ArweaveBlockImportDAO;
|
||||||
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.SolanaUserPdaCurrentDAO;
|
||||||
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovers individual App=test5590 DataItems and imports the exact signed ANS-104 bytes.
|
||||||
|
* The importer is transport-agnostic: a DataItem may have reached Arweave through Turbo
|
||||||
|
* or inside a direct server-created ANS-104 bundle.
|
||||||
|
*/
|
||||||
|
public final class ArweaveBlockSyncService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class);
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
|
||||||
|
|
||||||
|
private final ArweaveBlocksConfig cfg;
|
||||||
|
private final HttpClient http = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(20))
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build();
|
||||||
|
private final ArweaveBlockImportDAO importDAO = ArweaveBlockImportDAO.getInstance();
|
||||||
|
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||||
|
private final SolanaUserPdaCurrentDAO usersDAO = SolanaUserPdaCurrentDAO.getInstance();
|
||||||
|
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
||||||
|
private final Net_AddBlock_Handler addBlock = new Net_AddBlock_Handler();
|
||||||
|
|
||||||
|
public ArweaveBlockSyncService(ArweaveBlocksConfig cfg) { this.cfg = cfg; }
|
||||||
|
|
||||||
|
public void runCycle() throws Exception {
|
||||||
|
discover();
|
||||||
|
drainQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void discover() throws Exception {
|
||||||
|
long stored = importDAO.getLastBlockHeight();
|
||||||
|
long minHeight = Math.max(cfg.syncStartBlockHeight(), stored);
|
||||||
|
String cursor = null;
|
||||||
|
long maxHeight = minHeight;
|
||||||
|
long lowestRetryHeight = Long.MAX_VALUE;
|
||||||
|
int discovered = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
JsonNode response = graphQlPage(minHeight, cursor);
|
||||||
|
JsonNode txs = response.path("data").path("transactions");
|
||||||
|
if (response.has("errors")) throw new IOException("Arweave GraphQL errors: " + response.path("errors"));
|
||||||
|
JsonNode edges = txs.path("edges");
|
||||||
|
if (!edges.isArray()) throw new IOException("Arweave GraphQL returned no transactions.edges");
|
||||||
|
|
||||||
|
String nextCursor = null;
|
||||||
|
for (JsonNode edge : edges) {
|
||||||
|
nextCursor = edge.path("cursor").asText(null);
|
||||||
|
JsonNode node = edge.path("node");
|
||||||
|
String dataItemId = node.path("id").asText("").trim();
|
||||||
|
long height = node.path("block").path("height").asLong(-1L);
|
||||||
|
if (dataItemId.isBlank() || height < 0) continue;
|
||||||
|
maxHeight = Math.max(maxHeight, height);
|
||||||
|
|
||||||
|
byte[] id32;
|
||||||
|
try {
|
||||||
|
id32 = B64URL.decode(dataItemId);
|
||||||
|
if (id32.length != 32) throw new IllegalArgumentException("id length=" + id32.length);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Ignoring malformed Arweave DataItem id {}: {}", dataItemId, e.getMessage());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
byte[] rawDataItem = downloadSignedDataItem(dataItemId);
|
||||||
|
if (importDAO.enqueueIfMissing(id32, height, rawDataItem, System.currentTimeMillis())) discovered++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
lowestRetryHeight = Math.min(lowestRetryHeight, height);
|
||||||
|
log.warn("Cannot retrieve signed DataItem {} at height {} yet: {}", dataItemId, height, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false);
|
||||||
|
cursor = hasNext ? nextCursor : null;
|
||||||
|
if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor");
|
||||||
|
} while (cursor != null);
|
||||||
|
|
||||||
|
// Keep inclusive overlap. If a gateway has indexed GraphQL before offsets, do not advance past that item.
|
||||||
|
long checkpoint = lowestRetryHeight == Long.MAX_VALUE ? maxHeight : Math.min(maxHeight, lowestRetryHeight);
|
||||||
|
importDAO.setLastBlockHeight(checkpoint, System.currentTimeMillis());
|
||||||
|
if (discovered > 0) {
|
||||||
|
log.info("Arweave discovery queued {} new SHiNE test DataItems; checkpoint={}", discovered, checkpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drainQueue() throws Exception {
|
||||||
|
int passes = 0;
|
||||||
|
int importedTotal = 0;
|
||||||
|
boolean progress;
|
||||||
|
do {
|
||||||
|
progress = false;
|
||||||
|
List<ArweaveBlockImportDAO.QueueItem> pending = importDAO.listPending(cfg.syncQueueBatchSize());
|
||||||
|
if (pending.isEmpty()) break;
|
||||||
|
for (ArweaveBlockImportDAO.QueueItem q : pending) {
|
||||||
|
Ans104DataItem item;
|
||||||
|
try {
|
||||||
|
item = new Ans104DataItem(q.rawDataItem());
|
||||||
|
if (!Arrays.equals(item.id32(), q.dataItemId())) throw new IllegalArgumentException("data_item_id mismatch");
|
||||||
|
if (!item.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) throw new IllegalArgumentException("bad App tag");
|
||||||
|
if (!item.verifySignature()) throw new IllegalArgumentException("bad ANS-104 signature");
|
||||||
|
new BchBlockEntry(q.rawDataItem());
|
||||||
|
} catch (Exception e) {
|
||||||
|
importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
SolanaUserPdaCurrentEntry user = usersDAO.getByBlockchainKey(item.owner32());
|
||||||
|
if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) {
|
||||||
|
importDAO.setPendingError(q.dataItemId(), "unknown_owner", System.currentTimeMillis());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ensureState(user);
|
||||||
|
Net_AddBlock_Handler.ArweaveImportResult result = addBlock.addBlockFromArweave(user.getBlockchainName(), q.rawDataItem());
|
||||||
|
if (result.ok()) {
|
||||||
|
importDAO.delete(q.dataItemId());
|
||||||
|
importedTotal++; progress = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String reason = result.reasonCode();
|
||||||
|
if ("bad_block_number".equals(reason) || "bad_prev_hash".equals(reason)
|
||||||
|
|| "prev_line_block_not_found".equals(reason) || "channel_not_found".equals(reason)
|
||||||
|
|| "chain_resync_in_progress".equals(reason)) {
|
||||||
|
importDAO.setPendingError(q.dataItemId(), reason, System.currentTimeMillis());
|
||||||
|
} else {
|
||||||
|
importDAO.reject(q.dataItemId(), reason, System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
passes++;
|
||||||
|
} while (progress && passes < 8);
|
||||||
|
if (importedTotal > 0) log.info("Imported {} SHiNE test DataItems from Arweave", importedTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureState(SolanaUserPdaCurrentEntry user) throws Exception {
|
||||||
|
BlockchainStateEntry existing = stateDAO.getByBlockchainName(user.getBlockchainName());
|
||||||
|
if (existing != null) return;
|
||||||
|
BlockchainStateEntry s = new BlockchainStateEntry();
|
||||||
|
s.setBlockchainName(user.getBlockchainName()); s.setLogin(user.getLogin()); s.setBlockchainKey(user.getBlockchainKey());
|
||||||
|
s.setSizeLimit(user.getPaidLimitBytes() > 0 ? user.getPaidLimitBytes() : 100_000L);
|
||||||
|
s.setFileSizeBytes(0L); s.setLastBlockNumber(-1); s.setLastBlockHash(null); s.setUpdatedAtMs(System.currentTimeMillis());
|
||||||
|
stateDAO.insertIfMissing(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode graphQlPage(long minHeight, String cursor) throws Exception {
|
||||||
|
String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\"";
|
||||||
|
String query = "query { transactions(tags:[{name:\"" + ArweaveBlocksConfig.TEST_TAG_NAME + "\",values:[\""
|
||||||
|
+ ArweaveBlocksConfig.TEST_TAG_VALUE + "\"]}], block:{min:" + minHeight + "}, first:"
|
||||||
|
+ cfg.syncPageSize() + ", after:" + after
|
||||||
|
+ ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id block { height } } } } }";
|
||||||
|
String body = MAPPER.writeValueAsString(Map.of("query", query));
|
||||||
|
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql"))
|
||||||
|
.timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build();
|
||||||
|
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("GraphQL HTTP " + resp.statusCode());
|
||||||
|
return MAPPER.readTree(resp.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateways normally expose only the payload at /{dataItemId}. SHiNE needs the complete signed
|
||||||
|
* DataItem, so obtain its exact offset/size inside the root L1 transaction and range-read it.
|
||||||
|
*/
|
||||||
|
private byte[] downloadSignedDataItem(String dataItemId) throws Exception {
|
||||||
|
JsonNode offsets = getOffsets(dataItemId);
|
||||||
|
String rootTxId = offsets.path("rootTxId").asText("").trim();
|
||||||
|
long rootOffset = offsets.path("rootOffset").asLong(-1L);
|
||||||
|
long size = offsets.path("size").asLong(-1L);
|
||||||
|
if (rootTxId.isBlank() || rootOffset < 0 || size <= 0) {
|
||||||
|
throw new IOException("Bad AR.IO offsets for " + dataItemId + ": " + offsets);
|
||||||
|
}
|
||||||
|
if (size > cfg.syncMaxDataItemBytes()) {
|
||||||
|
throw new IOException("DataItem exceeds syncMaxDataItemBytes: " + size);
|
||||||
|
}
|
||||||
|
if (rootOffset > Long.MAX_VALUE - size) throw new IOException("DataItem offset overflow");
|
||||||
|
long endInclusive = rootOffset + size - 1;
|
||||||
|
|
||||||
|
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/raw/" + rootTxId))
|
||||||
|
.timeout(Duration.ofMinutes(2))
|
||||||
|
.header("Range", "bytes=" + rootOffset + "-" + endInclusive)
|
||||||
|
.header("Accept", "application/octet-stream")
|
||||||
|
.GET().build();
|
||||||
|
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
|
||||||
|
try (InputStream in = resp.body()) {
|
||||||
|
if (resp.statusCode() != 206) {
|
||||||
|
throw new IOException("Gateway ignored root range for DataItem " + dataItemId + ": HTTP " + resp.statusCode());
|
||||||
|
}
|
||||||
|
byte[] bytes = readExactlyBounded(in, (int) size);
|
||||||
|
if (bytes.length != size) throw new IOException("Truncated DataItem range: expected=" + size + " got=" + bytes.length);
|
||||||
|
Ans104DataItem parsed = new Ans104DataItem(bytes);
|
||||||
|
byte[] expectedId = B64URL.decode(dataItemId);
|
||||||
|
if (!Arrays.equals(parsed.id32(), expectedId)) {
|
||||||
|
throw new IOException("Range returned another ANS-104 DataItem for " + dataItemId);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode getOffsets(String dataItemId) throws Exception {
|
||||||
|
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/ar-io/offsets/" + dataItemId))
|
||||||
|
.timeout(Duration.ofSeconds(30)).header("Accept","application/json").GET().build();
|
||||||
|
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
if (resp.statusCode() == 404) throw new IOException("AR.IO offsets not indexed yet");
|
||||||
|
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||||
|
throw new IOException("AR.IO offsets HTTP " + resp.statusCode() + ": " + safe(resp.body()));
|
||||||
|
}
|
||||||
|
return MAPPER.readTree(resp.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readExactlyBounded(InputStream in, int expected) throws IOException {
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream(expected);
|
||||||
|
byte[] buf = new byte[Math.min(64 * 1024, Math.max(1024, expected))];
|
||||||
|
int remaining = expected;
|
||||||
|
while (remaining > 0) {
|
||||||
|
int n = in.read(buf, 0, Math.min(buf.length, remaining));
|
||||||
|
if (n < 0) break;
|
||||||
|
out.write(buf, 0, n);
|
||||||
|
remaining -= n;
|
||||||
|
}
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String value) {
|
||||||
|
String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim();
|
||||||
|
return v.length() <= 500 ? v : v.substring(0, 500);
|
||||||
|
}
|
||||||
|
private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");}
|
||||||
|
private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");}
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package server.archive;
|
||||||
|
|
||||||
|
import blockchain.Ans104DataItem;
|
||||||
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/** Configuration of per-user-block ANS-104 Arweave/Turbo transport. */
|
||||||
|
public record ArweaveBlocksConfig(
|
||||||
|
PublishMode publishMode,
|
||||||
|
int publishIntervalMinutes,
|
||||||
|
int publishMaxItems,
|
||||||
|
long publishMaxBundleBytes,
|
||||||
|
String publishGateway,
|
||||||
|
Path walletJwkPath,
|
||||||
|
int minConfirmations,
|
||||||
|
int confirmPollSeconds,
|
||||||
|
int confirmTimeoutMinutes,
|
||||||
|
String turboUploadUrl,
|
||||||
|
String turboPaidByAddress,
|
||||||
|
Path turboWalletJwkPath,
|
||||||
|
boolean syncEnabled,
|
||||||
|
int syncIntervalMinutes,
|
||||||
|
int syncPageSize,
|
||||||
|
int syncQueueBatchSize,
|
||||||
|
long syncStartBlockHeight,
|
||||||
|
long syncMaxDataItemBytes,
|
||||||
|
String syncGateway
|
||||||
|
) {
|
||||||
|
public enum PublishMode {
|
||||||
|
TURBO,
|
||||||
|
ARWEAVE,
|
||||||
|
NONE;
|
||||||
|
|
||||||
|
static PublishMode parse(String value) {
|
||||||
|
String normalized = value == null ? "none" : value.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return switch (normalized) {
|
||||||
|
case "turbo" -> TURBO;
|
||||||
|
case "arweave" -> ARWEAVE;
|
||||||
|
case "none", "" -> NONE;
|
||||||
|
default -> throw new IllegalArgumentException(
|
||||||
|
"arweave.blocks.publish.mode must be turbo, arweave or none; got: " + value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final String TEST_TAG_NAME = "App";
|
||||||
|
public static final String TEST_TAG_VALUE = "test5590";
|
||||||
|
public static final String CHANNEL_TAG_NAME = "c_test5590";
|
||||||
|
|
||||||
|
public static ArweaveBlocksConfig load() {
|
||||||
|
AppConfig c = AppConfig.getInstance();
|
||||||
|
long maxDataItemBytes = parseLong(c.getParam("arweave.blocks.sync.maxDataItemBytes"), Ans104DataItem.MAX_DATA_ITEM_BYTES);
|
||||||
|
if (maxDataItemBytes > Ans104DataItem.MAX_DATA_ITEM_BYTES) maxDataItemBytes = Ans104DataItem.MAX_DATA_ITEM_BYTES;
|
||||||
|
return new ArweaveBlocksConfig(
|
||||||
|
PublishMode.parse(c.getParam("arweave.blocks.publish.mode")),
|
||||||
|
positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"),
|
||||||
|
positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"),
|
||||||
|
positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"),
|
||||||
|
orDefault(c.getParam("arweave.blocks.publish.gateway"), "https://arweave.net"),
|
||||||
|
optionalPath(c.getParam("arweave.blocks.publish.walletJwkPath")),
|
||||||
|
nonNegative(c.getInt("arweave.blocks.publish.minConfirmations", 0), "publish.minConfirmations"),
|
||||||
|
positive(c.getInt("arweave.blocks.publish.confirmPollSeconds", 30), "publish.confirmPollSeconds"),
|
||||||
|
positive(c.getInt("arweave.blocks.publish.confirmTimeoutMinutes", 180), "publish.confirmTimeoutMinutes"),
|
||||||
|
orDefault(c.getParam("arweave.blocks.publish.turbo.uploadUrl"), "https://turbo.ardrive.io/tx"),
|
||||||
|
blankToNull(c.getParam("arweave.blocks.publish.turbo.paidByAddress")),
|
||||||
|
optionalPath(c.getParam("arweave.blocks.publish.turbo.walletJwkPath")),
|
||||||
|
c.getBoolean("arweave.blocks.sync.enabled", false),
|
||||||
|
positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"),
|
||||||
|
clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100),
|
||||||
|
positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"),
|
||||||
|
nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"),
|
||||||
|
positiveLong(maxDataItemBytes, "sync.maxDataItemBytes"),
|
||||||
|
orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean publishEnabled() { return publishMode != PublishMode.NONE; }
|
||||||
|
|
||||||
|
public void validatePublisher() {
|
||||||
|
if (publishMode == PublishMode.ARWEAVE && walletJwkPath == null) {
|
||||||
|
throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required for mode=arweave");
|
||||||
|
}
|
||||||
|
if (publishMode == PublishMode.TURBO && (turboUploadUrl == null || turboUploadUrl.isBlank())) {
|
||||||
|
throw new IllegalArgumentException("arweave.blocks.publish.turbo.uploadUrl is required for mode=turbo");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
|
||||||
|
private static String blankToNull(String v) { return v == null || v.isBlank() ? null : v.trim(); }
|
||||||
|
private static Path optionalPath(String v) { return v == null || v.isBlank() ? null : Path.of(v.trim()); }
|
||||||
|
private static long parseLong(String v, long d) { return v == null || v.isBlank() ? d : Long.parseLong(v.trim()); }
|
||||||
|
private static int positive(int v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;}
|
||||||
|
private static int nonNegative(int v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;}
|
||||||
|
private static long positiveLong(long v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;}
|
||||||
|
private static long nonNegativeLong(long v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;}
|
||||||
|
private static int clamp(int v,int min,int max){return Math.max(min,Math.min(max,v));}
|
||||||
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
package server.archive;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.security.*;
|
||||||
|
import java.security.spec.MGF1ParameterSpec;
|
||||||
|
import java.security.spec.PSSParameterSpec;
|
||||||
|
import java.security.spec.RSAPrivateCrtKeySpec;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** Existing native Arweave L1 transport adapted to upload in-memory ANS-104 bundles. */
|
||||||
|
public final class ArweaveL1Uploader {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ArweaveL1Uploader.class);
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
|
||||||
|
private static final Base64.Decoder B64D = Base64.getUrlDecoder();
|
||||||
|
|
||||||
|
public record Tag(String name, String value) {}
|
||||||
|
public record UploadResult(String txId, byte[] txIdBytes) {}
|
||||||
|
|
||||||
|
private final ArweaveBlocksConfig cfg;
|
||||||
|
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||||
|
private volatile Wallet wallet;
|
||||||
|
|
||||||
|
public ArweaveL1Uploader(ArweaveBlocksConfig cfg) { this.cfg = Objects.requireNonNull(cfg); }
|
||||||
|
|
||||||
|
public UploadResult upload(byte[] data, List<Tag> tags) throws Exception {
|
||||||
|
if (data == null || data.length == 0) throw new IllegalArgumentException("Arweave upload data is empty");
|
||||||
|
Wallet w = wallet();
|
||||||
|
String gateway = trimGateway(cfg.publishGateway());
|
||||||
|
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(data);
|
||||||
|
String anchor = getRequiredText(gateway, "/tx_anchor");
|
||||||
|
String reward = getRequiredText(gateway, "/price/" + data.length);
|
||||||
|
if (!reward.matches("^\\d+$")) throw new IOException("Bad Arweave price: " + reward);
|
||||||
|
verifyBalance(gateway, w.address(), reward);
|
||||||
|
|
||||||
|
List<List<byte[]>> tagList = new ArrayList<>();
|
||||||
|
List<Map<String,String>> jsonTags = new ArrayList<>();
|
||||||
|
for (Tag tag : tags == null ? List.<Tag>of() : tags) addTag(jsonTags, tagList, tag.name(), tag.value());
|
||||||
|
|
||||||
|
byte[] signaturePayload = deepHash(List.of(
|
||||||
|
utf8("2"), b64Decode(w.owner()), new byte[0], utf8("0"), utf8(reward),
|
||||||
|
b64Decode(anchor), tagList, utf8(Integer.toString(data.length)), merkle.dataRoot()
|
||||||
|
));
|
||||||
|
byte[] rawSignature = signPayload(w.privateKey(), signaturePayload);
|
||||||
|
byte[] txIdBytes = sha256(rawSignature);
|
||||||
|
String txId = b64(txIdBytes);
|
||||||
|
|
||||||
|
Map<String,Object> tx = new LinkedHashMap<>();
|
||||||
|
tx.put("format",2); tx.put("id",txId); tx.put("last_tx",anchor); tx.put("owner",w.owner());
|
||||||
|
tx.put("tags",jsonTags); tx.put("target",""); tx.put("quantity","0");
|
||||||
|
tx.put("data_root",b64(merkle.dataRoot())); tx.put("data_size",Integer.toString(data.length)); tx.put("data","");
|
||||||
|
tx.put("reward",reward); tx.put("signature",b64(rawSignature));
|
||||||
|
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
|
||||||
|
|
||||||
|
for (int i=0;i<merkle.chunks().size();i++) {
|
||||||
|
ArweaveMerkle.Chunk meta=merkle.chunks().get(i); ArweaveMerkle.Proof proof=merkle.proofs().get(i);
|
||||||
|
Map<String,Object> body=new LinkedHashMap<>();
|
||||||
|
body.put("data_root",b64(merkle.dataRoot())); body.put("data_size",Integer.toString(data.length));
|
||||||
|
body.put("data_path",b64(proof.proof())); body.put("offset",Long.toString(proof.offset()));
|
||||||
|
body.put("chunk",b64(ArweaveMerkle.readChunk(data,meta)));
|
||||||
|
postChunkWithRetry(gateway,body,i,merkle.chunks().size());
|
||||||
|
}
|
||||||
|
if (cfg.minConfirmations() > 0) waitForConfirmations(txId);
|
||||||
|
log.info("ANS-104 bundle uploaded to Arweave: tx={} bytes={} items-transported",txId,data.length);
|
||||||
|
return new UploadResult(txId,txIdBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int waitForConfirmations(String txId) throws Exception {
|
||||||
|
String gateway=trimGateway(cfg.publishGateway());
|
||||||
|
long deadline=System.currentTimeMillis()+cfg.confirmTimeoutMinutes()*60_000L; int last=0;
|
||||||
|
while(System.currentTimeMillis()<deadline){
|
||||||
|
HttpRequest req=HttpRequest.newBuilder(URI.create(gateway+"/tx/"+txId+"/status")).timeout(Duration.ofSeconds(20)).GET().build();
|
||||||
|
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
if(resp.statusCode()==200){JsonNode j=MAPPER.readTree(resp.body());last=j.path("number_of_confirmations").asInt(0);if(last>=cfg.minConfirmations())return last;}
|
||||||
|
else if(resp.statusCode()!=202&&resp.statusCode()!=404)log.warn("Arweave status tx={} HTTP {}",txId,resp.statusCode());
|
||||||
|
Thread.sleep(cfg.confirmPollSeconds()*1000L);
|
||||||
|
}
|
||||||
|
throw new IOException("Arweave confirmation timeout for "+txId+", last="+last);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postChunkWithRetry(String gateway,Map<String,Object> body,int index,int total)throws Exception{
|
||||||
|
Exception last=null;
|
||||||
|
for(int attempt=1;attempt<=12;attempt++)try{postJsonExpect2xx(gateway+"/chunk",body,Duration.ofSeconds(60));return;}catch(Exception e){last=e;if(attempt==12)break;Thread.sleep(5000L);}
|
||||||
|
throw new IOException("Failed Arweave chunk "+(index+1)+"/"+total,last);
|
||||||
|
}
|
||||||
|
private void postJsonExpect2xx(String url,Object body,Duration timeout)throws Exception{
|
||||||
|
HttpRequest req=HttpRequest.newBuilder(URI.create(url)).timeout(timeout).header("Content-Type","application/json").header("Accept","application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(body),StandardCharsets.UTF_8)).build();
|
||||||
|
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("HTTP "+resp.statusCode()+" "+safe(resp.body()));
|
||||||
|
}
|
||||||
|
private void verifyBalance(String gateway,String address,String reward)throws Exception{
|
||||||
|
String balance=getRequiredText(gateway,"/wallet/"+address+"/balance");
|
||||||
|
if(balance.matches("^\\d+$")&&new BigInteger(balance).compareTo(new BigInteger(reward))<0)throw new IllegalStateException("Not enough AR: balance="+balance+" reward="+reward);
|
||||||
|
}
|
||||||
|
private String getRequiredText(String gateway,String path)throws Exception{
|
||||||
|
HttpRequest req=HttpRequest.newBuilder(URI.create(gateway+path)).timeout(Duration.ofSeconds(20)).GET().build();
|
||||||
|
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("Arweave HTTP "+resp.statusCode()+" for "+path+": "+safe(resp.body()));
|
||||||
|
String s=resp.body()==null?"":resp.body().trim();if(s.isBlank())throw new IOException("Empty Arweave response for "+path);return s;
|
||||||
|
}
|
||||||
|
private Wallet wallet()throws Exception{
|
||||||
|
Wallet local=wallet;if(local!=null)return local;synchronized(this){if(wallet!=null)return wallet;
|
||||||
|
JsonNode jwk=MAPPER.readTree(Files.readString(cfg.walletJwkPath(),StandardCharsets.UTF_8));String owner=required(jwk,"n");
|
||||||
|
PrivateKey pk=buildPrivateKey(jwk);String address=b64(sha256(b64Decode(owner)));return wallet=new Wallet(owner,address,pk);}
|
||||||
|
}
|
||||||
|
private static PrivateKey buildPrivateKey(JsonNode jwk)throws GeneralSecurityException{
|
||||||
|
RSAPrivateCrtKeySpec spec=new RSAPrivateCrtKeySpec(big(required(jwk,"n")),big(required(jwk,"e")),big(required(jwk,"d")),big(required(jwk,"p")),big(required(jwk,"q")),big(required(jwk,"dp")),big(required(jwk,"dq")),big(required(jwk,"qi")));
|
||||||
|
return KeyFactory.getInstance("RSA").generatePrivate(spec);
|
||||||
|
}
|
||||||
|
private static byte[] signPayload(PrivateKey key,byte[] payload)throws GeneralSecurityException{
|
||||||
|
Signature s=Signature.getInstance("RSASSA-PSS");s.setParameter(new PSSParameterSpec("SHA-256","MGF1",MGF1ParameterSpec.SHA256,32,1));s.initSign(key);s.update(payload);return s.sign();
|
||||||
|
}
|
||||||
|
private static byte[] deepHash(Object data)throws GeneralSecurityException{
|
||||||
|
if(data instanceof List<?> list){byte[] acc=sha384(utf8("list"+list.size()));for(Object item:list)acc=sha384(concat(acc,deepHash(item)));return acc;}
|
||||||
|
if(!(data instanceof byte[] bytes))throw new IllegalArgumentException("deepHash byte[]/List expected");return sha384(concat(sha384(utf8("blob"+bytes.length)),sha384(bytes)));
|
||||||
|
}
|
||||||
|
private static void addTag(List<Map<String,String>> json,List<List<byte[]>> raw,String name,String value){byte[]n=utf8(name),v=utf8(value);Map<String,String>m=new LinkedHashMap<>();m.put("name",b64(n));m.put("value",b64(v));json.add(m);raw.add(List.of(n,v));}
|
||||||
|
private static String required(JsonNode n,String f){String s=n==null?"":n.path(f).asText("").trim();if(s.isBlank())throw new IllegalStateException("Arweave JWK missing "+f);return s;}
|
||||||
|
private static BigInteger big(String s){return new BigInteger(1,b64Decode(s));}
|
||||||
|
private static String trimGateway(String s){return(s==null?"https://arweave.net":s.trim()).replaceAll("/+$","");}
|
||||||
|
private static byte[] utf8(String s){return String.valueOf(s==null?"":s).getBytes(StandardCharsets.UTF_8);}
|
||||||
|
private static byte[] b64Decode(String s){return B64D.decode(s);}private static String b64(byte[]b){return B64.encodeToString(b);}
|
||||||
|
private static byte[] sha256(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-256").digest(b);}private static byte[] sha384(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-384").digest(b);}
|
||||||
|
private static byte[] concat(byte[]...a){int n=0;for(byte[]x:a)n=Math.addExact(n,x.length);byte[]o=new byte[n];int p=0;for(byte[]x:a){System.arraycopy(x,0,o,p,x.length);p+=x.length;}return o;}
|
||||||
|
private static String safe(String s){s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return s.length()<=250?s:s.substring(0,250)+"...";}
|
||||||
|
private record Wallet(String owner,String address,PrivateKey privateKey){}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package server.archive;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arweave v2 data Merkle tree/proofs. Алгоритм повторяет arweave-js merkle.ts.
|
||||||
|
*/
|
||||||
|
final class ArweaveMerkle {
|
||||||
|
static final int MAX_CHUNK_SIZE = 256 * 1024;
|
||||||
|
static final int MIN_CHUNK_SIZE = 32 * 1024;
|
||||||
|
|
||||||
|
record Chunk(byte[] dataHash, long minByteRange, long maxByteRange) {
|
||||||
|
long size() { return maxByteRange - minByteRange; }
|
||||||
|
}
|
||||||
|
record Proof(long offset, byte[] proof) {}
|
||||||
|
record Prepared(byte[] dataRoot, List<Chunk> chunks, List<Proof> proofs) {}
|
||||||
|
|
||||||
|
private sealed interface Node permits Leaf, Branch {
|
||||||
|
byte[] id();
|
||||||
|
long maxByteRange();
|
||||||
|
}
|
||||||
|
private record Leaf(byte[] id, byte[] dataHash, long minByteRange, long maxByteRange) implements Node {}
|
||||||
|
private record Branch(byte[] id, long byteRange, long maxByteRange, Node left, Node right) implements Node {}
|
||||||
|
|
||||||
|
private ArweaveMerkle() {}
|
||||||
|
|
||||||
|
|
||||||
|
static Prepared prepare(byte[] data) throws IOException {
|
||||||
|
if (data == null || data.length == 0) throw new IOException("Arweave data is empty");
|
||||||
|
List<Chunk> chunks = chunkBytes(data);
|
||||||
|
List<Node> leaves = new ArrayList<>(chunks.size());
|
||||||
|
for (Chunk c : chunks) {
|
||||||
|
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
|
||||||
|
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
|
||||||
|
}
|
||||||
|
Node root = buildLayers(leaves);
|
||||||
|
List<Proof> proofs = new ArrayList<>();
|
||||||
|
resolveProofs(root, new byte[0], proofs);
|
||||||
|
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
|
||||||
|
chunks = new ArrayList<>(chunks);
|
||||||
|
chunks.remove(chunks.size() - 1);
|
||||||
|
proofs.remove(proofs.size() - 1);
|
||||||
|
}
|
||||||
|
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
|
||||||
|
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Chunk> chunkBytes(byte[] data) {
|
||||||
|
List<Chunk> out = new ArrayList<>();
|
||||||
|
long remaining = data.length;
|
||||||
|
long cursor = 0;
|
||||||
|
int p = 0;
|
||||||
|
while (remaining >= MAX_CHUNK_SIZE) {
|
||||||
|
long chunkSize = MAX_CHUNK_SIZE;
|
||||||
|
long next = remaining - MAX_CHUNK_SIZE;
|
||||||
|
if (next > 0 && next < MIN_CHUNK_SIZE) chunkSize = (remaining + 1) / 2;
|
||||||
|
byte[] chunk = Arrays.copyOfRange(data, p, p + (int) chunkSize);
|
||||||
|
out.add(new Chunk(sha256(chunk), cursor, cursor + chunkSize));
|
||||||
|
p += (int) chunkSize;
|
||||||
|
cursor += chunkSize;
|
||||||
|
remaining -= chunkSize;
|
||||||
|
}
|
||||||
|
byte[] rest = Arrays.copyOfRange(data, p, p + (int) remaining);
|
||||||
|
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] readChunk(byte[] data, Chunk c) throws IOException {
|
||||||
|
long len = c.size();
|
||||||
|
if (data == null || len < 0 || len > Integer.MAX_VALUE || c.minByteRange() < 0 || c.maxByteRange() > data.length) {
|
||||||
|
throw new IOException("Invalid Arweave chunk range");
|
||||||
|
}
|
||||||
|
return Arrays.copyOfRange(data, (int) c.minByteRange(), (int) c.maxByteRange());
|
||||||
|
}
|
||||||
|
|
||||||
|
static Prepared prepare(Path file) throws IOException {
|
||||||
|
long fileSize = Files.size(file);
|
||||||
|
List<Chunk> chunks = chunkFile(file, fileSize);
|
||||||
|
List<Node> leaves = new ArrayList<>(chunks.size());
|
||||||
|
for (Chunk c : chunks) {
|
||||||
|
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
|
||||||
|
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
|
||||||
|
}
|
||||||
|
if (leaves.isEmpty()) throw new IOException("Arweave Merkle: нет leaves");
|
||||||
|
Node root = buildLayers(leaves);
|
||||||
|
List<Proof> proofs = new ArrayList<>();
|
||||||
|
resolveProofs(root, new byte[0], proofs);
|
||||||
|
|
||||||
|
// arweave-js строит root вместе с нулевым последним chunk при точном кратном 256KiB,
|
||||||
|
// но сам нулевой chunk/proof не загружает.
|
||||||
|
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
|
||||||
|
chunks = new ArrayList<>(chunks);
|
||||||
|
chunks.remove(chunks.size() - 1);
|
||||||
|
proofs.remove(proofs.size() - 1);
|
||||||
|
}
|
||||||
|
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
|
||||||
|
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Chunk> chunkFile(Path file, long fileSize) throws IOException {
|
||||||
|
List<Chunk> out = new ArrayList<>();
|
||||||
|
long remaining = fileSize;
|
||||||
|
long cursor = 0;
|
||||||
|
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
|
||||||
|
while (remaining >= MAX_CHUNK_SIZE) {
|
||||||
|
long chunkSize = MAX_CHUNK_SIZE;
|
||||||
|
long next = remaining - MAX_CHUNK_SIZE;
|
||||||
|
if (next > 0 && next < MIN_CHUNK_SIZE) {
|
||||||
|
chunkSize = (remaining + 1) / 2;
|
||||||
|
}
|
||||||
|
byte[] data = readExact(in, (int)chunkSize);
|
||||||
|
out.add(new Chunk(sha256(data), cursor, cursor + chunkSize));
|
||||||
|
cursor += chunkSize;
|
||||||
|
remaining -= chunkSize;
|
||||||
|
}
|
||||||
|
byte[] rest = readExact(in, (int)remaining);
|
||||||
|
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Node buildLayers(List<Node> nodes) {
|
||||||
|
if (nodes.size() < 2) return nodes.get(0);
|
||||||
|
List<Node> next = new ArrayList<>((nodes.size() + 1) / 2);
|
||||||
|
for (int i = 0; i < nodes.size(); i += 2) {
|
||||||
|
Node left = nodes.get(i);
|
||||||
|
Node right = i + 1 < nodes.size() ? nodes.get(i + 1) : null;
|
||||||
|
if (right == null) {
|
||||||
|
next.add(left);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
long byteRange = left.maxByteRange();
|
||||||
|
byte[] id = sha256(concat(
|
||||||
|
sha256(left.id()),
|
||||||
|
sha256(right.id()),
|
||||||
|
sha256(note(byteRange))
|
||||||
|
));
|
||||||
|
next.add(new Branch(id, byteRange, right.maxByteRange(), left, right));
|
||||||
|
}
|
||||||
|
return buildLayers(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void resolveProofs(Node node, byte[] prefix, List<Proof> out) {
|
||||||
|
if (node instanceof Leaf leaf) {
|
||||||
|
out.add(new Proof(leaf.maxByteRange() - 1,
|
||||||
|
concat(prefix, leaf.dataHash(), note(leaf.maxByteRange()))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Branch b = (Branch) node;
|
||||||
|
byte[] partial = concat(prefix, b.left().id(), b.right().id(), note(b.byteRange()));
|
||||||
|
resolveProofs(b.left(), partial, out);
|
||||||
|
resolveProofs(b.right(), partial, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] readChunk(Path file, Chunk c) throws IOException {
|
||||||
|
long len = c.size();
|
||||||
|
if (len < 0 || len > Integer.MAX_VALUE) throw new IOException("Некорректный Arweave chunk size: " + len);
|
||||||
|
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
|
||||||
|
raf.seek(c.minByteRange());
|
||||||
|
byte[] data = new byte[(int)len];
|
||||||
|
raf.readFully(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readExact(InputStream in, int len) throws IOException {
|
||||||
|
byte[] out = new byte[len];
|
||||||
|
int pos = 0;
|
||||||
|
while (pos < len) {
|
||||||
|
int n = in.read(out, pos, len - pos);
|
||||||
|
if (n < 0) throw new EOFException("Неожиданный EOF");
|
||||||
|
pos += n;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] note(long value) {
|
||||||
|
if (value < 0) throw new IllegalArgumentException("negative note");
|
||||||
|
byte[] out = new byte[32];
|
||||||
|
long v = value;
|
||||||
|
for (int i = 31; i >= 0 && v != 0; i--) {
|
||||||
|
out[i] = (byte)(v & 0xff);
|
||||||
|
v >>>= 8;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] sha256(byte[] data) {
|
||||||
|
try { return MessageDigest.getInstance("SHA-256").digest(data); }
|
||||||
|
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] concat(byte[]... arrays) {
|
||||||
|
int len = 0;
|
||||||
|
for (byte[] a : arrays) len = Math.addExact(len, a.length);
|
||||||
|
byte[] out = new byte[len];
|
||||||
|
int p=0;
|
||||||
|
for (byte[] a : arrays) { System.arraycopy(a,0,out,p,a.length); p+=a.length; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package server.archive;
|
||||||
|
|
||||||
|
import blockchain.Ans104DataItem;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Uploads an already user-signed ANS-104 DataItem to Turbo without modifying it. */
|
||||||
|
public final class TurboDataItemUploader {
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
|
||||||
|
private static final Base64.Decoder B64URL_DECODER = Base64.getUrlDecoder();
|
||||||
|
|
||||||
|
public record UploadResult(String dataItemId, String owner) {}
|
||||||
|
|
||||||
|
private final ArweaveBlocksConfig cfg;
|
||||||
|
private final HttpClient http = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(20))
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build();
|
||||||
|
private volatile String resolvedPaidByAddress;
|
||||||
|
|
||||||
|
public TurboDataItemUploader(ArweaveBlocksConfig cfg) {
|
||||||
|
this.cfg = Objects.requireNonNull(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UploadResult upload(byte[] rawDataItem, byte[] expectedId32) throws Exception {
|
||||||
|
if (rawDataItem == null || rawDataItem.length == 0) throw new IllegalArgumentException("Turbo DataItem is empty");
|
||||||
|
Ans104DataItem item = new Ans104DataItem(rawDataItem);
|
||||||
|
if (!item.verifySignature()) throw new IllegalArgumentException("Turbo DataItem has bad ANS-104 signature");
|
||||||
|
if (expectedId32 != null && !Arrays.equals(item.id32(), expectedId32)) {
|
||||||
|
throw new IllegalArgumentException("Turbo DataItem id does not match blocks.data_item_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
String expectedId = B64URL.encodeToString(item.id32());
|
||||||
|
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(cfg.turboUploadUrl()))
|
||||||
|
.timeout(Duration.ofMinutes(2))
|
||||||
|
.header("Content-Type", "application/octet-stream")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofByteArray(rawDataItem));
|
||||||
|
String paidBy = paidByAddress();
|
||||||
|
if (paidBy != null) request.header("x-paid-by", paidBy);
|
||||||
|
|
||||||
|
HttpResponse<String> response = http.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
String body = response.body() == null ? "" : response.body().trim();
|
||||||
|
if (response.statusCode() == 409 && body.toLowerCase().contains("data item exists")) {
|
||||||
|
return new UploadResult(expectedId, B64URL.encodeToString(item.owner32()));
|
||||||
|
}
|
||||||
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||||
|
String hint = response.statusCode() == 402
|
||||||
|
? " (Turbo payment required: check server credits / Credit Share Approval / x-paid-by)"
|
||||||
|
: "";
|
||||||
|
throw new IOException("Turbo HTTP " + response.statusCode() + hint + ": " + safe(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.isBlank()) return new UploadResult(expectedId, B64URL.encodeToString(item.owner32()));
|
||||||
|
JsonNode json;
|
||||||
|
try { json = MAPPER.readTree(body); }
|
||||||
|
catch (Exception ignored) { return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); }
|
||||||
|
String returnedId = json.path("id").asText("").trim();
|
||||||
|
if (!returnedId.isBlank() && !returnedId.equals(expectedId)) {
|
||||||
|
throw new IOException("Turbo returned another DataItem id: expected=" + expectedId + " got=" + returnedId);
|
||||||
|
}
|
||||||
|
return new UploadResult(expectedId, json.path("owner").asText(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* x-paid-by contains the payer's public native address, never the private key.
|
||||||
|
* A configured Arweave JWK is used only to derive that public address.
|
||||||
|
*/
|
||||||
|
private String paidByAddress() throws Exception {
|
||||||
|
if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress;
|
||||||
|
synchronized (this) {
|
||||||
|
if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress;
|
||||||
|
String explicit = cfg.turboPaidByAddress();
|
||||||
|
if (explicit != null && !explicit.isBlank()) return resolvedPaidByAddress = explicit.trim();
|
||||||
|
if (cfg.turboWalletJwkPath() == null) {
|
||||||
|
resolvedPaidByAddress = "";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JsonNode jwk = MAPPER.readTree(Files.readString(cfg.turboWalletJwkPath(), StandardCharsets.UTF_8));
|
||||||
|
String modulus = jwk.path("n").asText("").trim();
|
||||||
|
if (modulus.isBlank()) throw new IllegalStateException("Turbo payer JWK missing n");
|
||||||
|
byte[] owner = B64URL_DECODER.decode(modulus);
|
||||||
|
resolvedPaidByAddress = B64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(owner));
|
||||||
|
return resolvedPaidByAddress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String value) {
|
||||||
|
String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim();
|
||||||
|
return v.length() <= 500 ? v : v.substring(0, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package server.archive;
|
||||||
|
|
||||||
|
import blockchain.Ans104DataItem;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import utils.crypto.Ed25519Util;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
final class Ans104BundleTest {
|
||||||
|
@Test
|
||||||
|
void writeReadAndFindPreserveExactDataItems() {
|
||||||
|
byte[] a = item("a");
|
||||||
|
byte[] b = item("b");
|
||||||
|
byte[] bundle = Ans104Bundle.write(List.of(a, b));
|
||||||
|
|
||||||
|
List<Ans104Bundle.Entry> entries = Ans104Bundle.read(bundle, 10);
|
||||||
|
assertEquals(2, entries.size());
|
||||||
|
assertArrayEquals(a, entries.get(0).rawDataItem());
|
||||||
|
assertArrayEquals(b, entries.get(1).rawDataItem());
|
||||||
|
|
||||||
|
byte[] bId = new Ans104DataItem(b).id32();
|
||||||
|
assertArrayEquals(b, Ans104Bundle.find(bundle, bId, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] item(String payload) {
|
||||||
|
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("bundle-" + payload);
|
||||||
|
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
|
||||||
|
byte[] data = payload.getBytes(StandardCharsets.UTF_8);
|
||||||
|
List<Ans104DataItem.Tag> tags = List.of(new Ans104DataItem.Tag("App", "test5590"));
|
||||||
|
byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey);
|
||||||
|
return Ans104DataItem.serializeEd25519(signature, owner, tags, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
package blockchain;
|
||||||
|
|
||||||
|
import utils.crypto.Ed25519Util;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal ANS-104 DataItem parser/verifier used by SHiNE.
|
||||||
|
*
|
||||||
|
* SHiNE accepts only generic Ed25519 signature type 2:
|
||||||
|
* uint16le signatureType
|
||||||
|
* signature[64]
|
||||||
|
* owner[32]
|
||||||
|
* target_present[1] + target[32] when present
|
||||||
|
* anchor_present[1] + anchor[32] when present
|
||||||
|
* tags_count uint64le
|
||||||
|
* tags_size uint64le
|
||||||
|
* avro_tags[tags_size]
|
||||||
|
* data[*]
|
||||||
|
*
|
||||||
|
* The signing message matches the deployed arbundles/Turbo DataItem implementation:
|
||||||
|
* deepHash(["dataitem", "1", signatureType, owner, target, anchor, rawAvroTags, data]).
|
||||||
|
*/
|
||||||
|
public final class Ans104DataItem {
|
||||||
|
public static final int SIGNATURE_TYPE_ED25519 = 2;
|
||||||
|
public static final int SIGNATURE_LEN = 64;
|
||||||
|
public static final int OWNER_LEN = 32;
|
||||||
|
public static final int OPTIONAL_FIELD_LEN = 32;
|
||||||
|
public static final int ID_LEN = 32;
|
||||||
|
public static final int MAX_DATA_ITEM_BYTES = 8 * 1024 * 1024;
|
||||||
|
public static final int MAX_TAGS_BYTES = 64 * 1024;
|
||||||
|
public static final long MAX_TAGS_COUNT = 128;
|
||||||
|
|
||||||
|
public record Tag(String name, String value) {}
|
||||||
|
|
||||||
|
private final int signatureType;
|
||||||
|
private final byte[] signature;
|
||||||
|
private final byte[] owner;
|
||||||
|
private final byte[] target;
|
||||||
|
private final byte[] anchor;
|
||||||
|
private final long tagsCount;
|
||||||
|
private final byte[] rawTags;
|
||||||
|
private final List<Tag> tags;
|
||||||
|
private final byte[] data;
|
||||||
|
private final byte[] rawBytes;
|
||||||
|
private final byte[] id;
|
||||||
|
|
||||||
|
public Ans104DataItem(byte[] bytes) {
|
||||||
|
Objects.requireNonNull(bytes, "bytes == null");
|
||||||
|
if (bytes.length < 2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + 1) {
|
||||||
|
throw new IllegalArgumentException("ANS-104 DataItem too short: " + bytes.length);
|
||||||
|
}
|
||||||
|
if (bytes.length > MAX_DATA_ITEM_BYTES) {
|
||||||
|
throw new IllegalArgumentException("ANS-104 DataItem too large: " + bytes.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||||
|
this.signatureType = Short.toUnsignedInt(bb.getShort());
|
||||||
|
if (signatureType != SIGNATURE_TYPE_ED25519) {
|
||||||
|
throw new IllegalArgumentException("Unsupported ANS-104 signature type: " + signatureType + " (expected 2)");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.signature = readExact(bb, SIGNATURE_LEN, "signature");
|
||||||
|
this.owner = readExact(bb, OWNER_LEN, "owner");
|
||||||
|
this.target = readOptional32(bb, "target");
|
||||||
|
this.anchor = readOptional32(bb, "anchor");
|
||||||
|
|
||||||
|
this.tagsCount = readUnsignedLongAsNonNegative(bb, "tags_count");
|
||||||
|
long tagsSizeLong = readUnsignedLongAsNonNegative(bb, "tags_size");
|
||||||
|
if (tagsCount > MAX_TAGS_COUNT) {
|
||||||
|
throw new IllegalArgumentException("Too many ANS-104 tags: " + tagsCount);
|
||||||
|
}
|
||||||
|
if (tagsSizeLong > MAX_TAGS_BYTES || tagsSizeLong > bb.remaining()) {
|
||||||
|
throw new IllegalArgumentException("Bad ANS-104 tags_size: " + tagsSizeLong + ", remaining=" + bb.remaining());
|
||||||
|
}
|
||||||
|
this.rawTags = readExact(bb, (int) tagsSizeLong, "tags");
|
||||||
|
this.tags = Collections.unmodifiableList(parseAvroTags(rawTags, tagsCount));
|
||||||
|
this.data = new byte[bb.remaining()];
|
||||||
|
bb.get(this.data);
|
||||||
|
this.rawBytes = Arrays.copyOf(bytes, bytes.length);
|
||||||
|
this.id = sha256(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int signatureType() { return signatureType; }
|
||||||
|
public byte[] signature64() { return Arrays.copyOf(signature, signature.length); }
|
||||||
|
public byte[] owner32() { return Arrays.copyOf(owner, owner.length); }
|
||||||
|
public byte[] target() { return Arrays.copyOf(target, target.length); }
|
||||||
|
public byte[] anchor() { return Arrays.copyOf(anchor, anchor.length); }
|
||||||
|
public long tagsCount() { return tagsCount; }
|
||||||
|
public byte[] rawTags() { return Arrays.copyOf(rawTags, rawTags.length); }
|
||||||
|
public List<Tag> tags() { return tags; }
|
||||||
|
public byte[] data() { return Arrays.copyOf(data, data.length); }
|
||||||
|
public byte[] rawBytes() { return Arrays.copyOf(rawBytes, rawBytes.length); }
|
||||||
|
public byte[] id32() { return Arrays.copyOf(id, id.length); }
|
||||||
|
|
||||||
|
public String tagValue(String name) {
|
||||||
|
if (name == null) return null;
|
||||||
|
for (Tag tag : tags) {
|
||||||
|
if (name.equals(tag.name())) return tag.value();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasTag(String name, String value) {
|
||||||
|
for (Tag tag : tags) {
|
||||||
|
if (Objects.equals(name, tag.name()) && Objects.equals(value, tag.value())) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean verifySignature() {
|
||||||
|
return Ed25519Util.verify(signingMessage(), signature, owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean ownerEquals(byte[] expectedOwner32) {
|
||||||
|
return expectedOwner32 != null && expectedOwner32.length == OWNER_LEN && Arrays.equals(owner, expectedOwner32);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] signingMessage() {
|
||||||
|
return buildSigningMessage(owner, target, anchor, tags, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the exact ANS-104 signing message for generic Ed25519 DataItems.
|
||||||
|
* Useful for SHiNE clients/tests that must sign a DataItem before serializing it.
|
||||||
|
*/
|
||||||
|
public static byte[] buildSigningMessage(byte[] owner32, List<Tag> tags, byte[] data) {
|
||||||
|
return buildSigningMessage(owner32, new byte[0], new byte[0], tags, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] buildSigningMessage(byte[] owner32, byte[] target, byte[] anchor, List<Tag> tags, byte[] data) {
|
||||||
|
requireLen(owner32, OWNER_LEN, "owner");
|
||||||
|
byte[] safeTarget = target == null ? new byte[0] : Arrays.copyOf(target, target.length);
|
||||||
|
byte[] safeAnchor = anchor == null ? new byte[0] : Arrays.copyOf(anchor, anchor.length);
|
||||||
|
if (!(safeTarget.length == 0 || safeTarget.length == OPTIONAL_FIELD_LEN)) {
|
||||||
|
throw new IllegalArgumentException("target must be empty or 32 bytes");
|
||||||
|
}
|
||||||
|
if (!(safeAnchor.length == 0 || safeAnchor.length == OPTIONAL_FIELD_LEN)) {
|
||||||
|
throw new IllegalArgumentException("anchor must be empty or 32 bytes");
|
||||||
|
}
|
||||||
|
List<Tag> safeTags = validateTags(tags);
|
||||||
|
byte[] safeData = data == null ? new byte[0] : Arrays.copyOf(data, data.length);
|
||||||
|
|
||||||
|
byte[] rawTags = serializeAvroTags(safeTags);
|
||||||
|
return deepHash(List.of(
|
||||||
|
utf8("dataitem"),
|
||||||
|
utf8("1"),
|
||||||
|
utf8(Integer.toString(SIGNATURE_TYPE_ED25519)),
|
||||||
|
Arrays.copyOf(owner32, owner32.length),
|
||||||
|
safeTarget,
|
||||||
|
safeAnchor,
|
||||||
|
rawTags,
|
||||||
|
safeData
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize one SHiNE-ready ANS-104 DataItem with empty target/anchor. */
|
||||||
|
public static byte[] serializeEd25519(byte[] signature64, byte[] owner32, List<Tag> tags, byte[] data) {
|
||||||
|
requireLen(signature64, SIGNATURE_LEN, "signature");
|
||||||
|
requireLen(owner32, OWNER_LEN, "owner");
|
||||||
|
List<Tag> safeTags = validateTags(tags);
|
||||||
|
byte[] rawTags = serializeAvroTags(safeTags);
|
||||||
|
byte[] safeData = data == null ? new byte[0] : data;
|
||||||
|
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream(
|
||||||
|
2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + rawTags.length + safeData.length);
|
||||||
|
writeU16LE(out, SIGNATURE_TYPE_ED25519);
|
||||||
|
out.writeBytes(signature64);
|
||||||
|
out.writeBytes(owner32);
|
||||||
|
out.write(0); // target absent
|
||||||
|
out.write(0); // anchor absent
|
||||||
|
writeU64LE(out, safeTags.size());
|
||||||
|
writeU64LE(out, rawTags.length);
|
||||||
|
out.writeBytes(rawTags);
|
||||||
|
out.writeBytes(safeData);
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readOptional32(ByteBuffer bb, String name) {
|
||||||
|
if (!bb.hasRemaining()) throw new IllegalArgumentException("Missing " + name + " presence byte");
|
||||||
|
int present = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (present == 0) return new byte[0];
|
||||||
|
if (present != 1) throw new IllegalArgumentException("Bad " + name + " presence byte: " + present);
|
||||||
|
return readExact(bb, OPTIONAL_FIELD_LEN, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readExact(ByteBuffer bb, int len, String field) {
|
||||||
|
if (len < 0 || bb.remaining() < len) {
|
||||||
|
throw new IllegalArgumentException("ANS-104 " + field + " truncated: need=" + len + " remaining=" + bb.remaining());
|
||||||
|
}
|
||||||
|
byte[] out = new byte[len];
|
||||||
|
bb.get(out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long readUnsignedLongAsNonNegative(ByteBuffer bb, String field) {
|
||||||
|
if (bb.remaining() < 8) throw new IllegalArgumentException("Missing ANS-104 " + field);
|
||||||
|
long v = bb.getLong();
|
||||||
|
if (v < 0) throw new IllegalArgumentException("ANS-104 " + field + " exceeds signed long range");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Tag> validateTags(List<Tag> tags) {
|
||||||
|
List<Tag> safe = tags == null ? List.of() : List.copyOf(tags);
|
||||||
|
if (safe.size() > MAX_TAGS_COUNT) {
|
||||||
|
throw new IllegalArgumentException("Too many ANS-104 tags: " + safe.size());
|
||||||
|
}
|
||||||
|
for (Tag tag : safe) {
|
||||||
|
if (tag == null || tag.name() == null || tag.value() == null || tag.name().isEmpty() || tag.value().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
|
||||||
|
}
|
||||||
|
if (utf8(tag.name()).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
|
||||||
|
if (utf8(tag.value()).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
|
||||||
|
}
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] serializeAvroTags(List<Tag> tags) {
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
if (!tags.isEmpty()) {
|
||||||
|
writeAvroLong(out, tags.size());
|
||||||
|
for (Tag tag : tags) {
|
||||||
|
writeAvroString(out, tag.name());
|
||||||
|
writeAvroString(out, tag.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeAvroLong(out, 0); // array terminator; also the complete encoding for an empty array
|
||||||
|
byte[] raw = out.toByteArray();
|
||||||
|
if (raw.length > MAX_TAGS_BYTES) throw new IllegalArgumentException("ANS-104 tags exceed " + MAX_TAGS_BYTES + " bytes");
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeAvroString(ByteArrayOutputStream out, String value) {
|
||||||
|
byte[] bytes = utf8(value);
|
||||||
|
writeAvroLong(out, bytes.length);
|
||||||
|
out.writeBytes(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeAvroLong(ByteArrayOutputStream out, long value) {
|
||||||
|
long n = (value << 1) ^ (value >> 63);
|
||||||
|
while ((n & ~0x7fL) != 0) {
|
||||||
|
out.write((int) ((n & 0x7f) | 0x80));
|
||||||
|
n >>>= 7;
|
||||||
|
}
|
||||||
|
out.write((int) n);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeU16LE(ByteArrayOutputStream out, int value) {
|
||||||
|
out.write(value & 0xff);
|
||||||
|
out.write((value >>> 8) & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeU64LE(ByteArrayOutputStream out, long value) {
|
||||||
|
for (int i = 0; i < 8; i++) out.write((int) ((value >>> (8 * i)) & 0xff));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireLen(byte[] bytes, int expected, String name) {
|
||||||
|
if (bytes == null || bytes.length != expected) {
|
||||||
|
throw new IllegalArgumentException(name + " must be " + expected + " bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Tag> parseAvroTags(byte[] raw, long declaredCount) {
|
||||||
|
AvroCursor c = new AvroCursor(raw);
|
||||||
|
List<Tag> out = new ArrayList<>();
|
||||||
|
while (!c.eof()) {
|
||||||
|
long count = c.readLong();
|
||||||
|
if (count == 0) break;
|
||||||
|
if (count < 0) {
|
||||||
|
count = -count;
|
||||||
|
long blockBytes = c.readLong();
|
||||||
|
if (blockBytes < 0) throw new IllegalArgumentException("Negative Avro tag block size");
|
||||||
|
}
|
||||||
|
if (count > MAX_TAGS_COUNT || out.size() + count > MAX_TAGS_COUNT) {
|
||||||
|
throw new IllegalArgumentException("Too many Avro tags");
|
||||||
|
}
|
||||||
|
for (long i = 0; i < count; i++) {
|
||||||
|
String name = c.readString();
|
||||||
|
String value = c.readString();
|
||||||
|
if (name.isEmpty() || value.isEmpty()) throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
|
||||||
|
if (name.getBytes(StandardCharsets.UTF_8).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
|
||||||
|
if (value.getBytes(StandardCharsets.UTF_8).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
|
||||||
|
out.add(new Tag(name, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!c.eof()) {
|
||||||
|
throw new IllegalArgumentException("Trailing bytes in ANS-104 Avro tags");
|
||||||
|
}
|
||||||
|
if (out.size() != declaredCount) {
|
||||||
|
throw new IllegalArgumentException("ANS-104 tags_count mismatch: declared=" + declaredCount + " parsed=" + out.size());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class AvroCursor {
|
||||||
|
private final byte[] data;
|
||||||
|
private int p;
|
||||||
|
private AvroCursor(byte[] data) { this.data = data == null ? new byte[0] : data; }
|
||||||
|
boolean eof() { return p == data.length; }
|
||||||
|
long readLong() {
|
||||||
|
long raw = 0;
|
||||||
|
int shift = 0;
|
||||||
|
while (true) {
|
||||||
|
if (p >= data.length) throw new IllegalArgumentException("Truncated Avro long");
|
||||||
|
int b = data[p++] & 0xff;
|
||||||
|
raw |= (long) (b & 0x7f) << shift;
|
||||||
|
if ((b & 0x80) == 0) break;
|
||||||
|
shift += 7;
|
||||||
|
if (shift > 63) throw new IllegalArgumentException("Avro long too large");
|
||||||
|
}
|
||||||
|
return (raw >>> 1) ^ -(raw & 1L);
|
||||||
|
}
|
||||||
|
String readString() {
|
||||||
|
long len = readLong();
|
||||||
|
if (len < 0 || len > Integer.MAX_VALUE || p + len > data.length) {
|
||||||
|
throw new IllegalArgumentException("Bad Avro string length: " + len);
|
||||||
|
}
|
||||||
|
String s = new String(data, p, (int) len, StandardCharsets.UTF_8);
|
||||||
|
p += (int) len;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] deepHash(Object item) {
|
||||||
|
if (item instanceof byte[] bytes) {
|
||||||
|
return deepHashBlob(bytes);
|
||||||
|
}
|
||||||
|
if (item instanceof List<?> items) {
|
||||||
|
byte[] acc = sha384(utf8("list" + items.size()));
|
||||||
|
for (Object child : items) {
|
||||||
|
acc = sha384(concat(acc, deepHash(child)));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unsupported deepHash item: " + (item == null ? "null" : item.getClass()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] deepHashBlob(byte[] bytes) {
|
||||||
|
byte[] tag = utf8("blob" + bytes.length);
|
||||||
|
return sha384(concat(sha384(tag), sha384(bytes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] utf8(String s) { return s.getBytes(StandardCharsets.UTF_8); }
|
||||||
|
private static byte[] sha256(byte[] b) { return digest("SHA-256", b); }
|
||||||
|
private static byte[] sha384(byte[] b) { return digest("SHA-384", b); }
|
||||||
|
private static byte[] digest(String alg, byte[] b) {
|
||||||
|
try { return MessageDigest.getInstance(alg).digest(b); }
|
||||||
|
catch (Exception e) { throw new IllegalStateException(alg + " unavailable", e); }
|
||||||
|
}
|
||||||
|
private static byte[] concat(byte[] a, byte[] b) {
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream(a.length + b.length);
|
||||||
|
out.writeBytes(a); out.writeBytes(b); return out.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,363 +9,115 @@ import java.util.Arrays;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BchBlockEntry — универсальный блок формата SHiNE (Frame v0).
|
* SHiNE user block, Frame v1, carried as the data payload of an ANS-104 DataItem.
|
||||||
*
|
*
|
||||||
* =========================================================================
|
* The constructor accepts the complete serialized ANS-104 DataItem because this is
|
||||||
* FRAME v0 — ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА)
|
* exactly what is stored in PostgreSQL and exchanged between servers.
|
||||||
* =========================================================================
|
|
||||||
*
|
*
|
||||||
* Все числа BigEndian.
|
* SHiNE Frame v1 (all integer fields BigEndian):
|
||||||
|
* [2] frameCode = 0x0001
|
||||||
|
* [32] prevHash32 = SHA-256(Frame v1 bytes of previous SHiNE block)
|
||||||
|
* [4] blockSize = exact Frame v1 byte length
|
||||||
|
* [4] blockNumber
|
||||||
|
* [8] timestamp unix seconds
|
||||||
|
* [2] type
|
||||||
|
* [2] subType
|
||||||
|
* [2] version
|
||||||
|
* [N] bodyBytes
|
||||||
*
|
*
|
||||||
* PREIMAGE (входит в blockSize, подписывается):
|
* The user signature is NOT stored in this frame. It is the single Ed25519/Solana
|
||||||
* [2] frameCode (uint16) код/версия рамки:
|
* signature of the surrounding ANS-104 DataItem.
|
||||||
* - 0x0000 = Frame v0 (текущий)
|
|
||||||
* [32] prevHash32 (bytes) SHA-256(preimage) предыдущего блока (цепочка)
|
|
||||||
* [4] blockSize (int32) размер preimage (в байтах), ВКЛЮЧАЯ frameCode,
|
|
||||||
* НО БЕЗ sigMarker и БЕЗ signature64
|
|
||||||
* [4] blockNumber (int32) глобальный номер блока (>=0)
|
|
||||||
* [8] timestamp (int64) unix seconds
|
|
||||||
* [2] type (uint16) тип сообщения
|
|
||||||
* [2] subType (uint16) подтип сообщения
|
|
||||||
* [2] version (uint16) версия формата сообщения
|
|
||||||
* [N] bodyBytes (bytes) тело сообщения (БЕЗ type/subType/version)
|
|
||||||
*
|
|
||||||
* TAIL (НЕ входит в blockSize, НЕ подписывается в Frame v0):
|
|
||||||
* [2] sigMarker (uint16) маркер подписи:
|
|
||||||
* - 0x0100 (256) = далее подпись Ed25519 64 байта
|
|
||||||
* [64] signature64 (bytes) Ed25519 signature над hash32
|
|
||||||
*
|
|
||||||
* hash32 НЕ хранится в блоке.
|
|
||||||
* hash32 вычисляется при парсинге:
|
|
||||||
* preimage = первые blockSize байт
|
|
||||||
* hash32 = SHA-256(preimage)
|
|
||||||
*
|
|
||||||
* Правила MVP-парсера (Frame v0):
|
|
||||||
* - frameCode должен быть строго 0x0000, иначе REJECT.
|
|
||||||
* - sigMarker должен быть строго 0x0100, иначе REJECT.
|
|
||||||
* - подпись обязана присутствовать всегда (sigMarker+signature64).
|
|
||||||
* - НИКАКИХ fallback-веток “если маркер другой, то подписи нет/другой хвост”.
|
|
||||||
*
|
|
||||||
* Важно по безопасности:
|
|
||||||
* - sigMarker в v0 не входит в подписываемые байты → его можно подменить,
|
|
||||||
* поэтому единственная безопасная логика: "если не 0x0100 — reject".
|
|
||||||
* =========================================================================
|
|
||||||
*/
|
*/
|
||||||
public final class BchBlockEntry {
|
public final class BchBlockEntry {
|
||||||
|
|
||||||
public static final int SIGNATURE_LEN = 64;
|
|
||||||
public static final int HASH_LEN = 32;
|
public static final int HASH_LEN = 32;
|
||||||
|
public static final int FRAME_CODE_V1 = 0x0001;
|
||||||
public static final int FRAME_CODE_LEN = 2;
|
public static final int MAX_BLOCK_FULL_BYTES = Ans104DataItem.MAX_DATA_ITEM_BYTES;
|
||||||
public static final int SIG_MARKER_LEN = 2;
|
|
||||||
|
|
||||||
/** Frame v0 */
|
|
||||||
public static final int FRAME_CODE_V0 = 0x0000;
|
|
||||||
|
|
||||||
/** sigMarker: 256 = 0x0100 */
|
|
||||||
public static final int SIG_MARKER_ED25519 = 0x0100;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Максимальный допустимый размер блока (fullBytes = preimage + sigMarker + signature),
|
|
||||||
* чтобы не уложить сервер по памяти/диску.
|
|
||||||
*/
|
|
||||||
public static final int MAX_BLOCK_FULL_BYTES = 4 * 1024 * 1024;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Насколько блок может “обгонять” текущее время (защита от кривых часов/вбросов).
|
|
||||||
* Если timestamp больше now + 60 сек — блок считаем неверным.
|
|
||||||
*/
|
|
||||||
public static final long MAX_FUTURE_SECONDS = 60;
|
public static final long MAX_FUTURE_SECONDS = 60;
|
||||||
|
public static final int FRAME_HEADER_SIZE = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2;
|
||||||
|
|
||||||
/**
|
public final int frameCode;
|
||||||
* Размер фиксированной части PREIMAGE (без bodyBytes).
|
public final byte[] prevHash32;
|
||||||
*
|
public final int blockSize;
|
||||||
* PREIMAGE header:
|
public final int blockNumber;
|
||||||
* frameCode(2) + prevHash32(32) + blockSize(4) + blockNumber(4) + timestamp(8)
|
|
||||||
* + type(2) + subType(2) + version(2)
|
|
||||||
*/
|
|
||||||
public static final int PREIMAGE_HEADER_SIZE =
|
|
||||||
2 // frameCode
|
|
||||||
+ 32 // prevHash32
|
|
||||||
+ 4 // blockSize
|
|
||||||
+ 4 // blockNumber
|
|
||||||
+ 8 // timestamp
|
|
||||||
+ 2 // type
|
|
||||||
+ 2 // subType
|
|
||||||
+ 2; // version
|
|
||||||
|
|
||||||
/** Минимальный полный размер блока (без bodyBytes). */
|
|
||||||
public static final int MIN_FULL_BYTES =
|
|
||||||
PREIMAGE_HEADER_SIZE + SIG_MARKER_LEN + SIGNATURE_LEN;
|
|
||||||
|
|
||||||
// --- HEADER (PREIMAGE) ---
|
|
||||||
public final int frameCode; // uint16 (v0=0)
|
|
||||||
public final byte[] prevHash32; // 32
|
|
||||||
public final int blockSize; // preimage size (включая frameCode)
|
|
||||||
public final int blockNumber; // >=0
|
|
||||||
public final long timestamp;
|
public final long timestamp;
|
||||||
public final short type;
|
public final short type;
|
||||||
public final short subType;
|
public final short subType;
|
||||||
public final short version;
|
public final short version;
|
||||||
|
|
||||||
// --- BODY (PREIMAGE) ---
|
|
||||||
public final byte[] bodyBytes;
|
public final byte[] bodyBytes;
|
||||||
|
|
||||||
/** Распарсенное тело (создаётся сразу при парсинге блока). */
|
|
||||||
public final BodyRecord body;
|
public final BodyRecord body;
|
||||||
|
|
||||||
// --- TAIL ---
|
private final Ans104DataItem dataItem;
|
||||||
public final int sigMarker; // uint16 (v0: 0x0100)
|
private final byte[] frameBytes;
|
||||||
private final byte[] signature64; // 64
|
private final byte[] hash32;
|
||||||
|
|
||||||
// --- derived ---
|
/** Parse a complete serialized ANS-104 DataItem containing one SHiNE Frame v1. */
|
||||||
private final byte[] hash32; // 32, computed
|
public BchBlockEntry(byte[] fullDataItemBytes) {
|
||||||
private final byte[] preimage; // blockSize bytes
|
Objects.requireNonNull(fullDataItemBytes, "fullDataItemBytes == null");
|
||||||
private final byte[] fullBytes; // preimage + sigMarker + signature
|
this.dataItem = new Ans104DataItem(fullDataItemBytes);
|
||||||
|
this.frameBytes = dataItem.data();
|
||||||
/* ===================================================================== */
|
if (frameBytes.length < FRAME_HEADER_SIZE) {
|
||||||
/* ====================== Конструктор из байт ========================== */
|
throw new IllegalArgumentException("SHiNE Frame v1 too short: " + frameBytes.length);
|
||||||
/* ===================================================================== */
|
|
||||||
|
|
||||||
public BchBlockEntry(byte[] fullBytes) {
|
|
||||||
Objects.requireNonNull(fullBytes, "fullBytes == null");
|
|
||||||
|
|
||||||
if (fullBytes.length < MIN_FULL_BYTES) {
|
|
||||||
throw new IllegalArgumentException("Block too short: " + fullBytes.length + " < " + MIN_FULL_BYTES);
|
|
||||||
}
|
|
||||||
if (fullBytes.length > MAX_BLOCK_FULL_BYTES) {
|
|
||||||
throw new IllegalArgumentException("Block too large: " + fullBytes.length + " > " + MAX_BLOCK_FULL_BYTES);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(frameBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
// [2] frameCode
|
|
||||||
this.frameCode = Short.toUnsignedInt(bb.getShort());
|
this.frameCode = Short.toUnsignedInt(bb.getShort());
|
||||||
if (this.frameCode != FRAME_CODE_V0) {
|
if (frameCode != FRAME_CODE_V1) {
|
||||||
throw new IllegalArgumentException(String.format(
|
throw new IllegalArgumentException(String.format("Bad SHiNE frameCode: 0x%04X (expected 0x%04X)", frameCode, FRAME_CODE_V1));
|
||||||
"Bad frameCode: 0x%04X (expected 0x%04X)", this.frameCode, FRAME_CODE_V0
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// [32] prevHash32
|
|
||||||
this.prevHash32 = new byte[32];
|
this.prevHash32 = new byte[32];
|
||||||
bb.get(this.prevHash32);
|
bb.get(prevHash32);
|
||||||
|
|
||||||
// [4] blockSize
|
|
||||||
this.blockSize = bb.getInt();
|
this.blockSize = bb.getInt();
|
||||||
if (blockSize < PREIMAGE_HEADER_SIZE) {
|
if (blockSize != frameBytes.length || blockSize < FRAME_HEADER_SIZE) {
|
||||||
throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE);
|
throw new IllegalArgumentException("SHiNE blockSize mismatch: declared=" + blockSize + " actual=" + frameBytes.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// fullLen must match exactly: blockSize + sigMarker(2) + signature(64)
|
|
||||||
int expectedFullLen = blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
|
|
||||||
if (expectedFullLen != fullBytes.length) {
|
|
||||||
throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize
|
|
||||||
+ " expectedFullLen=" + expectedFullLen
|
|
||||||
+ " fullLen=" + fullBytes.length);
|
|
||||||
}
|
|
||||||
if (expectedFullLen > MAX_BLOCK_FULL_BYTES) {
|
|
||||||
throw new IllegalArgumentException("Block too large by blockSize: " + expectedFullLen + " > " + MAX_BLOCK_FULL_BYTES);
|
|
||||||
}
|
|
||||||
|
|
||||||
// [4] blockNumber
|
|
||||||
this.blockNumber = bb.getInt();
|
this.blockNumber = bb.getInt();
|
||||||
if (this.blockNumber < 0) {
|
if (blockNumber < 0) throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
|
||||||
throw new IllegalArgumentException("blockNumber < 0: " + this.blockNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
// [8] timestamp
|
|
||||||
this.timestamp = bb.getLong();
|
this.timestamp = bb.getLong();
|
||||||
|
|
||||||
// запрет “в будущее” больше чем на 1 минуту
|
|
||||||
long now = Instant.now().getEpochSecond();
|
long now = Instant.now().getEpochSecond();
|
||||||
if (this.timestamp > now + MAX_FUTURE_SECONDS) {
|
if (timestamp > now + MAX_FUTURE_SECONDS) {
|
||||||
throw new IllegalArgumentException("timestamp is too far in future: ts=" + this.timestamp
|
throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp + " now=" + now);
|
||||||
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// [2][2][2] type/subType/version
|
|
||||||
this.type = bb.getShort();
|
this.type = bb.getShort();
|
||||||
this.subType = bb.getShort();
|
this.subType = bb.getShort();
|
||||||
this.version = bb.getShort();
|
this.version = bb.getShort();
|
||||||
|
this.bodyBytes = new byte[bb.remaining()];
|
||||||
// [N] bodyBytes
|
bb.get(bodyBytes);
|
||||||
int bodyLen = blockSize - PREIMAGE_HEADER_SIZE;
|
this.body = BodyRecordParser.parse(type, subType, version, bodyBytes);
|
||||||
if (bodyLen < 0) {
|
this.hash32 = BchCryptoVerifier.sha256(frameBytes);
|
||||||
throw new IllegalArgumentException("Invalid body length: " + bodyLen);
|
|
||||||
}
|
|
||||||
this.bodyBytes = new byte[bodyLen];
|
|
||||||
bb.get(this.bodyBytes);
|
|
||||||
|
|
||||||
// TAIL: [2] sigMarker
|
|
||||||
this.sigMarker = Short.toUnsignedInt(bb.getShort());
|
|
||||||
if (this.sigMarker != SIG_MARKER_ED25519) {
|
|
||||||
throw new IllegalArgumentException(String.format(
|
|
||||||
"Bad sigMarker: 0x%04X (expected 0x%04X)", this.sigMarker, SIG_MARKER_ED25519
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// TAIL: [64] signature64
|
|
||||||
this.signature64 = new byte[SIGNATURE_LEN];
|
|
||||||
bb.get(this.signature64);
|
|
||||||
|
|
||||||
// preimage = первые blockSize байт (включая frameCode)
|
|
||||||
this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize);
|
|
||||||
|
|
||||||
// hash32 = sha256(preimage)
|
|
||||||
this.hash32 = BchCryptoVerifier.sha256(preimage);
|
|
||||||
|
|
||||||
// parse body по header.type/subType/version + ОБЯЗАТЕЛЬНЫЙ check()
|
|
||||||
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
|
|
||||||
|
|
||||||
this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length);
|
|
||||||
|
|
||||||
if (bb.remaining() != 0) {
|
|
||||||
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================================================================== */
|
public Ans104DataItem getDataItem() { return dataItem; }
|
||||||
/* ====================== Конструктор сборки ============================ */
|
public byte[] getFrameBytes() { return Arrays.copyOf(frameBytes, frameBytes.length); }
|
||||||
/* ===================================================================== */
|
/** Kept as an alias for code/docs that called the old field preimage. */
|
||||||
|
public byte[] getPreimageBytes() { return getFrameBytes(); }
|
||||||
public BchBlockEntry(byte[] prevHash32,
|
public byte[] getSignature64() { return dataItem.signature64(); }
|
||||||
int blockNumber,
|
public byte[] getOwner32() { return dataItem.owner32(); }
|
||||||
long timestamp,
|
public byte[] getDataItemId32() { return dataItem.id32(); }
|
||||||
short type,
|
public byte[] getHash32() { return Arrays.copyOf(hash32, hash32.length); }
|
||||||
short subType,
|
/** Full serialized ANS-104 DataItem. */
|
||||||
short version,
|
public byte[] toBytes() { return dataItem.rawBytes(); }
|
||||||
byte[] bodyBytes,
|
|
||||||
byte[] signature64) {
|
|
||||||
|
|
||||||
Objects.requireNonNull(prevHash32, "prevHash32 == null");
|
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
|
||||||
Objects.requireNonNull(signature64, "signature64 == null");
|
|
||||||
|
|
||||||
if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32");
|
|
||||||
if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64");
|
|
||||||
|
|
||||||
if (blockNumber < 0) {
|
|
||||||
throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
// запрет “в будущее” больше чем на 1 минуту
|
|
||||||
long now = Instant.now().getEpochSecond();
|
|
||||||
if (timestamp > now + MAX_FUTURE_SECONDS) {
|
|
||||||
throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp
|
|
||||||
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.frameCode = FRAME_CODE_V0;
|
|
||||||
this.prevHash32 = Arrays.copyOf(prevHash32, 32);
|
|
||||||
this.blockNumber = blockNumber;
|
|
||||||
this.timestamp = timestamp;
|
|
||||||
this.type = type;
|
|
||||||
this.subType = subType;
|
|
||||||
this.version = version;
|
|
||||||
this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length);
|
|
||||||
|
|
||||||
// blockSize = размер preimage (включая frameCode)
|
|
||||||
this.blockSize = PREIMAGE_HEADER_SIZE + this.bodyBytes.length;
|
|
||||||
|
|
||||||
int fullLen = this.blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
|
|
||||||
if (fullLen > MAX_BLOCK_FULL_BYTES) {
|
|
||||||
throw new IllegalArgumentException("Block too large: " + fullLen + " > " + MAX_BLOCK_FULL_BYTES);
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse body по header + ОБЯЗАТЕЛЬНЫЙ check()
|
|
||||||
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
|
|
||||||
|
|
||||||
// tail marker фиксирован
|
|
||||||
this.sigMarker = SIG_MARKER_ED25519;
|
|
||||||
this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN);
|
|
||||||
|
|
||||||
// build preimage
|
|
||||||
ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
pre.putShort((short) (FRAME_CODE_V0 & 0xFFFF));
|
|
||||||
pre.put(this.prevHash32);
|
|
||||||
pre.putInt(this.blockSize);
|
|
||||||
pre.putInt(this.blockNumber);
|
|
||||||
pre.putLong(this.timestamp);
|
|
||||||
pre.putShort(this.type);
|
|
||||||
pre.putShort(this.subType);
|
|
||||||
pre.putShort(this.version);
|
|
||||||
pre.put(this.bodyBytes);
|
|
||||||
|
|
||||||
this.preimage = pre.array();
|
|
||||||
this.hash32 = BchCryptoVerifier.sha256(preimage);
|
|
||||||
|
|
||||||
// build fullBytes: preimage + sigMarker + signature64
|
|
||||||
ByteBuffer full = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
full.put(this.preimage);
|
|
||||||
full.putShort((short) (SIG_MARKER_ED25519 & 0xFFFF));
|
|
||||||
full.put(this.signature64);
|
|
||||||
this.fullBytes = full.array();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===================================================================== */
|
|
||||||
/* ============================ Getters ================================= */
|
|
||||||
/* ===================================================================== */
|
|
||||||
|
|
||||||
public byte[] getPreimageBytes() {
|
|
||||||
return Arrays.copyOf(preimage, preimage.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Возвращает подпись Ed25519 (64 байта). */
|
|
||||||
public byte[] getSignature64() {
|
|
||||||
return Arrays.copyOf(signature64, SIGNATURE_LEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Возвращает hash32 = SHA-256(preimage). */
|
|
||||||
public byte[] getHash32() {
|
|
||||||
return Arrays.copyOf(hash32, HASH_LEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Возвращает полный блок: preimage + sigMarker + signature. */
|
|
||||||
public byte[] toBytes() {
|
|
||||||
return Arrays.copyOf(fullBytes, fullBytes.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
String timeIso;
|
return "BchBlockEntry{" +
|
||||||
try {
|
"frameCode=0x" + Integer.toHexString(frameCode) +
|
||||||
timeIso = Instant.ofEpochSecond(timestamp).toString();
|
", blockNumber=" + blockNumber +
|
||||||
} catch (Exception e) {
|
", timestamp=" + timestamp +
|
||||||
timeIso = "некорректныйTimestamp";
|
", type=" + (type & 0xffff) +
|
||||||
}
|
", subType=" + (subType & 0xffff) +
|
||||||
|
", version=" + (version & 0xffff) +
|
||||||
return "BchBlockEntry{"
|
", bodyLen=" + bodyBytes.length +
|
||||||
+ "FRAME{frameCode=0x" + hex4(frameCode)
|
", dataItemId=" + toHex(getDataItemId32()) +
|
||||||
+ "}, HDR{"
|
", blockHash=" + toHex(hash32) +
|
||||||
+ "blockSize=" + blockSize
|
'}';
|
||||||
+ ", blockNumber=" + blockNumber
|
|
||||||
+ ", timestamp=" + timestamp + " (" + timeIso + ")"
|
|
||||||
+ ", type=" + (type & 0xFFFF)
|
|
||||||
+ ", subType=" + (subType & 0xFFFF)
|
|
||||||
+ ", version=" + (version & 0xFFFF)
|
|
||||||
+ ", prevHash32(hex)=" + toHex(prevHash32)
|
|
||||||
+ "}"
|
|
||||||
+ ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}"
|
|
||||||
+ ", TAIL{sigMarker=0x" + hex4(sigMarker) + ", signature64(hex)=" + toHex(signature64) + "}"
|
|
||||||
+ ", DERIVED{hash32(hex)=" + toHex(hash32) + "}"
|
|
||||||
+ "}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String hex4(int v) {
|
|
||||||
String s = Integer.toHexString(v & 0xFFFF);
|
|
||||||
while (s.length() < 4) s = "0" + s;
|
|
||||||
return s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String toHex(byte[] bytes) {
|
private static String toHex(byte[] bytes) {
|
||||||
if (bytes == null) return "null";
|
char[] h = "0123456789abcdef".toCharArray();
|
||||||
char[] HEX = "0123456789abcdef".toCharArray();
|
|
||||||
char[] out = new char[bytes.length * 2];
|
char[] out = new char[bytes.length * 2];
|
||||||
for (int i = 0; i < bytes.length; i++) {
|
for (int i = 0; i < bytes.length; i++) {
|
||||||
int vv = bytes[i] & 0xFF;
|
int v = bytes[i] & 0xff;
|
||||||
out[i * 2] = HEX[vv >>> 4];
|
out[i * 2] = h[v >>> 4]; out[i * 2 + 1] = h[v & 15];
|
||||||
out[i * 2 + 1] = HEX[vv & 0x0F];
|
|
||||||
}
|
}
|
||||||
return new String(out);
|
return new String(out);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-24
@@ -1,42 +1,27 @@
|
|||||||
package blockchain;
|
package blockchain;
|
||||||
|
|
||||||
import utils.crypto.Ed25519Util;
|
|
||||||
|
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/** Cryptographic checks for SHiNE Frame v1 + surrounding ANS-104 DataItem. */
|
||||||
* Верификатор SHiNE (Frame v0):
|
|
||||||
*
|
|
||||||
* preimage = первые blockSize байт блока (ВКЛЮЧАЯ frameCode=0x0000),
|
|
||||||
* = всё до TAIL (sigMarker+signature).
|
|
||||||
*
|
|
||||||
* hash32 = SHA-256(preimage)
|
|
||||||
* verify = Ed25519.verify(hash32, signature64, pubKey32)
|
|
||||||
*/
|
|
||||||
public final class BchCryptoVerifier {
|
public final class BchCryptoVerifier {
|
||||||
|
|
||||||
private BchCryptoVerifier() {}
|
private BchCryptoVerifier() {}
|
||||||
|
|
||||||
public static byte[] sha256(byte[] data) {
|
public static byte[] sha256(byte[] data) {
|
||||||
Objects.requireNonNull(data, "data == null");
|
Objects.requireNonNull(data, "data == null");
|
||||||
try {
|
try { return MessageDigest.getInstance("SHA-256").digest(data); }
|
||||||
MessageDigest d = MessageDigest.getInstance("SHA-256");
|
catch (Exception e) { throw new IllegalStateException("SHA-256 unavailable", e); }
|
||||||
return d.digest(data);
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IllegalStateException("SHA-256 unavailable", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that the ANS-104 owner is the current blockchain key and that the
|
||||||
|
* ANS-104 Ed25519 signature is valid. The SHiNE block hash itself remains
|
||||||
|
* SHA-256(Frame v1 bytes) and is intentionally independent of Arweave.
|
||||||
|
*/
|
||||||
public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) {
|
public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) {
|
||||||
Objects.requireNonNull(block, "block == null");
|
Objects.requireNonNull(block, "block == null");
|
||||||
Objects.requireNonNull(publicKey32, "publicKey32 == null");
|
Objects.requireNonNull(publicKey32, "publicKey32 == null");
|
||||||
|
|
||||||
if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32");
|
if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32");
|
||||||
|
return block.getDataItem().ownerEquals(publicKey32) && block.getDataItem().verifySignature();
|
||||||
byte[] hash32 = block.getHash32();
|
|
||||||
byte[] sig64 = block.getSignature64();
|
|
||||||
|
|
||||||
return Ed25519Util.verify(hash32, sig64, publicKey32);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,10 @@ public final class BodyRecordParser {
|
|||||||
&& (v == (CreateChannelBody.VER & 0xFFFF))) {
|
&& (v == (CreateChannelBody.VER & 0xFFFF))) {
|
||||||
return new CreateChannelBody(subType, version, bodyBytes).check();
|
return new CreateChannelBody(subType, version, bodyBytes).check();
|
||||||
}
|
}
|
||||||
|
if (st == (ForkBody.SUBTYPE & 0xFFFF)
|
||||||
|
&& (v == (ForkBody.VER & 0xFFFF))) {
|
||||||
|
return new ForkBody(subType, version, bodyBytes).check();
|
||||||
|
}
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
|
String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
|
||||||
);
|
);
|
||||||
@@ -33,7 +37,7 @@ public final class BodyRecordParser {
|
|||||||
int key = (t << 16) | v;
|
int key = (t << 16) | v;
|
||||||
|
|
||||||
BodyRecord r = switch (key) {
|
BodyRecord r = switch (key) {
|
||||||
case TextBody.KEY -> {
|
case TextLineBody.KEY -> {
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ public final class MsgSubType {
|
|||||||
/** HeaderBody: subType всегда 0 (compat). */
|
/** HeaderBody: subType всегда 0 (compat). */
|
||||||
public static final short HEADER_COMPAT = 0;
|
public static final short HEADER_COMPAT = 0;
|
||||||
public static final short TECH_CREATE_CHANNEL = 1;
|
public static final short TECH_CREATE_CHANNEL = 1;
|
||||||
|
/** Новый fork/ротация ключей: ссылка на родительскую цепочку и точку отката. */
|
||||||
|
public static final short TECH_FORK = 2;
|
||||||
|
|
||||||
/* ===================== TEXT (msg_type=1) ===================== */
|
/* ===================== TEXT (msg_type=1) ===================== */
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ public final class MsgSubType {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* REPLY — ответ на сообщение.
|
* REPLY — ответ на сообщение.
|
||||||
* НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32).
|
* НЕ в линии. Имеет target (toLogin + blockNumber + hash32).
|
||||||
* Может указывать на чужой блокчейн/чужую линию/чужой канал.
|
* Может указывать на чужой блокчейн/чужую линию/чужой канал.
|
||||||
*/
|
*/
|
||||||
public static final short TEXT_REPLY = 20;
|
public static final short TEXT_REPLY = 20;
|
||||||
@@ -69,7 +71,7 @@ public final class MsgSubType {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
|
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
|
||||||
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
* Имеет hasLine + target (toLogin + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
||||||
*/
|
*/
|
||||||
public static final short TEXT_REPOST = 50;
|
public static final short TEXT_REPOST = 50;
|
||||||
|
|
||||||
|
|||||||
+13
-19
@@ -1,31 +1,25 @@
|
|||||||
package blockchain.body;
|
package blockchain.body;
|
||||||
|
|
||||||
import utils.blockchain.BlockchainNameUtil;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля).
|
* Body that refers to a logical block target.
|
||||||
*
|
*
|
||||||
* Новое правило:
|
* Canonical rules:
|
||||||
* - toLogin НЕ храним в байтах блока.
|
* - external target: login + forkNumber + blockNumber + blockHash;
|
||||||
* - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN".
|
* - own EDIT target: blockNumber + blockHash only;
|
||||||
*
|
* - logical identity NEVER includes forkNumber: login + blockNumber + blockHash;
|
||||||
* Все методы могут возвращать null.
|
* - forkNumber is informational/historical only (0 = unknown/reserved).
|
||||||
*/
|
*/
|
||||||
public interface BodyHasTarget {
|
public interface BodyHasTarget {
|
||||||
|
/** Target login; null for own-chain compact EDIT targets. */
|
||||||
|
default String toLogin() { return null; }
|
||||||
|
|
||||||
/** login цели (nullable). Вычисляется из toBchName(). */
|
/** Historical fork number recorded by the author; null for compact EDIT targets. */
|
||||||
default String toLogin() {
|
default Integer toForkNumber() { return null; }
|
||||||
String bch = toBchName();
|
|
||||||
if (bch == null) return null;
|
|
||||||
return BlockchainNameUtil.loginFromBlockchainName(bch);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** blockchainName цели (nullable). */
|
/** Runtime-only physical blockchain cache; never serialized in canonical targets. */
|
||||||
String toBchName();
|
@Deprecated
|
||||||
|
default String toBchName() { return null; }
|
||||||
|
|
||||||
/** globalNumber цели (nullable). */
|
|
||||||
Integer toBlockGlobalNumber();
|
Integer toBlockGlobalNumber();
|
||||||
|
|
||||||
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
|
|
||||||
byte[] toBlockHashBytes();
|
byte[] toBlockHashBytes();
|
||||||
}
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
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.Arrays;
|
||||||
|
|
||||||
|
/** Shared codec/validation for canonical SHiNE target formats. */
|
||||||
|
final class BodyTargetSupport {
|
||||||
|
private BodyTargetSupport() {}
|
||||||
|
|
||||||
|
record ExternalTarget(String login, int forkNumber, int blockNumber, byte[] blockHash32) {}
|
||||||
|
|
||||||
|
static ExternalTarget readExternal(ByteBuffer bb, String ctx, int bytesRequiredAfterTarget) {
|
||||||
|
ensureMin(bb, 1 + 1 + 2 + 4 + 32 + bytesRequiredAfterTarget, ctx + " target too short");
|
||||||
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (loginLen <= 0) throw new IllegalArgumentException(ctx + " toLoginLen is 0");
|
||||||
|
ensureMin(bb, loginLen + 2 + 4 + 32 + bytesRequiredAfterTarget, ctx + " target payload too short");
|
||||||
|
|
||||||
|
byte[] loginBytes = new byte[loginLen];
|
||||||
|
bb.get(loginBytes);
|
||||||
|
String login = decodeStrictUtf8(loginBytes, ctx + " toLogin");
|
||||||
|
if (login.isBlank()) throw new IllegalArgumentException(ctx + " toLogin is blank");
|
||||||
|
|
||||||
|
int forkNumber = Short.toUnsignedInt(bb.getShort());
|
||||||
|
int blockNumber = bb.getInt();
|
||||||
|
if (blockNumber < 0) throw new IllegalArgumentException(ctx + " toBlockGlobalNumber < 0");
|
||||||
|
|
||||||
|
byte[] hash = new byte[32];
|
||||||
|
bb.get(hash);
|
||||||
|
requireNonZeroHash32(hash, ctx + " toBlockHash32");
|
||||||
|
return new ExternalTarget(login, forkNumber, blockNumber, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int externalSize(String login) {
|
||||||
|
return 1 + loginBytes(login).length + 2 + 4 + 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void writeExternal(ByteBuffer bb, String login, int forkNumber, int blockNumber, byte[] hash32, String ctx) {
|
||||||
|
byte[] loginBytes = loginBytes(login);
|
||||||
|
validateForkNumber(forkNumber, ctx);
|
||||||
|
if (blockNumber < 0) throw new IllegalArgumentException(ctx + " toBlockGlobalNumber < 0");
|
||||||
|
requireNonZeroHash32(hash32, ctx + " toBlockHash32");
|
||||||
|
|
||||||
|
bb.put((byte) loginBytes.length);
|
||||||
|
bb.put(loginBytes);
|
||||||
|
bb.putShort((short) forkNumber);
|
||||||
|
bb.putInt(blockNumber);
|
||||||
|
bb.put(hash32);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void validateExternal(String login, Integer forkNumber, Integer blockNumber, byte[] hash32, String ctx) {
|
||||||
|
loginBytes(login);
|
||||||
|
if (forkNumber == null) throw new IllegalArgumentException(ctx + " toForkNumber is null");
|
||||||
|
validateForkNumber(forkNumber, ctx);
|
||||||
|
if (blockNumber == null || blockNumber < 0) throw new IllegalArgumentException(ctx + " toBlockGlobalNumber invalid");
|
||||||
|
requireNonZeroHash32(hash32, ctx + " toBlockHash32");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void validateCompact(Integer blockNumber, byte[] hash32, String ctx) {
|
||||||
|
if (blockNumber == null || blockNumber < 0) throw new IllegalArgumentException(ctx + " toBlockGlobalNumber invalid");
|
||||||
|
requireNonZeroHash32(hash32, ctx + " toBlockHash32");
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] readCompactHash(ByteBuffer bb, String ctx) {
|
||||||
|
ensureMin(bb, 4 + 32, ctx + " target too short");
|
||||||
|
int blockNumber = bb.getInt();
|
||||||
|
if (blockNumber < 0) throw new IllegalArgumentException(ctx + " toBlockGlobalNumber < 0");
|
||||||
|
byte[] hash = new byte[32];
|
||||||
|
bb.get(hash);
|
||||||
|
requireNonZeroHash32(hash, ctx + " toBlockHash32");
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] loginBytes(String login) {
|
||||||
|
if (login == null || login.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
|
byte[] bytes = login.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (bytes.length == 0 || bytes.length > 255) throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void validateForkNumber(int forkNumber, String ctx) {
|
||||||
|
if (forkNumber < 0 || forkNumber > 999) {
|
||||||
|
throw new IllegalArgumentException(ctx + " toForkNumber must be 0..999");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void requireNonZeroHash32(byte[] hash32, String field) {
|
||||||
|
if (hash32 == null || hash32.length != 32) throw new IllegalArgumentException(field + " must be 32 bytes");
|
||||||
|
boolean any = false;
|
||||||
|
for (byte b : hash32) any |= b != 0;
|
||||||
|
if (!any) throw new IllegalArgumentException(field + " must not be zero hash");
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] copyHash(byte[] hash32) {
|
||||||
|
return hash32 == null ? null : Arrays.copyOf(hash32, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String decodeStrictUtf8(byte[] bytes, String field) {
|
||||||
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
try {
|
||||||
|
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
|
} catch (CharacterCodingException e) {
|
||||||
|
throw new IllegalArgumentException(field + " is not valid UTF-8", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ensureMin(ByteBuffer bb, int need, String msg) {
|
||||||
|
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-247
@@ -1,286 +1,91 @@
|
|||||||
package blockchain.body;
|
package blockchain.body;
|
||||||
|
|
||||||
import blockchain.MsgSubType;
|
import blockchain.MsgSubType;
|
||||||
import utils.blockchain.BlockchainNameUtil;
|
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/** CONNECTION with line fields and canonical external target. */
|
||||||
* ConnectionBody — type=3, ver=1 (в заголовке блока).
|
|
||||||
*
|
|
||||||
* subType (в заголовке блока) как MsgSubType:
|
|
||||||
* FRIEND=10, UNFRIEND=11
|
|
||||||
* CONTACT=20, UNCONTACT=21
|
|
||||||
* FOLLOW=30, UNFOLLOW=31
|
|
||||||
* SPOUSE=40, UNSPOUSE=41
|
|
||||||
* PARENT=50, UNPARENT=51
|
|
||||||
* CHILD=52, UNCHILD=53
|
|
||||||
* SIBLING=54, UNSIBLING=55
|
|
||||||
* FRIEND=14, UNFRIEND=15
|
|
||||||
* KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI)
|
|
||||||
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
|
|
||||||
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
|
|
||||||
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
|
|
||||||
*
|
|
||||||
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
|
||||||
* [4] lineCode
|
|
||||||
* [4] prevLineNumber
|
|
||||||
* [32] prevLineHash32
|
|
||||||
* [4] thisLineNumber
|
|
||||||
*
|
|
||||||
* [1] toBlockchainNameLen (uint8)
|
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber (int32)
|
|
||||||
* [32] toBlockHash32 (raw 32 bytes)
|
|
||||||
*
|
|
||||||
* toLogin вычисляется автоматически из toBlockchainName:
|
|
||||||
* toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName)
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* =========================================================================
|
|
||||||
* ПРАВИЛО TARGET/ROOT ДЛЯ КАНАЛОВ И СВЯЗЕЙ (важно для подписок/друзей/контактов)
|
|
||||||
* =========================================================================
|
|
||||||
*
|
|
||||||
* Термины:
|
|
||||||
* - ROOT линии/канала = блок, который "начинает" линию:
|
|
||||||
* * для канала "0" root = HEADER (blockNumber=0)
|
|
||||||
* * для канала "X" root = CREATE_CHANNEL (blockNumber этого блока)
|
|
||||||
*
|
|
||||||
* 1) СВЯЗИ МЕЖДУ ПОЛЬЗОВАТЕЛЯМИ (CONNECTION_*):
|
|
||||||
* FRIEND / CONTACT -> цель ВСЕГДА HEADER пользователя:
|
|
||||||
* toBlockNumber = 0
|
|
||||||
* toBlockHash32 = hash32(HEADER цели)
|
|
||||||
*
|
|
||||||
* 2) ПОДПИСКИ НА КОНТЕНТ (FOLLOW/SUBSCRIBE):
|
|
||||||
* FOLLOW пользователя (в целом) -> цель = ROOT дефолтного канала "0" (то есть HEADER):
|
|
||||||
* toBlockNumber = 0
|
|
||||||
* toBlockHash32 = hash32(HEADER цели)
|
|
||||||
*
|
|
||||||
* FOLLOW/подписка на конкретный канал пользователя ->
|
|
||||||
* цель = ROOT этого канала:
|
|
||||||
* - канал "0": toBlockNumber=0, toBlockHash32=hash32(HEADER)
|
|
||||||
* - канал "X": toBlockNumber=blockNumber(CREATE_CHANNEL),
|
|
||||||
* toBlockHash32=hash32(CREATE_CHANNEL)
|
|
||||||
*
|
|
||||||
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
|
|
||||||
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
|
||||||
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
|
|
||||||
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
|
|
||||||
*
|
|
||||||
* Зачем так:
|
|
||||||
* - связи и подписки всегда стабильны и не ломаются при новых постах,
|
|
||||||
* - один понятный инвариант: "подписка всегда указывает на root линии".
|
|
||||||
* =========================================================================
|
|
||||||
*/
|
|
||||||
|
|
||||||
public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {
|
public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {
|
||||||
|
|
||||||
public static final short TYPE = 3;
|
public static final short TYPE = 3;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
|
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
public final short subType; // из header
|
public final short subType;
|
||||||
public final short version; // из header
|
public final short version;
|
||||||
|
|
||||||
// line
|
|
||||||
public final int lineCode;
|
public final int lineCode;
|
||||||
public final int prevLineNumber;
|
public final int prevLineNumber;
|
||||||
public final byte[] prevLineHash32;
|
public final byte[] prevLineHash32;
|
||||||
public final int thisLineNumber;
|
public final int thisLineNumber;
|
||||||
|
public final String toLogin;
|
||||||
// payload
|
public final int toForkNumber;
|
||||||
public final String toBlockchainName;
|
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
|
|
||||||
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
|
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
this.subType = subType; this.version = version;
|
||||||
this.subType = subType;
|
if ((version & 0xFFFF) != 1 || !isValidSubType(subType)) throw new IllegalArgumentException("Bad Connection header");
|
||||||
this.version = version;
|
|
||||||
|
|
||||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
|
||||||
throw new IllegalArgumentException("ConnectionBody version must be 1, got=" + (this.version & 0xFFFF));
|
|
||||||
}
|
|
||||||
if (!isValidSubType(this.subType)) {
|
|
||||||
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
|
|
||||||
}
|
|
||||||
|
|
||||||
// минимум:
|
|
||||||
// lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32]
|
|
||||||
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) {
|
|
||||||
throw new IllegalArgumentException("ConnectionBody too short");
|
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
BodyTargetSupport.ensureMin(bb, 4 + 4 + 32 + 4 + 1 + 1 + 2 + 4 + 32, "ConnectionBody too short");
|
||||||
this.lineCode = bb.getInt();
|
this.lineCode = bb.getInt(); this.prevLineNumber = bb.getInt(); this.prevLineHash32 = new byte[32]; bb.get(this.prevLineHash32); this.thisLineNumber = bb.getInt();
|
||||||
|
BodyTargetSupport.ExternalTarget t = BodyTargetSupport.readExternal(bb, "CONNECTION", 0);
|
||||||
this.prevLineNumber = bb.getInt();
|
this.toLogin = t.login(); this.toForkNumber = t.forkNumber(); this.toBlockGlobalNumber = t.blockNumber(); this.toBlockHash32 = t.blockHash32();
|
||||||
|
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected ConnectionBody tail");
|
||||||
this.prevLineHash32 = new byte[32];
|
|
||||||
bb.get(this.prevLineHash32);
|
|
||||||
|
|
||||||
this.thisLineNumber = bb.getInt();
|
|
||||||
|
|
||||||
int bchLen = Byte.toUnsignedInt(bb.get());
|
|
||||||
if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
|
||||||
if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
|
|
||||||
|
|
||||||
byte[] bchBytes = new byte[bchLen];
|
|
||||||
bb.get(bchBytes);
|
|
||||||
this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
|
||||||
|
|
||||||
this.toBlockHash32 = new byte[32];
|
|
||||||
bb.get(this.toBlockHash32);
|
|
||||||
|
|
||||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ConnectionBody(int lineCode,
|
public ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, short subType,
|
||||||
int prevLineNumber,
|
String toLogin, int toForkNumber, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
||||||
byte[] prevLineHash32,
|
if (lineCode < 0 || !isValidSubType(subType)) throw new IllegalArgumentException("Bad connection fields");
|
||||||
int thisLineNumber,
|
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION");
|
||||||
short subType,
|
this.lineCode = lineCode; this.prevLineNumber = prevLineNumber;
|
||||||
String toBlockchainName,
|
this.prevLineHash32 = prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32);
|
||||||
int toBlockGlobalNumber,
|
this.thisLineNumber = thisLineNumber; this.subType = subType; this.version = VER;
|
||||||
byte[] toBlockHash32) {
|
this.toLogin = toLogin; this.toForkNumber = toForkNumber; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
|
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
|
||||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
|
||||||
|
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
|
||||||
// Железное правило формата: bchName -> login + "-NNN"
|
|
||||||
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) {
|
|
||||||
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
|
||||||
|
|
||||||
this.lineCode = lineCode;
|
|
||||||
|
|
||||||
this.prevLineNumber = prevLineNumber;
|
|
||||||
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
|
||||||
this.thisLineNumber = thisLineNumber;
|
|
||||||
|
|
||||||
this.subType = subType;
|
|
||||||
this.version = VER;
|
|
||||||
|
|
||||||
this.toBlockchainName = toBlockchainName;
|
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isValidSubType(short st) {
|
@Override public ConnectionBody check() {
|
||||||
int v = st & 0xFFFF;
|
if (lineCode < 0 || !isValidSubType(subType)) throw new IllegalArgumentException("Bad connection fields");
|
||||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_SPOUSE & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_UNSPOUSE & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_PARENT & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_UNPARENT & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_CHILD & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_UNCHILD & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_SIBLING & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_UNSIBLING & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_KNOWN_PERSON & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_UNKNOWN_PERSON & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ConnectionBody check() {
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
|
||||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
|
||||||
|
|
||||||
// line rule (как было)
|
|
||||||
if (prevLineNumber == -1) {
|
if (prevLineNumber == -1) {
|
||||||
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
|
if (!isAllZero32(prevLineHash32) || thisLineNumber != -1) throw new IllegalArgumentException("Bad empty connection line anchor");
|
||||||
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
|
} else if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||||
} else {
|
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION");
|
||||||
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
|
||||||
throw new IllegalArgumentException("toBlockchainName is blank");
|
|
||||||
|
|
||||||
// гарантируем вычислимый toLogin (иначе target “битый” по стандарту)
|
|
||||||
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null)
|
|
||||||
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
|
|
||||||
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override public byte[] toBytes() {
|
||||||
public byte[] toBytes() {
|
check();
|
||||||
byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
ByteBuffer bb = ByteBuffer.allocate(4 + 4 + 32 + 4 + BodyTargetSupport.externalSize(toLogin)).order(ByteOrder.BIG_ENDIAN);
|
||||||
if (bchBytes.length == 0 || bchBytes.length > 255)
|
bb.putInt(lineCode).putInt(prevLineNumber).put(prevLineHash32).putInt(thisLineNumber);
|
||||||
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION");
|
||||||
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
|
||||||
throw new IllegalArgumentException("toBlockHash32 != 32");
|
|
||||||
|
|
||||||
int cap = 4 + (4 + 32 + 4)
|
|
||||||
+ 1 + bchBytes.length
|
|
||||||
+ 4 + 32;
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
|
|
||||||
bb.putInt(lineCode);
|
|
||||||
|
|
||||||
bb.putInt(prevLineNumber);
|
|
||||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
|
||||||
bb.putInt(thisLineNumber);
|
|
||||||
|
|
||||||
bb.put((byte) bchBytes.length);
|
|
||||||
bb.put(bchBytes);
|
|
||||||
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
|
||||||
bb.put(toBlockHash32);
|
|
||||||
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isAllZero32(byte[] b) {
|
|
||||||
if (b == null || b.length != 32) return true;
|
|
||||||
for (int i = 0; i < 32; i++) if (b[i] != 0) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ====================== BodyHasLine ====================== */
|
|
||||||
@Override public int lineCode() { return lineCode; }
|
@Override public int lineCode() { return lineCode; }
|
||||||
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||||
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
|
@Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); }
|
||||||
@Override public int lineSeq() { return thisLineNumber; }
|
@Override public int lineSeq() { return thisLineNumber; }
|
||||||
|
@Override public String toLogin() { return toLogin; }
|
||||||
/* ====================== BodyHasTarget ===================== */
|
@Override public Integer toForkNumber() { return toForkNumber; }
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return Arrays.copyOf(toBlockHash32, 32); }
|
||||||
|
|
||||||
|
private static boolean isValidSubType(short st) {
|
||||||
|
int v = st & 0xFFFF;
|
||||||
|
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF) || v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF) || v == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_SPOUSE & 0xFFFF) || v == (MsgSubType.CONNECTION_UNSPOUSE & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_PARENT & 0xFFFF) || v == (MsgSubType.CONNECTION_UNPARENT & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_CHILD & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCHILD & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_SIBLING & 0xFFFF) || v == (MsgSubType.CONNECTION_UNSIBLING & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_KNOWN_PERSON & 0xFFFF) || v == (MsgSubType.CONNECTION_UNKNOWN_PERSON & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF) || v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF) || v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF) || v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||||
|
}
|
||||||
|
private static boolean isAllZero32(byte[] b) { if (b == null || b.length != 32) return false; for (byte v : b) if (v != 0) return false; return true; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package blockchain.body;
|
||||||
|
|
||||||
|
import blockchain.MsgSubType;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TECH_FORK body (type=0, subType=2, version=1).
|
||||||
|
*
|
||||||
|
* Записывается первым новым блоком после точной перепубликации выбранного
|
||||||
|
* префикса старой цепочки новым blockchain key.
|
||||||
|
*
|
||||||
|
* body bytes (BigEndian):
|
||||||
|
* [32] parentBlockchainKey
|
||||||
|
* [4] forkPointBlockNumber
|
||||||
|
* [32] forkPointBlockHash32
|
||||||
|
* [8] forkPointTimestampMs
|
||||||
|
* [4] parentTipBlockNumber
|
||||||
|
* [32] parentTipBlockHash32
|
||||||
|
* [8] parentTipTimestampMs
|
||||||
|
* [4] discardedBlocksCount
|
||||||
|
* [1] reasonCode
|
||||||
|
* [2] commentUtf8Length
|
||||||
|
* [N] comment UTF-8 (0..1024 bytes)
|
||||||
|
*/
|
||||||
|
public final class ForkBody implements BodyRecord {
|
||||||
|
|
||||||
|
public static final short TYPE = 0;
|
||||||
|
public static final short VER = 1;
|
||||||
|
public static final short SUBTYPE = MsgSubType.TECH_FORK;
|
||||||
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
|
public static final int REASON_ROUTINE_ROTATION = 1;
|
||||||
|
public static final int REASON_POSSIBLE_COMPROMISE = 2;
|
||||||
|
public static final int REASON_CONFIRMED_COMPROMISE_ROLLBACK = 3;
|
||||||
|
public static final int REASON_RECOVERY = 4;
|
||||||
|
|
||||||
|
public static final int MAX_COMMENT_UTF8_LEN = 1024;
|
||||||
|
private static final int FIXED_LEN = 32 + 4 + 32 + 8 + 4 + 32 + 8 + 4 + 1 + 2;
|
||||||
|
|
||||||
|
public final short subType;
|
||||||
|
public final short version;
|
||||||
|
public final byte[] parentBlockchainKey32;
|
||||||
|
public final int forkPointBlockNumber;
|
||||||
|
public final byte[] forkPointBlockHash32;
|
||||||
|
public final long forkPointTimestampMs;
|
||||||
|
public final int parentTipBlockNumber;
|
||||||
|
public final byte[] parentTipBlockHash32;
|
||||||
|
public final long parentTipTimestampMs;
|
||||||
|
public final int discardedBlocksCount;
|
||||||
|
public final int reasonCode;
|
||||||
|
public final String comment;
|
||||||
|
|
||||||
|
public ForkBody(short subType, short version, byte[] bodyBytes) {
|
||||||
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
this.subType = subType;
|
||||||
|
this.version = version;
|
||||||
|
|
||||||
|
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)");
|
||||||
|
}
|
||||||
|
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("ForkBody version must be 1");
|
||||||
|
}
|
||||||
|
if (bodyBytes.length < FIXED_LEN) {
|
||||||
|
throw new IllegalArgumentException("ForkBody too short");
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
this.parentBlockchainKey32 = new byte[32];
|
||||||
|
bb.get(this.parentBlockchainKey32);
|
||||||
|
this.forkPointBlockNumber = bb.getInt();
|
||||||
|
this.forkPointBlockHash32 = new byte[32];
|
||||||
|
bb.get(this.forkPointBlockHash32);
|
||||||
|
this.forkPointTimestampMs = bb.getLong();
|
||||||
|
this.parentTipBlockNumber = bb.getInt();
|
||||||
|
this.parentTipBlockHash32 = new byte[32];
|
||||||
|
bb.get(this.parentTipBlockHash32);
|
||||||
|
this.parentTipTimestampMs = bb.getLong();
|
||||||
|
this.discardedBlocksCount = bb.getInt();
|
||||||
|
this.reasonCode = Byte.toUnsignedInt(bb.get());
|
||||||
|
int commentLen = Short.toUnsignedInt(bb.getShort());
|
||||||
|
if (commentLen > MAX_COMMENT_UTF8_LEN) {
|
||||||
|
throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024");
|
||||||
|
}
|
||||||
|
if (bb.remaining() != commentLen) {
|
||||||
|
throw new IllegalArgumentException("ForkBody tail mismatch: remaining=" + bb.remaining() + " commentLen=" + commentLen);
|
||||||
|
}
|
||||||
|
byte[] commentBytes = new byte[commentLen];
|
||||||
|
bb.get(commentBytes);
|
||||||
|
this.comment = new String(commentBytes, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ForkBody(byte[] parentBlockchainKey32,
|
||||||
|
int forkPointBlockNumber,
|
||||||
|
byte[] forkPointBlockHash32,
|
||||||
|
long forkPointTimestampMs,
|
||||||
|
int parentTipBlockNumber,
|
||||||
|
byte[] parentTipBlockHash32,
|
||||||
|
long parentTipTimestampMs,
|
||||||
|
int discardedBlocksCount,
|
||||||
|
int reasonCode,
|
||||||
|
String comment) {
|
||||||
|
this.subType = SUBTYPE;
|
||||||
|
this.version = VER;
|
||||||
|
this.parentBlockchainKey32 = copy32(parentBlockchainKey32, "parentBlockchainKey32");
|
||||||
|
this.forkPointBlockNumber = forkPointBlockNumber;
|
||||||
|
this.forkPointBlockHash32 = copy32(forkPointBlockHash32, "forkPointBlockHash32");
|
||||||
|
this.forkPointTimestampMs = forkPointTimestampMs;
|
||||||
|
this.parentTipBlockNumber = parentTipBlockNumber;
|
||||||
|
this.parentTipBlockHash32 = copy32(parentTipBlockHash32, "parentTipBlockHash32");
|
||||||
|
this.parentTipTimestampMs = parentTipTimestampMs;
|
||||||
|
this.discardedBlocksCount = discardedBlocksCount;
|
||||||
|
this.reasonCode = reasonCode;
|
||||||
|
this.comment = normalizeComment(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ForkBody check() {
|
||||||
|
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)");
|
||||||
|
}
|
||||||
|
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("ForkBody version must be 1");
|
||||||
|
}
|
||||||
|
require32(parentBlockchainKey32, "parentBlockchainKey32");
|
||||||
|
require32(forkPointBlockHash32, "forkPointBlockHash32");
|
||||||
|
require32(parentTipBlockHash32, "parentTipBlockHash32");
|
||||||
|
if (forkPointBlockNumber < 0) throw new IllegalArgumentException("forkPointBlockNumber < 0");
|
||||||
|
if (parentTipBlockNumber < forkPointBlockNumber) {
|
||||||
|
throw new IllegalArgumentException("parentTipBlockNumber < forkPointBlockNumber");
|
||||||
|
}
|
||||||
|
if (forkPointTimestampMs < 0) throw new IllegalArgumentException("forkPointTimestampMs < 0");
|
||||||
|
if (parentTipTimestampMs < 0) throw new IllegalArgumentException("parentTipTimestampMs < 0");
|
||||||
|
int expectedDiscarded = parentTipBlockNumber - forkPointBlockNumber;
|
||||||
|
if (discardedBlocksCount != expectedDiscarded) {
|
||||||
|
throw new IllegalArgumentException("discardedBlocksCount must equal parentTipBlockNumber - forkPointBlockNumber");
|
||||||
|
}
|
||||||
|
if (!isReasonSupported(reasonCode)) {
|
||||||
|
throw new IllegalArgumentException("Unsupported fork reasonCode=" + reasonCode);
|
||||||
|
}
|
||||||
|
byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (commentUtf8.length > MAX_COMMENT_UTF8_LEN) {
|
||||||
|
throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024");
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] toBytes() {
|
||||||
|
check();
|
||||||
|
byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8);
|
||||||
|
ByteBuffer bb = ByteBuffer.allocate(FIXED_LEN + commentUtf8.length).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.put(parentBlockchainKey32);
|
||||||
|
bb.putInt(forkPointBlockNumber);
|
||||||
|
bb.put(forkPointBlockHash32);
|
||||||
|
bb.putLong(forkPointTimestampMs);
|
||||||
|
bb.putInt(parentTipBlockNumber);
|
||||||
|
bb.put(parentTipBlockHash32);
|
||||||
|
bb.putLong(parentTipTimestampMs);
|
||||||
|
bb.putInt(discardedBlocksCount);
|
||||||
|
bb.put((byte) reasonCode);
|
||||||
|
bb.putShort((short) commentUtf8.length);
|
||||||
|
bb.put(commentUtf8);
|
||||||
|
return bb.array();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isReasonSupported(int code) {
|
||||||
|
return code == REASON_ROUTINE_ROTATION
|
||||||
|
|| code == REASON_POSSIBLE_COMPROMISE
|
||||||
|
|| code == REASON_CONFIRMED_COMPROMISE_ROLLBACK
|
||||||
|
|| code == REASON_RECOVERY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] copy32(byte[] value, String name) {
|
||||||
|
require32(value, name);
|
||||||
|
return Arrays.copyOf(value, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void require32(byte[] value, String name) {
|
||||||
|
if (value == null || value.length != 32) {
|
||||||
|
throw new IllegalArgumentException(name + " must be 32 bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeComment(String value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
-64
@@ -5,123 +5,107 @@ import utils.config.ShineSignatureConstants;
|
|||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HeaderBody — type=0, version=1.
|
* HeaderBody — type=0, subType=0, version=1.
|
||||||
*
|
|
||||||
* В новом формате type/subType/version живут в HEADER блока,
|
|
||||||
* поэтому bodyBytes для HeaderBody содержат только payload:
|
|
||||||
*
|
*
|
||||||
* bodyBytes (BigEndian):
|
* bodyBytes (BigEndian):
|
||||||
* [TAG_LEN] tag ASCII "SHiNE"
|
* [TAG_LEN] tag ASCII
|
||||||
* [1] loginLength=N (uint8)
|
* [1] loginLength (uint8)
|
||||||
* [N] login UTF-8
|
* [N] login UTF-8
|
||||||
|
* [32] initialBlockchainKey32
|
||||||
|
*
|
||||||
|
* initialBlockchainKey32 is the public blockchain-signing key that created fork #1.
|
||||||
|
* It is historical identity metadata and MUST remain byte-for-byte unchanged when a
|
||||||
|
* preserved prefix (including block #0) is republished into later forks.
|
||||||
*/
|
*/
|
||||||
public final class HeaderBody implements BodyRecord {
|
public final class HeaderBody implements BodyRecord {
|
||||||
|
|
||||||
public static final short TYPE = 0;
|
public static final short TYPE = 0;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
|
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
/** Для header subType всегда 0 (служебная совместимость). */
|
|
||||||
public static final short SUBTYPE_COMPAT = 0;
|
public static final short SUBTYPE_COMPAT = 0;
|
||||||
|
|
||||||
/** TAG формата (ASCII). */
|
|
||||||
public static final String TAG = ShineSignatureConstants.BLOCKCHAIN_HEADER_TAG;
|
public static final String TAG = ShineSignatureConstants.BLOCKCHAIN_HEADER_TAG;
|
||||||
|
|
||||||
private static final byte[] TAG_ASCII = TAG.getBytes(StandardCharsets.US_ASCII);
|
private static final byte[] TAG_ASCII = TAG.getBytes(StandardCharsets.US_ASCII);
|
||||||
private static final int TAG_LEN = TAG_ASCII.length;
|
private static final int TAG_LEN = TAG_ASCII.length;
|
||||||
|
|
||||||
public final short subType; // всегда 0 (из заголовка блока)
|
public final short subType;
|
||||||
public final short version; // из заголовка блока
|
public final short version;
|
||||||
public final String tag; // "SHiNE"
|
public final String tag;
|
||||||
public final String login;
|
public final String login;
|
||||||
|
public final byte[] initialBlockchainKey32;
|
||||||
|
|
||||||
/** Десериализация из payload bodyBytes (без type/subType/version). */
|
|
||||||
public HeaderBody(short subType, short version, byte[] bodyBytes) {
|
public HeaderBody(short subType, short version, byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
if ((subType & 0xFFFF) != 0) throw new IllegalArgumentException("HeaderBody subType must be 0");
|
||||||
if ((this.subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF)) {
|
if ((version & 0xFFFF) != (VER & 0xFFFF)) throw new IllegalArgumentException("HeaderBody version must be 1");
|
||||||
throw new IllegalArgumentException("HeaderBody subType must be 0, got=" + (this.subType & 0xFFFF));
|
if (bodyBytes.length < TAG_LEN + 1 + 1 + 32) throw new IllegalArgumentException("HeaderBody too short");
|
||||||
}
|
|
||||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
|
||||||
throw new IllegalArgumentException("HeaderBody version must be 1, got=" + (this.version & 0xFFFF));
|
|
||||||
}
|
|
||||||
|
|
||||||
// минимум: tag[TAG_LEN] + loginLen[1]
|
|
||||||
if (bodyBytes.length < TAG_LEN + 1) throw new IllegalArgumentException("HeaderBody too short");
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
byte[] tagBytes = new byte[TAG_LEN];
|
byte[] tagBytes = new byte[TAG_LEN];
|
||||||
bb.get(tagBytes);
|
bb.get(tagBytes);
|
||||||
String t = new String(tagBytes, StandardCharsets.US_ASCII);
|
String parsedTag = new String(tagBytes, StandardCharsets.US_ASCII);
|
||||||
if (!TAG.equals(t)) throw new IllegalArgumentException("Bad tag: " + t);
|
if (!TAG.equals(parsedTag)) throw new IllegalArgumentException("Bad tag: " + parsedTag);
|
||||||
this.tag = t;
|
this.tag = parsedTag;
|
||||||
|
|
||||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (loginLen <= 0 || bb.remaining() < loginLen)
|
if (loginLen <= 0 || bb.remaining() != loginLen + 32) throw new IllegalArgumentException("Bad HeaderBody login/key length");
|
||||||
throw new IllegalArgumentException("Bad login length");
|
|
||||||
|
|
||||||
byte[] loginBytes = new byte[loginLen];
|
byte[] loginBytes = new byte[loginLen];
|
||||||
bb.get(loginBytes);
|
bb.get(loginBytes);
|
||||||
this.login = new String(loginBytes, StandardCharsets.UTF_8);
|
this.login = BodyTargetSupport.decodeStrictUtf8(loginBytes, "HeaderBody login");
|
||||||
|
this.initialBlockchainKey32 = new byte[32];
|
||||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
bb.get(this.initialBlockchainKey32);
|
||||||
|
requireNonZeroKey(this.initialBlockchainKey32);
|
||||||
|
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected HeaderBody tail");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Создание “вручную”. */
|
public HeaderBody(String login, byte[] initialBlockchainKey32) {
|
||||||
public HeaderBody(String login) {
|
|
||||||
Objects.requireNonNull(login, "login == null");
|
Objects.requireNonNull(login, "login == null");
|
||||||
|
Objects.requireNonNull(initialBlockchainKey32, "initialBlockchainKey32 == null");
|
||||||
this.subType = SUBTYPE_COMPAT;
|
this.subType = SUBTYPE_COMPAT;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
this.tag = TAG;
|
this.tag = TAG;
|
||||||
this.login = login;
|
this.login = login;
|
||||||
|
this.initialBlockchainKey32 = Arrays.copyOf(initialBlockchainKey32, initialBlockchainKey32.length);
|
||||||
|
check();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public HeaderBody check() {
|
public HeaderBody check() {
|
||||||
if ((subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF))
|
if ((subType & 0xFFFF) != 0) throw new IllegalArgumentException("HeaderBody subType must be 0");
|
||||||
throw new IllegalArgumentException("HeaderBody subType must be 0");
|
if (login == null || login.isBlank()) throw new IllegalArgumentException("Login is blank");
|
||||||
|
if (!login.matches("^[A-Za-z0-9_]+$")) throw new IllegalArgumentException("Login must match ^[A-Za-z0-9_]+$");
|
||||||
if (login == null || login.isBlank())
|
byte[] loginUtf8 = login.getBytes(StandardCharsets.UTF_8);
|
||||||
throw new IllegalArgumentException("Login is blank");
|
if (loginUtf8.length == 0 || loginUtf8.length > 255) throw new IllegalArgumentException("Login utf8 len must be 1..255");
|
||||||
if (!login.matches("^[A-Za-z0-9_]+$"))
|
requireNonZeroKey(initialBlockchainKey32);
|
||||||
throw new IllegalArgumentException("Login must match ^[A-Za-z0-9_]+$");
|
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
|
check();
|
||||||
byte[] loginUtf8 = login.getBytes(StandardCharsets.UTF_8);
|
byte[] loginUtf8 = login.getBytes(StandardCharsets.UTF_8);
|
||||||
if (loginUtf8.length == 0 || loginUtf8.length > 255)
|
ByteBuffer bb = ByteBuffer.allocate(TAG_LEN + 1 + loginUtf8.length + 32).order(ByteOrder.BIG_ENDIAN);
|
||||||
throw new IllegalArgumentException("Login utf8 len must be 1..255");
|
|
||||||
|
|
||||||
int cap = TAG_LEN + 1 + loginUtf8.length;
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.put(TAG_ASCII);
|
bb.put(TAG_ASCII);
|
||||||
bb.put((byte) loginUtf8.length);
|
bb.put((byte) loginUtf8.length);
|
||||||
bb.put(loginUtf8);
|
bb.put(loginUtf8);
|
||||||
|
bb.put(initialBlockchainKey32);
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void requireNonZeroKey(byte[] key) {
|
||||||
|
if (key == null || key.length != 32) throw new IllegalArgumentException("initialBlockchainKey32 must be 32 bytes");
|
||||||
|
boolean any = false;
|
||||||
|
for (byte b : key) any |= b != 0;
|
||||||
|
if (!any) throw new IllegalArgumentException("initialBlockchainKey32 must not be zero");
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return """
|
return "HeaderBody{login='" + login + "', initialBlockchainKey32=<32 bytes>}";
|
||||||
HeaderBody {
|
|
||||||
тип записи : HEADER (type=0, ver=1) [в заголовке блока]
|
|
||||||
subType : 0 (compat)
|
|
||||||
тег формата : "%s"
|
|
||||||
login владельца : "%s"
|
|
||||||
}
|
|
||||||
""".formatted(tag, login);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+31
-93
@@ -4,130 +4,68 @@ import blockchain.MsgSubType;
|
|||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/** REACTION LIKE/UNLIKE. External target = login + forkNumber + blockNumber + hash32. */
|
||||||
* ReactionBody — type=2, version=1 (в заголовке блока).
|
|
||||||
*
|
|
||||||
* subType (в заголовке блока):
|
|
||||||
* 1 = LIKE
|
|
||||||
* 2 = UNLIKE
|
|
||||||
*
|
|
||||||
* bodyBytes (BigEndian), новый формат:
|
|
||||||
* [1] toBlockchainNameLen (uint8)
|
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber (int32)
|
|
||||||
* [32] toBlockHash32 (raw 32 bytes)
|
|
||||||
*
|
|
||||||
* ЛИНИИ НЕТ.
|
|
||||||
*/
|
|
||||||
public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
||||||
|
|
||||||
public static final short TYPE = 2;
|
public static final short TYPE = 2;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
|
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
public final short subType; // из header
|
public final short subType;
|
||||||
public final short version; // из header
|
public final short version;
|
||||||
|
public final String toLogin;
|
||||||
public final String toBlockchainName;
|
public final int toForkNumber;
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
|
|
||||||
public ReactionBody(short subType, short version, byte[] bodyBytes) {
|
public ReactionBody(short subType, short version, byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
if ((version & 0xFFFF) != (VER & 0xFFFF)) throw new IllegalArgumentException("ReactionBody version must be 1");
|
||||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
||||||
throw new IllegalArgumentException("ReactionBody version must be 1, got=" + (this.version & 0xFFFF));
|
|
||||||
}
|
|
||||||
if (!isSupportedSubType(this.subType)) {
|
|
||||||
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
|
|
||||||
}
|
|
||||||
|
|
||||||
// минимум: nameLen[1]+name[1]+global[4]+hash[32]
|
|
||||||
if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
BodyTargetSupport.ExternalTarget t = BodyTargetSupport.readExternal(bb, "REACTION", 0);
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
this.toLogin = t.login();
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
this.toForkNumber = t.forkNumber();
|
||||||
if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
|
this.toBlockGlobalNumber = t.blockNumber();
|
||||||
|
this.toBlockHash32 = t.blockHash32();
|
||||||
byte[] nameBytes = new byte[nameLen];
|
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected REACTION tail");
|
||||||
bb.get(nameBytes);
|
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
|
||||||
|
|
||||||
this.toBlockHash32 = new byte[32];
|
|
||||||
bb.get(this.toBlockHash32);
|
|
||||||
|
|
||||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
public ReactionBody(short subType, String toLogin, int toForkNumber, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad reaction subType");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REACTION");
|
||||||
|
this.subType = subType;
|
||||||
this.subType = MsgSubType.REACTION_LIKE;
|
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
|
this.toLogin = toLogin;
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
this.toForkNumber = toForkNumber;
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
|
||||||
|
|
||||||
this.toBlockchainName = toBlockchainName;
|
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override public ReactionBody check() {
|
||||||
public ReactionBody check() {
|
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad reaction subType");
|
||||||
if (!isSupportedSubType(subType))
|
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REACTION");
|
||||||
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
|
||||||
|
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
|
||||||
throw new IllegalArgumentException("toBlockchainName is blank");
|
|
||||||
if (toBlockGlobalNumber < 0)
|
|
||||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override public byte[] toBytes() {
|
||||||
public byte[] toBytes() {
|
check();
|
||||||
byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
ByteBuffer bb = ByteBuffer.allocate(BodyTargetSupport.externalSize(toLogin)).order(ByteOrder.BIG_ENDIAN);
|
||||||
if (nameBytes.length == 0 || nameBytes.length > 255)
|
BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REACTION");
|
||||||
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
|
||||||
|
|
||||||
int cap = 1 + nameBytes.length + 4 + 32;
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.put((byte) nameBytes.length);
|
|
||||||
bb.put(nameBytes);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
|
||||||
bb.put(toBlockHash32);
|
|
||||||
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ====================== */
|
@Override public String toLogin() { return toLogin; }
|
||||||
|
@Override public Integer toForkNumber() { return toForkNumber; }
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return Arrays.copyOf(toBlockHash32, 32); }
|
||||||
|
|
||||||
private static boolean isSupportedSubType(short subType) {
|
private static boolean isSupportedSubType(short subType) {
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
return st == (MsgSubType.REACTION_LIKE & 0xFFFF)
|
return st == (MsgSubType.REACTION_LIKE & 0xFFFF) || st == (MsgSubType.REACTION_UNLIKE & 0xFFFF);
|
||||||
|| st == (MsgSubType.REACTION_UNLIKE & 0xFFFF);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-116
@@ -10,157 +10,78 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/** STATUS_ACTION. External target = login + forkNumber + blockNumber + hash32. */
|
||||||
* StatusActionBody — type=5, ver=1.
|
|
||||||
*
|
|
||||||
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
|
|
||||||
*
|
|
||||||
* Формат bodyBytes (BigEndian):
|
|
||||||
* [1] toBlockchainNameLen (uint8)
|
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber
|
|
||||||
* [32] toBlockHash32
|
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [M] text UTF-8
|
|
||||||
*/
|
|
||||||
public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||||
|
|
||||||
public static final short TYPE = 5;
|
public static final short TYPE = 5;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
public final short subType;
|
public final short subType;
|
||||||
public final short version;
|
public final short version;
|
||||||
public final String toBlockchainName;
|
public final String toLogin;
|
||||||
|
public final int toForkNumber;
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
public final String message;
|
public final String message;
|
||||||
|
|
||||||
public StatusActionBody(short subType, short version, byte[] bodyBytes) {
|
public StatusActionBody(short subType, short version, byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
this.subType = subType;
|
this.subType = subType; this.version = version;
|
||||||
this.version = version;
|
if ((version & 0xFFFF) != 1 || !isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION header");
|
||||||
|
|
||||||
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
|
||||||
throw new IllegalArgumentException("StatusActionBody version must be 1, got=" + (version & 0xFFFF));
|
|
||||||
}
|
|
||||||
if (!isSupportedSubType(subType)) {
|
|
||||||
throw new IllegalArgumentException("Unsupported STATUS_ACTION subType: " + (subType & 0xFFFF));
|
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
|
BodyTargetSupport.ExternalTarget t = BodyTargetSupport.readExternal(bb, "STATUS_ACTION", 2);
|
||||||
|
this.toLogin = t.login(); this.toForkNumber = t.forkNumber(); this.toBlockGlobalNumber = t.blockNumber(); this.toBlockHash32 = t.blockHash32();
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
this.message = readText(bb);
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
|
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected STATUS_ACTION tail");
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
|
||||||
bb.get(nameBytes);
|
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
|
||||||
this.toBlockHash32 = new byte[32];
|
|
||||||
bb.get(this.toBlockHash32);
|
|
||||||
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
|
|
||||||
|
|
||||||
ensureNoTail(bb, "StatusActionBody");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
public StatusActionBody(short subType, String toLogin, int toForkNumber, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
|
||||||
Objects.requireNonNull(message, "message == null");
|
Objects.requireNonNull(message, "message == null");
|
||||||
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
|
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "STATUS_ACTION");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
this.subType = subType; this.version = VER; this.toLogin = toLogin; this.toForkNumber = toForkNumber;
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.message = message;
|
||||||
|
|
||||||
this.subType = subType;
|
|
||||||
this.version = VER;
|
|
||||||
this.toBlockchainName = toBlockchainName;
|
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
|
||||||
this.message = message;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override public StatusActionBody check() {
|
||||||
public StatusActionBody check() {
|
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION subType");
|
||||||
if (!isSupportedSubType(subType)) {
|
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "STATUS_ACTION");
|
||||||
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
|
||||||
}
|
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
|
|
||||||
}
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
|
||||||
}
|
|
||||||
if (message == null) throw new IllegalArgumentException("message is null");
|
if (message == null) throw new IllegalArgumentException("message is null");
|
||||||
|
if (message.getBytes(StandardCharsets.UTF_8).length > 65535) throw new IllegalArgumentException("Text too long");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override public byte[] toBytes() {
|
||||||
public byte[] toBytes() {
|
check();
|
||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
byte[] text = message.getBytes(StandardCharsets.UTF_8);
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
ByteBuffer bb = ByteBuffer.allocate(BodyTargetSupport.externalSize(toLogin) + 2 + text.length).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "STATUS_ACTION");
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
bb.putShort((short) text.length).put(text);
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
|
||||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255");
|
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
|
||||||
.order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.put((byte) nameUtf8.length);
|
|
||||||
bb.put(nameUtf8);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
|
||||||
bb.put(toBlockHash32);
|
|
||||||
bb.putShort((short) msgUtf8.length);
|
|
||||||
bb.put(msgUtf8);
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
@Override public String toLogin() { return toLogin; }
|
||||||
|
@Override public Integer toForkNumber() { return toForkNumber; }
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return Arrays.copyOf(toBlockHash32, 32); }
|
||||||
|
|
||||||
private static boolean isSupportedSubType(short subType) {
|
private static boolean isSupportedSubType(short subType) {
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF)
|
return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF) || st == (MsgSubType.STATUS_LEARNED & 0xFFFF)
|
||||||
|| st == (MsgSubType.STATUS_LEARNED & 0xFFFF)
|
|| st == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF) || st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)
|
||||||
|| st == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)
|
|| st == (MsgSubType.STATUS_INTERESTED & 0xFFFF) || st == (MsgSubType.STATUS_STARTED & 0xFFFF)
|
||||||
|| st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)
|
|| st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF) || st == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|
||||||
|| st == (MsgSubType.STATUS_INTERESTED & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.STATUS_STARTED & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.STATUS_COMPLETED & 0xFFFF);
|
|| st == (MsgSubType.STATUS_COMPLETED & 0xFFFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) {
|
private static String readText(ByteBuffer bb) {
|
||||||
|
if (bb.remaining() < 2) throw new IllegalArgumentException("STATUS_ACTION text length missing");
|
||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
|
if (bb.remaining() < len) throw new IllegalArgumentException("STATUS_ACTION text payload too short");
|
||||||
if (len == 0) return "";
|
if (len == 0) return "";
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
byte[] bytes = new byte[len]; bb.get(bytes);
|
||||||
|
var dec = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
byte[] bytes = new byte[len];
|
try { return dec.decode(ByteBuffer.wrap(bytes)).toString(); }
|
||||||
bb.get(bytes);
|
catch (CharacterCodingException e) { throw new IllegalArgumentException("STATUS_ACTION text invalid UTF-8", e); }
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
|
||||||
} catch (CharacterCodingException e) {
|
|
||||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
|
||||||
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
|
||||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,544 +0,0 @@
|
|||||||
package blockchain.body;
|
|
||||||
|
|
||||||
import blockchain.MsgSubType;
|
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.ByteOrder;
|
|
||||||
import java.nio.charset.CharacterCodingException;
|
|
||||||
import java.nio.charset.CodingErrorAction;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TextBody — type=1, ver=1 (в заголовке блока).
|
|
||||||
*
|
|
||||||
* subType (в заголовке блока):
|
|
||||||
* 10 = POST
|
|
||||||
* 11 = EDIT_POST
|
|
||||||
* 20 = REPLY
|
|
||||||
* 21 = EDIT_REPLY
|
|
||||||
*
|
|
||||||
* =========================================================================
|
|
||||||
* КОНЦЕПЦИЯ ЛИНИЙ ДЛЯ ТЕКСТОВЫХ СООБЩЕНИЙ:
|
|
||||||
*
|
|
||||||
* POST и EDIT_POST принадлежат ЛИНИИ КАНАЛА и имеют hasLine.
|
|
||||||
* В новом формате добавлен lineCode:
|
|
||||||
* lineCode = 0 для канала "0"
|
|
||||||
* lineCode = blockNumber "заглавия линии/канала" (например CREATE_CHANNEL)
|
|
||||||
*
|
|
||||||
* REPLY и EDIT_REPLY НЕ имеют линии (нет hasLine в байтах).
|
|
||||||
*
|
|
||||||
* =========================================================================
|
|
||||||
* ФОРМАТЫ bodyBytes (BigEndian):
|
|
||||||
*
|
|
||||||
* 1) POST (subType=10):
|
|
||||||
* [4] lineCode
|
|
||||||
* [4] prevLineNumber
|
|
||||||
* [32] prevLineHash32
|
|
||||||
* [4] thisLineNumber
|
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [N] text UTF-8
|
|
||||||
*
|
|
||||||
* 2) EDIT_POST (subType=11):
|
|
||||||
* [4] lineCode
|
|
||||||
* [4] prevLineNumber
|
|
||||||
* [32] prevLineHash32
|
|
||||||
* [4] thisLineNumber
|
|
||||||
*
|
|
||||||
* hasTarget (на ОРИГИНАЛЬНЫЙ POST, toBchName НЕ хранить):
|
|
||||||
* [4] toBlockGlobalNumber
|
|
||||||
* [32] toBlockHash32
|
|
||||||
*
|
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [N] text UTF-8
|
|
||||||
*
|
|
||||||
* 3) REPLY (subType=20) — НЕ в линии:
|
|
||||||
* hasTarget:
|
|
||||||
* [1] toBlockchainNameLen (uint8)
|
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber
|
|
||||||
* [32] toBlockHash32
|
|
||||||
*
|
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [M] text UTF-8
|
|
||||||
*
|
|
||||||
* 4) EDIT_REPLY (subType=21) — НЕ в линии:
|
|
||||||
* hasTarget (на ОРИГИНАЛЬНЫЙ REPLY, toBchName НЕ хранить):
|
|
||||||
* [4] toBlockGlobalNumber
|
|
||||||
* [32] toBlockHash32
|
|
||||||
*
|
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [N] text UTF-8
|
|
||||||
*/
|
|
||||||
public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine {
|
|
||||||
|
|
||||||
public static final short TYPE = 1;
|
|
||||||
public static final short VER = 1;
|
|
||||||
|
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
|
||||||
|
|
||||||
public final short subType; // из header
|
|
||||||
public final short version; // из header
|
|
||||||
|
|
||||||
// ===== line fields (только для POST/EDIT_POST) =====
|
|
||||||
// Для REPLY/EDIT_REPLY эти поля НЕ сериализуются; значения держим как "пустые".
|
|
||||||
public final int lineCode; // только для line-message; иначе -1
|
|
||||||
public final int prevLineNumber;
|
|
||||||
public final byte[] prevLineHash32; // 32 or null
|
|
||||||
public final int thisLineNumber;
|
|
||||||
|
|
||||||
// ===== message text =====
|
|
||||||
public final String message;
|
|
||||||
|
|
||||||
// ===== target fields =====
|
|
||||||
// REPLY: toBlockchainName + globalNumber + hash32
|
|
||||||
// EDIT_POST / EDIT_REPLY: только globalNumber + hash32 (без toBlockchainName)
|
|
||||||
public final String toBlockchainName; // nullable
|
|
||||||
public final Integer toBlockGlobalNumber; // nullable
|
|
||||||
public final byte[] toBlockHash32; // nullable (но если target есть -> 32)
|
|
||||||
|
|
||||||
/* ===================================================================== */
|
|
||||||
/* ====================== Конструктор из байт ========================== */
|
|
||||||
/* ===================================================================== */
|
|
||||||
|
|
||||||
public TextBody(short subType, short version, byte[] bodyBytes) {
|
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
|
||||||
|
|
||||||
this.subType = subType;
|
|
||||||
this.version = version;
|
|
||||||
|
|
||||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
|
||||||
throw new IllegalArgumentException("TextBody version must be 1, got=" + (this.version & 0xFFFF));
|
|
||||||
}
|
|
||||||
if (!isValidSubType(this.subType)) {
|
|
||||||
throw new IllegalArgumentException("Bad Text subType: " + (this.subType & 0xFFFF));
|
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
|
|
||||||
int st = this.subType & 0xFFFF;
|
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
|
||||||
// POST: hasLine(lineCode+line) + text
|
|
||||||
ensureMin(bb, (4 + 4 + 32 + 4) + 2, "POST too short");
|
|
||||||
|
|
||||||
this.lineCode = bb.getInt();
|
|
||||||
this.prevLineNumber = bb.getInt();
|
|
||||||
this.prevLineHash32 = new byte[32];
|
|
||||||
bb.get(this.prevLineHash32);
|
|
||||||
this.thisLineNumber = bb.getInt();
|
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "POST text");
|
|
||||||
|
|
||||||
this.toBlockchainName = null;
|
|
||||||
this.toBlockGlobalNumber = null;
|
|
||||||
this.toBlockHash32 = null;
|
|
||||||
|
|
||||||
ensureNoTail(bb, "POST");
|
|
||||||
|
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|
||||||
// EDIT_POST: hasLine(lineCode+line) + target(no bch) + text
|
|
||||||
ensureMin(bb, (4 + 4 + 32 + 4) + (4 + 32) + 2, "EDIT_POST too short");
|
|
||||||
|
|
||||||
this.lineCode = bb.getInt();
|
|
||||||
this.prevLineNumber = bb.getInt();
|
|
||||||
this.prevLineHash32 = new byte[32];
|
|
||||||
bb.get(this.prevLineHash32);
|
|
||||||
this.thisLineNumber = bb.getInt();
|
|
||||||
|
|
||||||
int tgtNum = bb.getInt();
|
|
||||||
byte[] tgtHash = new byte[32];
|
|
||||||
bb.get(tgtHash);
|
|
||||||
|
|
||||||
this.toBlockchainName = null;
|
|
||||||
this.toBlockGlobalNumber = tgtNum;
|
|
||||||
this.toBlockHash32 = tgtHash;
|
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "EDIT_POST text");
|
|
||||||
|
|
||||||
ensureNoTail(bb, "EDIT_POST");
|
|
||||||
|
|
||||||
} else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
|
||||||
// REPLY: target(with bch) + text (без line)
|
|
||||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");
|
|
||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
|
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");
|
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
|
||||||
bb.get(nameBytes);
|
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
|
||||||
|
|
||||||
this.toBlockHash32 = new byte[32];
|
|
||||||
bb.get(this.toBlockHash32);
|
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "REPLY text");
|
|
||||||
|
|
||||||
// line fields отсутствуют в байтах
|
|
||||||
this.lineCode = -1;
|
|
||||||
this.prevLineNumber = -1;
|
|
||||||
this.prevLineHash32 = null;
|
|
||||||
this.thisLineNumber = -1;
|
|
||||||
|
|
||||||
ensureNoTail(bb, "REPLY");
|
|
||||||
|
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
|
||||||
// EDIT_REPLY: target(no bch) + text (без line)
|
|
||||||
ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");
|
|
||||||
|
|
||||||
int tgtNum = bb.getInt();
|
|
||||||
byte[] tgtHash = new byte[32];
|
|
||||||
bb.get(tgtHash);
|
|
||||||
|
|
||||||
this.toBlockchainName = null;
|
|
||||||
this.toBlockGlobalNumber = tgtNum;
|
|
||||||
this.toBlockHash32 = tgtHash;
|
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "EDIT_REPLY text");
|
|
||||||
|
|
||||||
// line fields отсутствуют в байтах
|
|
||||||
this.lineCode = -1;
|
|
||||||
this.prevLineNumber = -1;
|
|
||||||
this.prevLineHash32 = null;
|
|
||||||
this.thisLineNumber = -1;
|
|
||||||
|
|
||||||
ensureNoTail(bb, "EDIT_REPLY");
|
|
||||||
|
|
||||||
} else {
|
|
||||||
throw new IllegalArgumentException("Unsupported Text subType: " + st);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===================================================================== */
|
|
||||||
/* ====================== Фабрики (удобно) ============================= */
|
|
||||||
/* ===================================================================== */
|
|
||||||
|
|
||||||
public static TextBody newPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, String message) {
|
|
||||||
return new TextBody(MsgSubType.TEXT_POST, lineCode, prevLineNumber, prevLineHash32, thisLineNumber,
|
|
||||||
message, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TextBody newEditPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber,
|
|
||||||
int targetBlockNumber, byte[] targetHash32,
|
|
||||||
String message) {
|
|
||||||
return new TextBody(MsgSubType.TEXT_EDIT_POST, lineCode, prevLineNumber, prevLineHash32, thisLineNumber,
|
|
||||||
message, null, targetBlockNumber, targetHash32);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TextBody newReply(String toBlockchainName, int targetBlockNumber, byte[] targetHash32, String message) {
|
|
||||||
return new TextBody(MsgSubType.TEXT_REPLY, -1, -1, null, -1,
|
|
||||||
message, toBlockchainName, targetBlockNumber, targetHash32);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TextBody newEditReply(int targetBlockNumber, byte[] targetHash32, String message) {
|
|
||||||
return new TextBody(MsgSubType.TEXT_EDIT_REPLY, -1, -1, null, -1,
|
|
||||||
message, null, targetBlockNumber, targetHash32);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Универсальный конструктор “вручную”.
|
|
||||||
* Для REPLY/EDIT_REPLY line поля игнорируются при сериализации (их в формате нет).
|
|
||||||
*/
|
|
||||||
public TextBody(short subType,
|
|
||||||
int lineCode,
|
|
||||||
int prevLineNumber,
|
|
||||||
byte[] prevLineHash32,
|
|
||||||
int thisLineNumber,
|
|
||||||
String message,
|
|
||||||
String toBlockchainName,
|
|
||||||
Integer toBlockGlobalNumber,
|
|
||||||
byte[] toBlockHash32) {
|
|
||||||
|
|
||||||
Objects.requireNonNull(message, "message == null");
|
|
||||||
|
|
||||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF));
|
|
||||||
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
|
||||||
|
|
||||||
this.subType = subType;
|
|
||||||
this.version = VER;
|
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
|
||||||
|
|
||||||
// line применима только к POST/EDIT_POST
|
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message");
|
|
||||||
this.lineCode = lineCode;
|
|
||||||
this.prevLineNumber = prevLineNumber;
|
|
||||||
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
|
||||||
this.thisLineNumber = thisLineNumber;
|
|
||||||
} else {
|
|
||||||
this.lineCode = -1;
|
|
||||||
this.prevLineNumber = -1;
|
|
||||||
this.prevLineHash32 = null;
|
|
||||||
this.thisLineNumber = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.message = message;
|
|
||||||
|
|
||||||
// target правила
|
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
|
||||||
this.toBlockchainName = null;
|
|
||||||
this.toBlockGlobalNumber = null;
|
|
||||||
this.toBlockHash32 = null;
|
|
||||||
|
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|
||||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
|
||||||
|
|
||||||
this.toBlockchainName = null; // по ТЗ: не хранить
|
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
|
||||||
|
|
||||||
} else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
|
||||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
|
||||||
|
|
||||||
this.toBlockchainName = toBlockchainName;
|
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
|
||||||
|
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
|
||||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
|
||||||
|
|
||||||
this.toBlockchainName = null; // по ТЗ: не хранить
|
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
this.toBlockchainName = null;
|
|
||||||
this.toBlockGlobalNumber = null;
|
|
||||||
this.toBlockHash32 = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isValidSubType(short st) {
|
|
||||||
int v = st & 0xFFFF;
|
|
||||||
return v == (MsgSubType.TEXT_POST & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.TEXT_REPLY & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public TextBody check() {
|
|
||||||
if (!isValidSubType(subType))
|
|
||||||
throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF));
|
|
||||||
|
|
||||||
if (message == null || message.isBlank())
|
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
|
||||||
|
|
||||||
// локальные проверки line (БД не трогаем)
|
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message");
|
|
||||||
if (prevLineHash32 == null || prevLineHash32.length != 32)
|
|
||||||
throw new IllegalArgumentException("prevLineHash32 invalid");
|
|
||||||
} else {
|
|
||||||
// reply/edit_reply: line отсутствует
|
|
||||||
if (prevLineHash32 != null)
|
|
||||||
throw new IllegalArgumentException("REPLY/EDIT_REPLY must not contain line hash");
|
|
||||||
}
|
|
||||||
|
|
||||||
// target rules
|
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
|
||||||
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
|
||||||
throw new IllegalArgumentException("POST must not contain target fields");
|
|
||||||
|
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|
||||||
if (toBlockchainName != null)
|
|
||||||
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName in target");
|
|
||||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
|
||||||
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
|
||||||
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
|
||||||
|
|
||||||
} else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
|
||||||
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
|
|
||||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
|
||||||
throw new IllegalArgumentException("REPLY toBlockGlobalNumber invalid");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
|
||||||
throw new IllegalArgumentException("REPLY toBlockHash32 invalid");
|
|
||||||
|
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
|
||||||
if (toBlockchainName != null)
|
|
||||||
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName in target");
|
|
||||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
|
||||||
throw new IllegalArgumentException("EDIT_REPLY toBlockGlobalNumber invalid");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
|
||||||
throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 invalid");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public byte[] toBytes() {
|
|
||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
|
||||||
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
|
||||||
// hasLine(lineCode+line) + text
|
|
||||||
int cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.putInt(lineCode);
|
|
||||||
bb.putInt(prevLineNumber);
|
|
||||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
|
||||||
bb.putInt(thisLineNumber);
|
|
||||||
bb.putShort((short) msgUtf8.length);
|
|
||||||
bb.put(msgUtf8);
|
|
||||||
return bb.array();
|
|
||||||
|
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|
||||||
// hasLine(lineCode+line) + target(no bch) + text
|
|
||||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
|
|
||||||
|
|
||||||
int cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.putInt(lineCode);
|
|
||||||
bb.putInt(prevLineNumber);
|
|
||||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
|
||||||
bb.putInt(thisLineNumber);
|
|
||||||
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
|
||||||
bb.put(toBlockHash32);
|
|
||||||
|
|
||||||
bb.putShort((short) msgUtf8.length);
|
|
||||||
bb.put(msgUtf8);
|
|
||||||
return bb.array();
|
|
||||||
|
|
||||||
} else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
|
||||||
// target(with bch) + text
|
|
||||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
|
||||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPLY missing toBlockGlobalNumber");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPLY toBlockHash32 != 32");
|
|
||||||
|
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255)
|
|
||||||
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");
|
|
||||||
|
|
||||||
int cap = 1 + nameUtf8.length + 4 + 32
|
|
||||||
+ 2 + msgUtf8.length;
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.put((byte) nameUtf8.length);
|
|
||||||
bb.put(nameUtf8);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
|
||||||
bb.put(toBlockHash32);
|
|
||||||
|
|
||||||
bb.putShort((short) msgUtf8.length);
|
|
||||||
bb.put(msgUtf8);
|
|
||||||
return bb.array();
|
|
||||||
|
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
|
||||||
// target(no bch) + text
|
|
||||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_REPLY missing toBlockGlobalNumber");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 != 32");
|
|
||||||
|
|
||||||
int cap = (4 + 32) + 2 + msgUtf8.length;
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
|
||||||
bb.put(toBlockHash32);
|
|
||||||
|
|
||||||
bb.putShort((short) msgUtf8.length);
|
|
||||||
bb.put(msgUtf8);
|
|
||||||
return bb.array();
|
|
||||||
|
|
||||||
} else {
|
|
||||||
throw new IllegalStateException("Unsupported Text subType: " + st);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===================================================================== */
|
|
||||||
/* ========================== Helpers ================================== */
|
|
||||||
/* ===================================================================== */
|
|
||||||
|
|
||||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
|
|
||||||
int len = Short.toUnsignedInt(bb.getShort());
|
|
||||||
if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
|
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
|
||||||
bb.get(bytes);
|
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
|
||||||
|
|
||||||
try {
|
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
|
||||||
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
|
||||||
return s;
|
|
||||||
} catch (CharacterCodingException e) {
|
|
||||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
|
||||||
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
|
||||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ====================== BodyHasLine ====================== */
|
|
||||||
@Override public int lineCode() { return lineCode; }
|
|
||||||
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
|
||||||
@Override public byte[] prevLineBlockHash32() {
|
|
||||||
if (prevLineHash32 == null) return null;
|
|
||||||
return Arrays.copyOf(prevLineHash32, 32);
|
|
||||||
}
|
|
||||||
@Override public int lineSeq() { return thisLineNumber; }
|
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ===================== */
|
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
|
||||||
|
|
||||||
/* ===================================================================== */
|
|
||||||
/* ===================== Удобные хелперы (для ChainState) =============== */
|
|
||||||
/* ===================================================================== */
|
|
||||||
|
|
||||||
/** true только для POST / EDIT_POST (т.е. это сообщение в линии канала). */
|
|
||||||
public boolean isLineMessage() {
|
|
||||||
int st = subType & 0xFFFF;
|
|
||||||
return st == (MsgSubType.TEXT_POST & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** true только для EDIT_POST / EDIT_REPLY. */
|
|
||||||
public boolean isEditMessage() {
|
|
||||||
int st = subType & 0xFFFF;
|
|
||||||
return st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** true только для REPLY / EDIT_REPLY (т.е. “не в линии”). */
|
|
||||||
public boolean isReplyFamily() {
|
|
||||||
int st = subType & 0xFFFF;
|
|
||||||
return st == (MsgSubType.TEXT_REPLY & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+85
-285
@@ -11,374 +11,174 @@ import java.util.Arrays;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TextLineBody — type=1, ver=1.
|
* Line-based TEXT body.
|
||||||
*
|
|
||||||
* subType:
|
|
||||||
* - POST (10)
|
|
||||||
* - EDIT_POST (11)
|
|
||||||
* - REPOST (50)
|
|
||||||
* - CHANNEL_META (90)
|
|
||||||
* - ENTRYPOINT (100)
|
|
||||||
* - EXERCISE (110)
|
|
||||||
* - SERVICE (120)
|
|
||||||
* - COURSE (130)
|
|
||||||
*
|
|
||||||
* Формат bodyBytes (BigEndian):
|
|
||||||
*
|
*
|
||||||
* POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
|
* POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
|
||||||
* [4] lineCode
|
* line fields + text
|
||||||
* [4] prevLineNumber
|
|
||||||
* [32] prevLineHash32
|
|
||||||
* [4] thisLineNumber
|
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [N] text UTF-8
|
|
||||||
*
|
*
|
||||||
* EDIT_POST:
|
* EDIT_POST (own-chain compact target):
|
||||||
* [4] lineCode
|
* line fields + [4] targetBlockNumber + [32] targetHash + text
|
||||||
* [4] prevLineNumber
|
|
||||||
* [32] prevLineHash32
|
|
||||||
* [4] thisLineNumber
|
|
||||||
* [4] toBlockGlobalNumber (int32)
|
|
||||||
* [32] toBlockHash32
|
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [N] text UTF-8
|
|
||||||
*
|
*
|
||||||
* REPOST:
|
* REPOST (external target):
|
||||||
* [4] lineCode
|
* line fields + [1]loginLen + login + [2]forkNumber + [4]blockNumber + [32]hash + text
|
||||||
* [4] prevLineNumber
|
|
||||||
* [32] prevLineHash32
|
|
||||||
* [4] thisLineNumber
|
|
||||||
* [1] toBlockchainNameLen (uint8)
|
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber (int32)
|
|
||||||
* [32] toBlockHash32
|
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [N] text UTF-8
|
|
||||||
*/
|
*/
|
||||||
public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarget {
|
public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarget {
|
||||||
|
|
||||||
public static final short TYPE = 1;
|
public static final short TYPE = 1;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
|
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
public final short subType; // из header
|
public final short subType;
|
||||||
public final short version; // из header (=1)
|
public final short version;
|
||||||
|
|
||||||
// line
|
|
||||||
public final int lineCode;
|
public final int lineCode;
|
||||||
public final int prevLineNumber;
|
public final int prevLineNumber;
|
||||||
public final byte[] prevLineHash32; // 32 (может быть нули)
|
public final byte[] prevLineHash32;
|
||||||
public final int thisLineNumber;
|
public final int thisLineNumber;
|
||||||
|
public final String toLogin;
|
||||||
// target (для EDIT_POST / REPOST)
|
public final Integer toForkNumber;
|
||||||
public final String toBlockchainName; // nullable для POST/EDIT_POST
|
public final Integer toBlockGlobalNumber;
|
||||||
public final Integer toBlockGlobalNumber; // nullable для POST
|
public final byte[] toBlockHash32;
|
||||||
public final byte[] toBlockHash32; // nullable для POST
|
|
||||||
|
|
||||||
// text
|
|
||||||
public final String message;
|
public final String message;
|
||||||
|
|
||||||
/* ====================== parse from bytes ====================== */
|
|
||||||
|
|
||||||
public TextLineBody(short subType, short version, byte[] bodyBytes) {
|
public TextLineBody(short subType, short version, byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
if ((version & 0xFFFF) != 1 || !isSupportedSubType(subType)) throw new IllegalArgumentException("Bad TextLineBody header");
|
||||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
|
||||||
throw new IllegalArgumentException("TextLineBody version must be 1, got=" + (this.version & 0xFFFF));
|
|
||||||
}
|
|
||||||
|
|
||||||
int st = this.subType & 0xFFFF;
|
|
||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
|
||||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st);
|
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
BodyTargetSupport.ensureMin(bb, 4 + 4 + 32 + 4 + 2, "TextLineBody too short");
|
||||||
// минимум line + textLen(2)
|
|
||||||
ensureMin(bb, (4 + 4 + 32 + 4) + 2, "TextLineBody too short");
|
|
||||||
|
|
||||||
this.lineCode = bb.getInt();
|
this.lineCode = bb.getInt();
|
||||||
this.prevLineNumber = bb.getInt();
|
this.prevLineNumber = bb.getInt();
|
||||||
|
this.prevLineHash32 = new byte[32]; bb.get(this.prevLineHash32);
|
||||||
this.prevLineHash32 = new byte[32];
|
|
||||||
bb.get(this.prevLineHash32);
|
|
||||||
|
|
||||||
this.thisLineNumber = bb.getInt();
|
this.thisLineNumber = bb.getInt();
|
||||||
|
|
||||||
|
int st = subType & 0xFFFF;
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
// нужен target
|
BodyTargetSupport.ensureMin(bb, 4 + 32 + 2, "EDIT_POST target missing");
|
||||||
ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target");
|
this.toLogin = null;
|
||||||
int tgtNum = bb.getInt();
|
this.toForkNumber = null;
|
||||||
byte[] tgtHash = new byte[32];
|
|
||||||
bb.get(tgtHash);
|
|
||||||
|
|
||||||
this.toBlockchainName = null;
|
|
||||||
this.toBlockGlobalNumber = tgtNum;
|
|
||||||
this.toBlockHash32 = tgtHash;
|
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
|
||||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPOST missing target");
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("REPOST toBlockchainNameLen is 0");
|
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPOST payload too short");
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
|
||||||
bb.get(nameBytes);
|
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
this.toBlockHash32 = new byte[32];
|
this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32);
|
||||||
bb.get(this.toBlockHash32);
|
BodyTargetSupport.validateCompact(this.toBlockGlobalNumber, this.toBlockHash32, "EDIT_POST");
|
||||||
|
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
|
BodyTargetSupport.ExternalTarget t = BodyTargetSupport.readExternal(bb, "REPOST", 2);
|
||||||
|
this.toLogin = t.login();
|
||||||
|
this.toForkNumber = t.forkNumber();
|
||||||
|
this.toBlockGlobalNumber = t.blockNumber();
|
||||||
|
this.toBlockHash32 = t.blockHash32();
|
||||||
} else {
|
} else {
|
||||||
this.toBlockchainName = null;
|
this.toLogin = null;
|
||||||
|
this.toForkNumber = null;
|
||||||
this.toBlockGlobalNumber = null;
|
this.toBlockGlobalNumber = null;
|
||||||
this.toBlockHash32 = null;
|
this.toBlockHash32 = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "TextLineBody text",
|
boolean allowEmpty = st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF);
|
||||||
st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF));
|
this.message = readText(bb, allowEmpty);
|
||||||
|
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected TextLineBody tail");
|
||||||
ensureNoTail(bb, "TextLineBody");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== manual ctor ====================== */
|
|
||||||
|
|
||||||
public TextLineBody(int lineCode,
|
public TextLineBody(int lineCode,
|
||||||
int prevLineNumber,
|
int prevLineNumber,
|
||||||
byte[] prevLineHash32,
|
byte[] prevLineHash32,
|
||||||
int thisLineNumber,
|
int thisLineNumber,
|
||||||
short subType,
|
short subType,
|
||||||
|
String toLogin,
|
||||||
|
Integer toForkNumber,
|
||||||
Integer toBlockGlobalNumber,
|
Integer toBlockGlobalNumber,
|
||||||
byte[] toBlockHash32,
|
byte[] toBlockHash32,
|
||||||
String toBlockchainName,
|
|
||||||
String message) {
|
String message) {
|
||||||
|
|
||||||
Objects.requireNonNull(message, "message == null");
|
Objects.requireNonNull(message, "message == null");
|
||||||
|
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad TextLineBody subtype");
|
||||||
int st = subType & 0xFFFF;
|
|
||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
|
||||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
if (requiresNonBlankMessage(st) && message.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("message is blank");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
|
|
||||||
this.lineCode = lineCode;
|
this.lineCode = lineCode;
|
||||||
this.prevLineNumber = prevLineNumber;
|
this.prevLineNumber = prevLineNumber;
|
||||||
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
this.prevLineHash32 = prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32);
|
||||||
this.thisLineNumber = thisLineNumber;
|
this.thisLineNumber = thisLineNumber;
|
||||||
|
this.toLogin = toLogin;
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
this.toForkNumber = toForkNumber;
|
||||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
this.toBlockHash32 = toBlockHash32 == null ? null : Arrays.copyOf(toBlockHash32, 32);
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
|
||||||
|
|
||||||
this.toBlockchainName = null;
|
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
|
||||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
|
||||||
|
|
||||||
this.toBlockchainName = toBlockchainName;
|
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
|
||||||
} else {
|
|
||||||
this.toBlockchainName = null;
|
|
||||||
this.toBlockGlobalNumber = null;
|
|
||||||
this.toBlockHash32 = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.message = message;
|
this.message = message;
|
||||||
|
check();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override public TextLineBody check() {
|
||||||
public TextLineBody check() {
|
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad TextLineBody subtype");
|
||||||
int st = subType & 0xFFFF;
|
|
||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
|
||||||
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF))
|
|
||||||
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
|
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
if (prevLineHash32 == null || prevLineHash32.length != 32)
|
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||||
throw new IllegalArgumentException("prevLineHash32 invalid");
|
|
||||||
|
|
||||||
|
int st = subType & 0xFFFF;
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
|
if (toLogin != null || toForkNumber != null) throw new IllegalArgumentException("EDIT_POST must not contain login/fork target");
|
||||||
|
BodyTargetSupport.validateCompact(toBlockGlobalNumber, toBlockHash32, "EDIT_POST");
|
||||||
if (message == null) throw new IllegalArgumentException("EDIT_POST message is null");
|
if (message == null) throw new IllegalArgumentException("EDIT_POST message is null");
|
||||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
|
||||||
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
|
||||||
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
|
||||||
if (toBlockchainName != null)
|
|
||||||
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName");
|
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
if (message == null || message.isBlank())
|
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REPOST");
|
||||||
throw new IllegalArgumentException("REPOST message is blank");
|
if (message == null || message.isBlank()) throw new IllegalArgumentException("REPOST message is blank");
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
|
||||||
throw new IllegalArgumentException("REPOST toBlockchainName is blank");
|
|
||||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
|
||||||
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
|
||||||
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
|
|
||||||
} else {
|
} else {
|
||||||
|
if (toLogin != null || toForkNumber != null || toBlockGlobalNumber != null || toBlockHash32 != null) {
|
||||||
|
throw new IllegalArgumentException("Non-target text body must not contain target fields");
|
||||||
|
}
|
||||||
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||||
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
|
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
|
||||||
} else if (requiresNonBlankMessage(st) && (message == null || message.isBlank())) {
|
} else if (message == null || message.isBlank()) {
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
throw new IllegalArgumentException("Text message is blank");
|
||||||
} else if (message == null) {
|
|
||||||
throw new IllegalArgumentException("Text message is null");
|
|
||||||
}
|
}
|
||||||
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
|
||||||
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.getBytes(StandardCharsets.UTF_8).length > 65535) throw new IllegalArgumentException("Text too long");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override public byte[] toBytes() {
|
||||||
public byte[] toBytes() {
|
check();
|
||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
byte[] text = message.getBytes(StandardCharsets.UTF_8);
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (requiresNonBlankMessage(st) && msgUtf8.length == 0) {
|
int targetSize = 0;
|
||||||
throw new IllegalArgumentException("Text payload is empty");
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) targetSize = 4 + 32;
|
||||||
}
|
else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) targetSize = BodyTargetSupport.externalSize(toLogin);
|
||||||
|
|
||||||
int cap;
|
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
|
||||||
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|
||||||
// EDIT_POST
|
|
||||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
|
|
||||||
cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
|
|
||||||
} else {
|
|
||||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName");
|
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
|
||||||
throw new IllegalArgumentException("REPOST toBlockchainName utf8 len must be 1..255");
|
|
||||||
}
|
|
||||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPOST missing toBlockGlobalNumber");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32");
|
|
||||||
cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
|
|
||||||
bb.putInt(lineCode);
|
|
||||||
bb.putInt(prevLineNumber);
|
|
||||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
|
||||||
bb.putInt(thisLineNumber);
|
|
||||||
|
|
||||||
|
ByteBuffer bb = ByteBuffer.allocate(4 + 4 + 32 + 4 + targetSize + 2 + text.length).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.putInt(lineCode).putInt(prevLineNumber).put(prevLineHash32).putInt(thisLineNumber);
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber).put(toBlockHash32);
|
||||||
bb.put(toBlockHash32);
|
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REPOST");
|
||||||
bb.put((byte) nameUtf8.length);
|
|
||||||
bb.put(nameUtf8);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
|
||||||
bb.put(toBlockHash32);
|
|
||||||
}
|
}
|
||||||
|
bb.putShort((short) text.length).put(text);
|
||||||
bb.putShort((short) msgUtf8.length);
|
|
||||||
bb.put(msgUtf8);
|
|
||||||
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== BodyHasLine ====================== */
|
|
||||||
@Override public int lineCode() { return lineCode; }
|
@Override public int lineCode() { return lineCode; }
|
||||||
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||||
@Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); }
|
@Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); }
|
||||||
@Override public int lineSeq() { return thisLineNumber; }
|
@Override public int lineSeq() { return thisLineNumber; }
|
||||||
|
@Override public String toLogin() { return toLogin; }
|
||||||
/* ====================== BodyHasTarget ===================== */
|
@Override public Integer toForkNumber() { return toForkNumber; }
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32 == null ? null : Arrays.copyOf(toBlockHash32, 32); }
|
||||||
|
public boolean isEditPost() { return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF); }
|
||||||
|
|
||||||
/* ====================== helpers ====================== */
|
private static boolean isSupportedSubType(short subType) {
|
||||||
|
int st = subType & 0xFFFF;
|
||||||
public boolean isEditPost() {
|
return st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) || st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF) || st == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean requiresNonBlankMessage(int st) {
|
private static String readText(ByteBuffer bb, boolean allowEmpty) {
|
||||||
return st == (MsgSubType.TEXT_POST & 0xFFFF)
|
if (bb.remaining() < 2) throw new IllegalArgumentException("Text length missing");
|
||||||
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
|
||||||
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
|
||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) {
|
if (len == 0) { if (allowEmpty) return ""; throw new IllegalArgumentException("Text is empty"); }
|
||||||
if (allowEmpty) return "";
|
if (bb.remaining() < len) throw new IllegalArgumentException("Text payload too short");
|
||||||
throw new IllegalArgumentException(fieldName + " is empty");
|
byte[] bytes = new byte[len]; bb.get(bytes);
|
||||||
}
|
var dec = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
try { String s = dec.decode(ByteBuffer.wrap(bytes)).toString(); if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException("Text blank"); return s; }
|
||||||
|
catch (CharacterCodingException e) { throw new IllegalArgumentException("Text invalid UTF-8", e); }
|
||||||
byte[] bytes = new byte[len];
|
|
||||||
bb.get(bytes);
|
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
|
||||||
|
|
||||||
try {
|
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
|
||||||
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
|
||||||
return s;
|
|
||||||
} catch (CharacterCodingException e) {
|
|
||||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
|
||||||
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
|
||||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-106
@@ -10,29 +10,16 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/** TEXT_RATING. External target = login + forkNumber + blockNumber + hash32. */
|
||||||
* TextRatingBody — type=1, ver=1.
|
|
||||||
*
|
|
||||||
* subType:
|
|
||||||
* - RATING (30)
|
|
||||||
*
|
|
||||||
* Формат bodyBytes (BigEndian):
|
|
||||||
* [1] toBlockchainNameLen (uint8)
|
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber
|
|
||||||
* [32] toBlockHash32
|
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [M] text UTF-8
|
|
||||||
*/
|
|
||||||
public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||||
|
|
||||||
public static final short TYPE = 1;
|
public static final short TYPE = 1;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
public final short subType;
|
public final short subType;
|
||||||
public final short version;
|
public final short version;
|
||||||
public final String toBlockchainName;
|
public final String toLogin;
|
||||||
|
public final int toForkNumber;
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
public final String message;
|
public final String message;
|
||||||
@@ -41,117 +28,60 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
if ((version & 0xFFFF) != (VER & 0xFFFF)) throw new IllegalArgumentException("TextRatingBody version must be 1");
|
||||||
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) throw new IllegalArgumentException("TextRatingBody supports only TEXT_RATING");
|
||||||
throw new IllegalArgumentException("TextRatingBody version must be 1, got=" + (version & 0xFFFF));
|
|
||||||
}
|
|
||||||
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
|
||||||
throw new IllegalArgumentException("TextRatingBody supports only TEXT_RATING");
|
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
|
BodyTargetSupport.ExternalTarget t = BodyTargetSupport.readExternal(bb, "RATING", 2);
|
||||||
|
this.toLogin = t.login();
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
this.toForkNumber = t.forkNumber();
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
|
this.toBlockGlobalNumber = t.blockNumber();
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
|
this.toBlockHash32 = t.blockHash32();
|
||||||
|
this.message = readText(bb);
|
||||||
byte[] nameBytes = new byte[nameLen];
|
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected RATING tail");
|
||||||
bb.get(nameBytes);
|
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
|
||||||
this.toBlockHash32 = new byte[32];
|
|
||||||
bb.get(this.toBlockHash32);
|
|
||||||
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
|
|
||||||
|
|
||||||
ensureNoTail(bb, "TextRatingBody");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
public TextRatingBody(String toLogin, int toForkNumber, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
|
||||||
Objects.requireNonNull(message, "message == null");
|
Objects.requireNonNull(message, "message == null");
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
|
||||||
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||||
|
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "RATING");
|
||||||
this.subType = MsgSubType.TEXT_RATING;
|
this.subType = MsgSubType.TEXT_RATING;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toLogin = toLogin;
|
||||||
|
this.toForkNumber = toForkNumber;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
this.message = message;
|
this.message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override public TextRatingBody check() {
|
||||||
public TextRatingBody check() {
|
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) throw new IllegalArgumentException("Bad RATING subType");
|
||||||
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "RATING");
|
||||||
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
|
|
||||||
}
|
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("RATING toBlockchainName is blank");
|
|
||||||
}
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
|
||||||
}
|
|
||||||
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
|
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||||
|
if (message.getBytes(StandardCharsets.UTF_8).length > 65535) throw new IllegalArgumentException("Text too long");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override public byte[] toBytes() {
|
||||||
public byte[] toBytes() {
|
check();
|
||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
byte[] text = message.getBytes(StandardCharsets.UTF_8);
|
||||||
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
ByteBuffer bb = ByteBuffer.allocate(BodyTargetSupport.externalSize(toLogin) + 2 + text.length).order(ByteOrder.BIG_ENDIAN);
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "RATING");
|
||||||
|
bb.putShort((short) text.length).put(text);
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
|
||||||
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
|
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
|
||||||
.order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.put((byte) nameUtf8.length);
|
|
||||||
bb.put(nameUtf8);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
|
||||||
bb.put(toBlockHash32);
|
|
||||||
bb.putShort((short) msgUtf8.length);
|
|
||||||
bb.put(msgUtf8);
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
@Override public String toLogin() { return toLogin; }
|
||||||
|
@Override public Integer toForkNumber() { return toForkNumber; }
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return Arrays.copyOf(toBlockHash32, 32); }
|
||||||
|
|
||||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
|
private static String readText(ByteBuffer bb) {
|
||||||
|
if (bb.remaining() < 2) throw new IllegalArgumentException("RATING text length missing");
|
||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
|
if (len <= 0 || bb.remaining() < len) throw new IllegalArgumentException("RATING text invalid");
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
byte[] bytes = new byte[len]; bb.get(bytes);
|
||||||
|
var dec = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
byte[] bytes = new byte[len];
|
try { String s = dec.decode(ByteBuffer.wrap(bytes)).toString(); if (s.isBlank()) throw new IllegalArgumentException("RATING text blank"); return s; }
|
||||||
bb.get(bytes);
|
catch (CharacterCodingException e) { throw new IllegalArgumentException("RATING text invalid UTF-8", e); }
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
|
||||||
|
|
||||||
try {
|
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
|
||||||
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
|
||||||
return s;
|
|
||||||
} catch (CharacterCodingException e) {
|
|
||||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
|
||||||
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
|
||||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-190
@@ -11,241 +11,111 @@ import java.util.Arrays;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TextReplyBody — type=1, ver=1.
|
* TEXT_REPLY / TEXT_EDIT_REPLY.
|
||||||
*
|
* REPLY external target: login + forkNumber + blockNumber + hash32.
|
||||||
* subType:
|
* EDIT_REPLY compact own-chain target: blockNumber + hash32.
|
||||||
* - REPLY (20)
|
|
||||||
* - EDIT_REPLY (21)
|
|
||||||
*
|
|
||||||
* Форматы bodyBytes (BigEndian):
|
|
||||||
*
|
|
||||||
* REPLY:
|
|
||||||
* [1] toBlockchainNameLen (uint8)
|
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber
|
|
||||||
* [32] toBlockHash32
|
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [M] text UTF-8
|
|
||||||
*
|
|
||||||
* EDIT_REPLY:
|
|
||||||
* [4] toBlockGlobalNumber
|
|
||||||
* [32] toBlockHash32
|
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [N] text UTF-8
|
|
||||||
*/
|
*/
|
||||||
public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||||
|
|
||||||
public static final short TYPE = 1;
|
public static final short TYPE = 1;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
|
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
public final short subType; // из header
|
public final short subType;
|
||||||
public final short version; // (=1)
|
public final short version;
|
||||||
|
public final String toLogin;
|
||||||
// target
|
public final Integer toForkNumber;
|
||||||
public final String toBlockchainName; // nullable для EDIT_REPLY
|
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32; // 32
|
public final byte[] toBlockHash32;
|
||||||
|
|
||||||
// text
|
|
||||||
public final String message;
|
public final String message;
|
||||||
|
|
||||||
public TextReplyBody(short subType, short version, byte[] bodyBytes) {
|
public TextReplyBody(short subType, short version, byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
if ((version & 0xFFFF) != (VER & 0xFFFF)) throw new IllegalArgumentException("TextReplyBody version must be 1");
|
||||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
int st = subType & 0xFFFF;
|
||||||
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
|
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) throw new IllegalArgumentException("Bad reply subtype");
|
||||||
}
|
|
||||||
|
|
||||||
int st = this.subType & 0xFFFF;
|
|
||||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
|
||||||
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
|
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||||
// минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2]
|
BodyTargetSupport.ExternalTarget t = BodyTargetSupport.readExternal(bb, "REPLY", 2);
|
||||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");
|
this.toLogin = t.login();
|
||||||
|
this.toForkNumber = t.forkNumber();
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
this.toBlockGlobalNumber = t.blockNumber();
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
|
this.toBlockHash32 = t.blockHash32();
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");
|
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
|
||||||
bb.get(nameBytes);
|
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
|
||||||
|
|
||||||
this.toBlockHash32 = new byte[32];
|
|
||||||
bb.get(this.toBlockHash32);
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// EDIT_REPLY: target без имени
|
BodyTargetSupport.ensureMin(bb, 4 + 32 + 2, "EDIT_REPLY too short");
|
||||||
ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");
|
this.toLogin = null;
|
||||||
|
this.toForkNumber = null;
|
||||||
this.toBlockchainName = null;
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
|
|
||||||
this.toBlockHash32 = new byte[32];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(this.toBlockHash32);
|
bb.get(this.toBlockHash32);
|
||||||
|
BodyTargetSupport.validateCompact(this.toBlockGlobalNumber, this.toBlockHash32, "EDIT_REPLY");
|
||||||
}
|
}
|
||||||
|
this.message = readText(bb, st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
||||||
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected TextReplyBody tail");
|
||||||
ensureNoTail(bb, "TextReplyBody");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public TextReplyBody(short subType,
|
public TextReplyBody(short subType, String toLogin, Integer toForkNumber, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||||
int toBlockGlobalNumber,
|
|
||||||
byte[] toBlockHash32,
|
|
||||||
String toBlockchainName,
|
|
||||||
String message) {
|
|
||||||
|
|
||||||
Objects.requireNonNull(message, "message == null");
|
Objects.requireNonNull(message, "message == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) throw new IllegalArgumentException("Bad reply subtype");
|
||||||
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY");
|
|
||||||
}
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("message is blank");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
if (message.isBlank()) throw new IllegalArgumentException("REPLY message is blank");
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REPLY");
|
||||||
this.toBlockchainName = toBlockchainName;
|
|
||||||
} else {
|
} else {
|
||||||
// EDIT_REPLY: имя не хранить
|
if (toLogin != null || toForkNumber != null) throw new IllegalArgumentException("EDIT_REPLY must not contain login/fork target");
|
||||||
this.toBlockchainName = null;
|
BodyTargetSupport.validateCompact(toBlockGlobalNumber, toBlockHash32, "EDIT_REPLY");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
|
this.toLogin = toLogin;
|
||||||
|
this.toForkNumber = toForkNumber;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
|
|
||||||
this.message = message;
|
this.message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override public TextReplyBody check() {
|
||||||
public TextReplyBody check() {
|
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
|
|
||||||
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
|
||||||
|
|
||||||
if (toBlockGlobalNumber < 0)
|
|
||||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||||
if (message == null || message.isBlank())
|
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REPLY");
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
if (message == null || message.isBlank()) throw new IllegalArgumentException("REPLY message is blank");
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||||
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
|
if (toLogin != null || toForkNumber != null) throw new IllegalArgumentException("EDIT_REPLY must not contain login/fork target");
|
||||||
} else {
|
BodyTargetSupport.validateCompact(toBlockGlobalNumber, toBlockHash32, "EDIT_REPLY");
|
||||||
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
|
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
|
||||||
if (toBlockchainName != null)
|
} else throw new IllegalArgumentException("Bad reply subtype");
|
||||||
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
|
if (message.getBytes(StandardCharsets.UTF_8).length > 65535) throw new IllegalArgumentException("Text too long");
|
||||||
}
|
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override public byte[] toBytes() {
|
||||||
public byte[] toBytes() {
|
check();
|
||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
byte[] text = message.getBytes(StandardCharsets.UTF_8);
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
boolean reply = (subType & 0xFFFF) == (MsgSubType.TEXT_REPLY & 0xFFFF);
|
||||||
|
int targetSize = reply ? BodyTargetSupport.externalSize(toLogin) : 4 + 32;
|
||||||
int st = subType & 0xFFFF;
|
ByteBuffer bb = ByteBuffer.allocate(targetSize + 2 + text.length).order(ByteOrder.BIG_ENDIAN);
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
|
if (reply) BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REPLY");
|
||||||
throw new IllegalArgumentException("Text payload is empty");
|
else bb.putInt(toBlockGlobalNumber).put(toBlockHash32);
|
||||||
}
|
bb.putShort((short) text.length).put(text);
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
|
||||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
|
||||||
|
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255)
|
|
||||||
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");
|
|
||||||
|
|
||||||
int cap = 1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length;
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.put((byte) nameUtf8.length);
|
|
||||||
bb.put(nameUtf8);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
|
||||||
bb.put(toBlockHash32);
|
|
||||||
bb.putShort((short) msgUtf8.length);
|
|
||||||
bb.put(msgUtf8);
|
|
||||||
|
|
||||||
return bb.array();
|
|
||||||
}
|
|
||||||
|
|
||||||
// EDIT_REPLY
|
|
||||||
int cap = (4 + 32) + 2 + msgUtf8.length;
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
|
||||||
bb.put(toBlockHash32);
|
|
||||||
bb.putShort((short) msgUtf8.length);
|
|
||||||
bb.put(msgUtf8);
|
|
||||||
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ====================== */
|
@Override public String toLogin() { return toLogin; }
|
||||||
|
@Override public Integer toForkNumber() { return toForkNumber; }
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return Arrays.copyOf(toBlockHash32, 32); }
|
||||||
|
public boolean isEditReply() { return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); }
|
||||||
|
|
||||||
public boolean isEditReply() {
|
private static String readText(ByteBuffer bb, boolean allowEmpty) {
|
||||||
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
if (bb.remaining() < 2) throw new IllegalArgumentException("Text length missing");
|
||||||
}
|
|
||||||
|
|
||||||
/* ====================== helpers ====================== */
|
|
||||||
|
|
||||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
|
||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) {
|
if (len == 0) { if (allowEmpty) return ""; throw new IllegalArgumentException("Text is empty"); }
|
||||||
if (allowEmpty) return "";
|
if (bb.remaining() < len) throw new IllegalArgumentException("Text payload too short");
|
||||||
throw new IllegalArgumentException(fieldName + " is empty");
|
byte[] bytes = new byte[len]; bb.get(bytes);
|
||||||
}
|
var dec = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
try { String s = dec.decode(ByteBuffer.wrap(bytes)).toString(); if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException("Text is blank"); return s; }
|
||||||
|
catch (CharacterCodingException e) { throw new IllegalArgumentException("Text invalid UTF-8", e); }
|
||||||
byte[] bytes = new byte[len];
|
|
||||||
bb.get(bytes);
|
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
|
||||||
|
|
||||||
try {
|
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
|
||||||
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
|
||||||
return s;
|
|
||||||
} catch (CharacterCodingException e) {
|
|
||||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
|
||||||
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
|
||||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-52
@@ -1,71 +1,54 @@
|
|||||||
package utils.blockchain;
|
package utils.blockchain;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** Canonical physical fork name helper: login-NNN, forks are limited to 001..999. */
|
||||||
public final class BlockchainNameUtil {
|
public final class BlockchainNameUtil {
|
||||||
|
private static final Pattern SUFFIX = Pattern.compile("^(.*)-(\\d{3})$");
|
||||||
/**
|
public static final int MIN_FORK_NUMBER = 1;
|
||||||
* Теперь новое правило:
|
public static final int MAX_FORK_NUMBER = 999;
|
||||||
* blockchainName = login + "-"+ 3 цифры
|
|
||||||
* Пример: "Dima-001" -> "Dima"
|
|
||||||
*
|
|
||||||
* Сколько символов отрезаем с конца blockchainName, чтобы получить login: "-001" = 4
|
|
||||||
*/
|
|
||||||
public static final int BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN = 4;
|
|
||||||
|
|
||||||
private BlockchainNameUtil() {}
|
private BlockchainNameUtil() {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Извлечь login из blockchainName: отрезаем последние 4 символа ("-NNN").
|
|
||||||
* Пример: "Dima-001" -> "Dima"
|
|
||||||
*/
|
|
||||||
public static String loginFromBlockchainName(String blockchainName) {
|
public static String loginFromBlockchainName(String blockchainName) {
|
||||||
if (blockchainName == null) return null;
|
Matcher m = matcher(blockchainName);
|
||||||
|
return m == null ? null : m.group(1);
|
||||||
|
}
|
||||||
|
|
||||||
String s = blockchainName.trim();
|
public static Integer forkNumberFromBlockchainName(String blockchainName) {
|
||||||
if (!hasDashAnd3DigitsSuffix(s)) return null;
|
Matcher m = matcher(blockchainName);
|
||||||
|
if (m == null) return null;
|
||||||
return s.substring(0, s.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN);
|
int fork = Integer.parseInt(m.group(2));
|
||||||
|
return fork >= MIN_FORK_NUMBER && fork <= MAX_FORK_NUMBER ? fork : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверка правила:
|
|
||||||
* - blockchainName должен оканчиваться на "-"+3 цифры
|
|
||||||
* - blockchainName без суффикса "-NNN" должен равняться login
|
|
||||||
*
|
|
||||||
* ВАЖНО:
|
|
||||||
* - сравнение строгое (case-sensitive)
|
|
||||||
* - null/blank считаем невалидным
|
|
||||||
*/
|
|
||||||
public static boolean isBlockchainNameMatchesLogin(String blockchainName, String login) {
|
public static boolean isBlockchainNameMatchesLogin(String blockchainName, String login) {
|
||||||
if (blockchainName == null || login == null) return false;
|
if (login == null || login.isBlank()) return false;
|
||||||
|
String extracted = loginFromBlockchainName(blockchainName);
|
||||||
String bn = blockchainName.trim();
|
return extracted != null && Objects.equals(extracted, login.trim());
|
||||||
String lg = login.trim();
|
|
||||||
|
|
||||||
if (bn.isEmpty() || lg.isEmpty()) return false;
|
|
||||||
if (!hasDashAnd3DigitsSuffix(bn)) return false;
|
|
||||||
|
|
||||||
String extracted = bn.substring(0, bn.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN);
|
|
||||||
return Objects.equals(extracted, lg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean hasDashAnd3DigitsSuffix(String s) {
|
public static String blockchainName(String login, int forkNumber) {
|
||||||
if (s == null) return false;
|
if (login == null || login.isBlank()) throw new IllegalArgumentException("login is blank");
|
||||||
int len = s.length();
|
if (forkNumber < MIN_FORK_NUMBER || forkNumber > MAX_FORK_NUMBER) {
|
||||||
if (len <= BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN) return false;
|
throw new IllegalArgumentException("forkNumber must be 1..999");
|
||||||
|
}
|
||||||
int dashPos = len - 4;
|
return login.trim() + "-" + String.format("%03d", forkNumber);
|
||||||
if (s.charAt(dashPos) != '-') return false;
|
|
||||||
|
|
||||||
char c1 = s.charAt(len - 3);
|
|
||||||
char c2 = s.charAt(len - 2);
|
|
||||||
char c3 = s.charAt(len - 1);
|
|
||||||
|
|
||||||
return isDigit(c1) && isDigit(c2) && isDigit(c3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isDigit(char c) {
|
private static Matcher matcher(String blockchainName) {
|
||||||
return c >= '0' && c <= '9';
|
if (blockchainName == null) return null;
|
||||||
|
String s = blockchainName.trim();
|
||||||
|
if (s.isEmpty()) return null;
|
||||||
|
Matcher m = SUFFIX.matcher(s);
|
||||||
|
if (!m.matches() || m.group(1).isBlank()) return null;
|
||||||
|
try {
|
||||||
|
int fork = Integer.parseInt(m.group(2));
|
||||||
|
return fork >= MIN_FORK_NUMBER && fork <= MAX_FORK_NUMBER ? m : null;
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
package utils.files;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.*;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ===============================================================
|
|
||||||
* FileStoreUtil — утилита работы с файлами в папке data/.
|
|
||||||
*
|
|
||||||
* Теперь поддерживает:
|
|
||||||
* - основной файл блокчейна: <blockchainName>.bch
|
|
||||||
* - временный файл блокчейна: <blockchainName>.tmp_bch
|
|
||||||
* - sidecar-файл проверки записи: <blockchainName>.write_check
|
|
||||||
* - marker-файл записи: <blockchainName>.write_pending
|
|
||||||
*
|
|
||||||
* Важное:
|
|
||||||
* - validateSimpleFileName() запрещает path traversal.
|
|
||||||
* - atomicReplaceBlockchainFile(): пытается сделать ATOMIC_MOVE (если ФС поддерживает),
|
|
||||||
* иначе делает обычный REPLACE_EXISTING move.
|
|
||||||
* ===============================================================
|
|
||||||
*/
|
|
||||||
public final class FileStoreUtil {
|
|
||||||
|
|
||||||
/** Базовая папка для хранения всех файлов (создаётся автоматически). */
|
|
||||||
public static final String DATA_DIR_NAME = "data";
|
|
||||||
|
|
||||||
/** Расширение основного файла блокчейна. */
|
|
||||||
public static final String BLOCKCHAIN_FILE_EXTENSION = ".bch";
|
|
||||||
|
|
||||||
/** Расширение временного файла (старое+новое). */
|
|
||||||
public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch";
|
|
||||||
|
|
||||||
/** Маркер того, что chain сейчас в процессе полного resync. */
|
|
||||||
public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending";
|
|
||||||
|
|
||||||
/** Marker того, что обычный AddBlock находится в опасной фазе записи. */
|
|
||||||
public static final String BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION = ".write_pending";
|
|
||||||
|
|
||||||
/** Sidecar-файл с blockNumber/blockHash для обычного AddBlock. */
|
|
||||||
public static final String BLOCKCHAIN_WRITE_CHECK_EXTENSION = ".write_check";
|
|
||||||
|
|
||||||
private static final FileStoreUtil INSTANCE = new FileStoreUtil();
|
|
||||||
|
|
||||||
private final Path dataDirPath;
|
|
||||||
|
|
||||||
private FileStoreUtil() {
|
|
||||||
this.dataDirPath = Paths.get(DATA_DIR_NAME);
|
|
||||||
ensureDataDirExists();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static FileStoreUtil getInstance() {
|
|
||||||
return INSTANCE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===================================================================== */
|
|
||||||
/* ======================== Базовые операции =========================== */
|
|
||||||
/* ===================================================================== */
|
|
||||||
|
|
||||||
public void newFile(String fileName, byte[] data) {
|
|
||||||
Objects.requireNonNull(data, "data == null");
|
|
||||||
Path target = resolveSafe(fileName);
|
|
||||||
try {
|
|
||||||
Files.write(target, data,
|
|
||||||
StandardOpenOption.CREATE,
|
|
||||||
StandardOpenOption.TRUNCATE_EXISTING,
|
|
||||||
StandardOpenOption.WRITE);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("Не удалось записать файл: " + target, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void addDataToFile(String fileName, byte[] data) {
|
|
||||||
Objects.requireNonNull(data, "data == null");
|
|
||||||
Path target = resolveSafe(fileName);
|
|
||||||
try {
|
|
||||||
Files.write(target, data,
|
|
||||||
StandardOpenOption.CREATE,
|
|
||||||
StandardOpenOption.WRITE,
|
|
||||||
StandardOpenOption.APPEND);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("Не удалось дописать файл: " + target, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public byte[] readAllDataFromFile(String fileName) {
|
|
||||||
Path target = resolveSafe(fileName);
|
|
||||||
if (!Files.exists(target)) {
|
|
||||||
throw new IllegalStateException("Файл не найден: " + target);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return Files.readAllBytes(target);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("Не удалось прочитать файл: " + target, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean exists(String fileName) {
|
|
||||||
Path target = resolveSafe(fileName);
|
|
||||||
return Files.exists(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
public long size(String fileName) {
|
|
||||||
Path target = resolveSafe(fileName);
|
|
||||||
try {
|
|
||||||
return Files.size(target);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("Не удалось получить размер файла: " + target, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===================================================================== */
|
|
||||||
/* ===================== Блокчейн-файлы по имени ======================= */
|
|
||||||
/* ===================================================================== */
|
|
||||||
|
|
||||||
/** <blockchainName>.bch */
|
|
||||||
public String buildBlockchainFileName(String blockchainName) {
|
|
||||||
validateSimpleFileName(blockchainName);
|
|
||||||
return blockchainName + BLOCKCHAIN_FILE_EXTENSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** <blockchainName>.tmp_bch */
|
|
||||||
public String buildBlockchainTmpFileName(String blockchainName) {
|
|
||||||
validateSimpleFileName(blockchainName);
|
|
||||||
return blockchainName + BLOCKCHAIN_TMP_EXTENSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Path resolveBlockchainPath(String blockchainName) {
|
|
||||||
return resolveSafe(buildBlockchainFileName(blockchainName));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Path resolveBlockchainTmpPath(String blockchainName) {
|
|
||||||
return resolveSafe(buildBlockchainTmpFileName(blockchainName));
|
|
||||||
}
|
|
||||||
|
|
||||||
public byte[] readBlockchain(String blockchainName) {
|
|
||||||
return readAllDataFromFile(buildBlockchainFileName(blockchainName));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void writeBlockchainTmp(String blockchainName, byte[] data) {
|
|
||||||
newFile(buildBlockchainTmpFileName(blockchainName), data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** <blockchainName>.write_check */
|
|
||||||
public String buildBlockchainWriteCheckFileName(String blockchainName) {
|
|
||||||
validateSimpleFileName(blockchainName);
|
|
||||||
return blockchainName + BLOCKCHAIN_WRITE_CHECK_EXTENSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Path resolveBlockchainWriteCheckPath(String blockchainName) {
|
|
||||||
return resolveSafe(buildBlockchainWriteCheckFileName(blockchainName));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void writeBlockchainWriteCheck(String blockchainName, int blockNumber, String blockHashHex) {
|
|
||||||
StringBuilder sb = new StringBuilder(128);
|
|
||||||
sb.append("blockNumber=").append(blockNumber).append('\n');
|
|
||||||
sb.append("blockHash=").append(blockHashHex == null ? "" : blockHashHex).append('\n');
|
|
||||||
newFile(buildBlockchainWriteCheckFileName(blockchainName), sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** <blockchainName>.write_pending */
|
|
||||||
public String buildBlockchainWritePendingMarkerFileName(String blockchainName) {
|
|
||||||
validateSimpleFileName(blockchainName);
|
|
||||||
return blockchainName + BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Path resolveBlockchainWritePendingMarkerPath(String blockchainName) {
|
|
||||||
return resolveSafe(buildBlockchainWritePendingMarkerFileName(blockchainName));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void writeBlockchainWritePendingMarker(String blockchainName) {
|
|
||||||
newFile(buildBlockchainWritePendingMarkerFileName(blockchainName), new byte[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** <blockchainName>.resync_pending */
|
|
||||||
public String buildBlockchainResyncMarkerFileName(String blockchainName) {
|
|
||||||
validateSimpleFileName(blockchainName);
|
|
||||||
return blockchainName + BLOCKCHAIN_RESYNC_MARKER_EXTENSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Path resolveBlockchainResyncMarkerPath(String blockchainName) {
|
|
||||||
return resolveSafe(buildBlockchainResyncMarkerFileName(blockchainName));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void writeBlockchainResyncMarker(String blockchainName, String markerContent) {
|
|
||||||
byte[] data = markerContent == null ? new byte[0] : markerContent.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
|
||||||
newFile(buildBlockchainResyncMarkerFileName(blockchainName), data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void deleteIfExists(Path path) {
|
|
||||||
if (path == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Files.deleteIfExists(path);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("Не удалось удалить файл: " + path, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void deleteBlockchainFileIfExists(String blockchainName) {
|
|
||||||
deleteIfExists(resolveBlockchainPath(blockchainName));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void deleteBlockchainTmpFileIfExists(String blockchainName) {
|
|
||||||
deleteIfExists(resolveBlockchainTmpPath(blockchainName));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void deleteBlockchainWriteCheckIfExists(String blockchainName) {
|
|
||||||
deleteIfExists(resolveBlockchainWriteCheckPath(blockchainName));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void deleteBlockchainWritePendingMarkerIfExists(String blockchainName) {
|
|
||||||
deleteIfExists(resolveBlockchainWritePendingMarkerPath(blockchainName));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void deleteBlockchainResyncMarkerIfExists(String blockchainName) {
|
|
||||||
deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName));
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean existsBlockchainResyncMarker(String blockchainName) {
|
|
||||||
return exists(buildBlockchainResyncMarkerFileName(blockchainName));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Атомарно заменить основной файл блокчейна временным:
|
|
||||||
* <name>.tmp_bch -> <name>.bch
|
|
||||||
*
|
|
||||||
* Стратегия:
|
|
||||||
* 1) Пытаемся Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING)
|
|
||||||
* 2) Если ATOMIC_MOVE не поддерживается — делаем move с REPLACE_EXISTING без атомарности
|
|
||||||
*
|
|
||||||
* Важный нюанс:
|
|
||||||
* - атомарность гарантируется только в пределах одной файловой системы.
|
|
||||||
*/
|
|
||||||
public void atomicReplaceBlockchainFile(String blockchainName) {
|
|
||||||
Path tmp = resolveBlockchainTmpPath(blockchainName);
|
|
||||||
Path main = resolveBlockchainPath(blockchainName);
|
|
||||||
|
|
||||||
if (!Files.exists(tmp)) {
|
|
||||||
throw new IllegalStateException("TMP-файл не найден: " + tmp);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1) Пытаемся атомарный move
|
|
||||||
Files.move(tmp, main,
|
|
||||||
StandardCopyOption.REPLACE_EXISTING,
|
|
||||||
StandardCopyOption.ATOMIC_MOVE);
|
|
||||||
} catch (AtomicMoveNotSupportedException e) {
|
|
||||||
// 2) Если ФС не поддерживает атомарный move — делаем обычный replace
|
|
||||||
try {
|
|
||||||
Files.move(tmp, main, StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
} catch (IOException ex) {
|
|
||||||
throw new IllegalStateException("Не удалось заменить файл блокчейна (non-atomic): " + main, ex);
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("Не удалось заменить файл блокчейна (atomic): " + main, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===================================================================== */
|
|
||||||
/* ============================ Helpers ================================= */
|
|
||||||
/* ===================================================================== */
|
|
||||||
|
|
||||||
private void ensureDataDirExists() {
|
|
||||||
try {
|
|
||||||
if (!Files.exists(dataDirPath)) {
|
|
||||||
Files.createDirectories(dataDirPath);
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("Не удалось создать директорию хранения: " + dataDirPath, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path resolveSafe(String fileName) {
|
|
||||||
validateSimpleFileName(fileName);
|
|
||||||
return dataDirPath.resolve(fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Валидация "простого имени":
|
|
||||||
* - запрещаем слэши, обратные слэши, ".."
|
|
||||||
* - запрещаем пустоту
|
|
||||||
*
|
|
||||||
* Важно: сюда у нас попадает и blockchainName (как часть имени файла),
|
|
||||||
* поэтому blockchainName должен быть "простым": без путей.
|
|
||||||
*/
|
|
||||||
private void validateSimpleFileName(String fileName) {
|
|
||||||
Objects.requireNonNull(fileName, "fileName == null");
|
|
||||||
if (fileName.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("Имя файла не должно быть пустым");
|
|
||||||
}
|
|
||||||
if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
|
|
||||||
throw new IllegalArgumentException("Недопустимое имя файла: " + fileName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package blockchain;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import utils.crypto.Ed25519Util;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
final class Ans104DataItemTest {
|
||||||
|
@Test
|
||||||
|
void ed25519RoundTripWithTestAndChannelTags() {
|
||||||
|
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-test-key");
|
||||||
|
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
|
||||||
|
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
|
||||||
|
List<Ans104DataItem.Tag> tags = List.of(
|
||||||
|
new Ans104DataItem.Tag("App", "test5590"),
|
||||||
|
new Ans104DataItem.Tag("c_test5590", "books")
|
||||||
|
);
|
||||||
|
|
||||||
|
byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data);
|
||||||
|
byte[] signature = Ed25519Util.sign(message, privateKey);
|
||||||
|
byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data);
|
||||||
|
|
||||||
|
Ans104DataItem parsed = new Ans104DataItem(raw);
|
||||||
|
assertEquals(2, parsed.signatureType());
|
||||||
|
assertArrayEquals(owner, parsed.owner32());
|
||||||
|
assertArrayEquals(data, parsed.data());
|
||||||
|
assertTrue(parsed.hasTag("App", "test5590"));
|
||||||
|
assertEquals("books", parsed.tagValue("c_test5590"));
|
||||||
|
assertTrue(parsed.verifySignature());
|
||||||
|
assertEquals(32, parsed.id32().length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mutationBreaksSignature() {
|
||||||
|
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-mutation-key");
|
||||||
|
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
|
||||||
|
byte[] data = "original".getBytes(StandardCharsets.UTF_8);
|
||||||
|
List<Ans104DataItem.Tag> tags = List.of(new Ans104DataItem.Tag("App", "test5590"));
|
||||||
|
byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey);
|
||||||
|
byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data);
|
||||||
|
raw[raw.length - 1] ^= 1;
|
||||||
|
assertFalse(new Ans104DataItem(raw).verifySignature());
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
void signingMessageMatchesArbundlesTurboCompatibilityVector() {
|
||||||
|
byte[] owner = new byte[32];
|
||||||
|
for (int i = 0; i < owner.length; i++) owner[i] = (byte) i;
|
||||||
|
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
|
||||||
|
List<Ans104DataItem.Tag> tags = List.of(
|
||||||
|
new Ans104DataItem.Tag("App", "test5590"),
|
||||||
|
new Ans104DataItem.Tag("c_test5590", "books")
|
||||||
|
);
|
||||||
|
|
||||||
|
byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data);
|
||||||
|
byte[] expected = hex("1abe0371d12268b34be32ca5ebf3d3d2189f9d311d9004c142538ca8a4312faa329d1a4873df72dd01b632a2ecce6b87");
|
||||||
|
assertArrayEquals(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] hex(String value) {
|
||||||
|
if ((value.length() & 1) != 0) throw new IllegalArgumentException("odd hex length");
|
||||||
|
byte[] out = new byte[value.length() / 2];
|
||||||
|
for (int i = 0; i < out.length; i++) {
|
||||||
|
int hi = Character.digit(value.charAt(i * 2), 16);
|
||||||
|
int lo = Character.digit(value.charAt(i * 2 + 1), 16);
|
||||||
|
if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex");
|
||||||
|
out[i] = (byte) ((hi << 4) | lo);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package blockchain;
|
||||||
|
|
||||||
|
import blockchain.body.ForkBody;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
final class ForkBodyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundTripThroughBodyRecordParser() {
|
||||||
|
byte[] parentKey = filled(32, 0x11);
|
||||||
|
byte[] forkHash = filled(32, 0x22);
|
||||||
|
byte[] tipHash = filled(32, 0x33);
|
||||||
|
|
||||||
|
ForkBody source = new ForkBody(
|
||||||
|
parentKey,
|
||||||
|
120,
|
||||||
|
forkHash,
|
||||||
|
1_700_000_000_000L,
|
||||||
|
137,
|
||||||
|
tipHash,
|
||||||
|
1_700_100_000_000L,
|
||||||
|
17,
|
||||||
|
ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK,
|
||||||
|
"Удаляю неизвестные записи"
|
||||||
|
).check();
|
||||||
|
|
||||||
|
ForkBody parsed = assertInstanceOf(
|
||||||
|
ForkBody.class,
|
||||||
|
BodyRecordParser.parse(ForkBody.TYPE, ForkBody.SUBTYPE, ForkBody.VER, source.toBytes())
|
||||||
|
);
|
||||||
|
|
||||||
|
assertArrayEquals(parentKey, parsed.parentBlockchainKey32);
|
||||||
|
assertEquals(120, parsed.forkPointBlockNumber);
|
||||||
|
assertArrayEquals(forkHash, parsed.forkPointBlockHash32);
|
||||||
|
assertEquals(137, parsed.parentTipBlockNumber);
|
||||||
|
assertArrayEquals(tipHash, parsed.parentTipBlockHash32);
|
||||||
|
assertEquals(17, parsed.discardedBlocksCount);
|
||||||
|
assertEquals(ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK, parsed.reasonCode);
|
||||||
|
assertEquals("Удаляю неизвестные записи", parsed.comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void routineRotationMayDiscardNothing() {
|
||||||
|
ForkBody body = new ForkBody(
|
||||||
|
filled(32, 1),
|
||||||
|
10,
|
||||||
|
filled(32, 2),
|
||||||
|
1000,
|
||||||
|
10,
|
||||||
|
filled(32, 2),
|
||||||
|
1000,
|
||||||
|
0,
|
||||||
|
ForkBody.REASON_ROUTINE_ROTATION,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
assertDoesNotThrow(body::check);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectedWhenDiscardCountDoesNotMatchForkPointAndTip() {
|
||||||
|
ForkBody body = new ForkBody(
|
||||||
|
filled(32, 1),
|
||||||
|
10,
|
||||||
|
filled(32, 2),
|
||||||
|
1000,
|
||||||
|
12,
|
||||||
|
filled(32, 3),
|
||||||
|
2000,
|
||||||
|
99,
|
||||||
|
ForkBody.REASON_POSSIBLE_COMPROMISE,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
assertThrows(IllegalArgumentException.class, body::check);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] filled(int size, int value) {
|
||||||
|
byte[] out = new byte[size];
|
||||||
|
Arrays.fill(out, (byte) value);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package blockchain;
|
||||||
|
|
||||||
|
import blockchain.body.ConnectionBody;
|
||||||
|
import blockchain.body.HeaderBody;
|
||||||
|
import blockchain.body.ReactionBody;
|
||||||
|
import blockchain.body.StatusActionBody;
|
||||||
|
import blockchain.body.TextLineBody;
|
||||||
|
import blockchain.body.TextRatingBody;
|
||||||
|
import blockchain.body.TextReplyBody;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
final class TargetLoginFormatTest {
|
||||||
|
|
||||||
|
private static final byte[] HASH = hash(7);
|
||||||
|
private static final byte[] INITIAL_KEY = hash(11);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void externalTargetRoundTripKeepsHistoricalForkMetadata() {
|
||||||
|
ReactionBody source = new ReactionBody(MsgSubType.REACTION_LIKE, "alice", 7, 42, HASH);
|
||||||
|
ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check();
|
||||||
|
|
||||||
|
assertEquals("alice", parsed.toLogin());
|
||||||
|
assertEquals(7, parsed.toForkNumber());
|
||||||
|
assertEquals(42, parsed.toBlockGlobalNumber());
|
||||||
|
assertArrayEquals(HASH, parsed.toBlockHashBytes());
|
||||||
|
assertFalse(new String(source.toBytes()).contains("alice-007"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyIsExternalButEditReplyIsCompactOwnChainTarget() {
|
||||||
|
TextReplyBody reply = new TextReplyBody(MsgSubType.TEXT_REPLY, "alice", 12, 11, HASH, "reply");
|
||||||
|
TextReplyBody parsedReply = new TextReplyBody(MsgSubType.TEXT_REPLY, TextReplyBody.VER, reply.toBytes()).check();
|
||||||
|
assertEquals("alice", parsedReply.toLogin());
|
||||||
|
assertEquals(12, parsedReply.toForkNumber());
|
||||||
|
|
||||||
|
TextReplyBody edit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, null, null, 11, HASH, "");
|
||||||
|
TextReplyBody parsedEdit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, TextReplyBody.VER, edit.toBytes()).check();
|
||||||
|
assertNull(parsedEdit.toLogin());
|
||||||
|
assertNull(parsedEdit.toForkNumber());
|
||||||
|
assertEquals(11, parsedEdit.toBlockGlobalNumber());
|
||||||
|
assertArrayEquals(HASH, parsedEdit.toBlockHashBytes());
|
||||||
|
assertTrue(edit.toBytes().length < reply.toBytes().length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editPostIsCompactAndConnectionUsesRealExternalTarget() {
|
||||||
|
TextLineBody editPost = new TextLineBody(
|
||||||
|
0, -1, new byte[32], -1,
|
||||||
|
MsgSubType.TEXT_EDIT_POST,
|
||||||
|
null, null, 5, HASH, "edited"
|
||||||
|
);
|
||||||
|
TextLineBody parsedEditPost = new TextLineBody(MsgSubType.TEXT_EDIT_POST, TextLineBody.VER, editPost.toBytes()).check();
|
||||||
|
assertNull(parsedEditPost.toLogin());
|
||||||
|
assertNull(parsedEditPost.toForkNumber());
|
||||||
|
assertEquals(5, parsedEditPost.toBlockGlobalNumber());
|
||||||
|
|
||||||
|
ConnectionBody connection = new ConnectionBody(
|
||||||
|
0, -1, new byte[32], -1,
|
||||||
|
MsgSubType.CONNECTION_FOLLOW,
|
||||||
|
"alice", 3, 0, HASH
|
||||||
|
);
|
||||||
|
ConnectionBody parsedConnection = new ConnectionBody(MsgSubType.CONNECTION_FOLLOW, ConnectionBody.VER, connection.toBytes()).check();
|
||||||
|
assertEquals("alice", parsedConnection.toLogin());
|
||||||
|
assertEquals(3, parsedConnection.toForkNumber());
|
||||||
|
assertEquals(0, parsedConnection.toBlockGlobalNumber());
|
||||||
|
assertArrayEquals(HASH, parsedConnection.toBlockHashBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ratingAndStatusCarryHistoricalForkNumber() {
|
||||||
|
TextRatingBody rating = new TextRatingBody("alice", 9, 70, HASH, "5");
|
||||||
|
TextRatingBody parsedRating = new TextRatingBody(MsgSubType.TEXT_RATING, TextRatingBody.VER, rating.toBytes()).check();
|
||||||
|
assertEquals(9, parsedRating.toForkNumber());
|
||||||
|
|
||||||
|
StatusActionBody status = new StatusActionBody(MsgSubType.STATUS_STARTED, "alice", 9, 77, HASH, "");
|
||||||
|
StatusActionBody parsedStatus = new StatusActionBody(MsgSubType.STATUS_STARTED, StatusActionBody.VER, status.toBytes()).check();
|
||||||
|
assertEquals("alice", parsedStatus.toLogin());
|
||||||
|
assertEquals(9, parsedStatus.toForkNumber());
|
||||||
|
assertEquals(77, parsedStatus.toBlockGlobalNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forkZeroIsReservedButStillDecodableAsHistoricalUnknown() {
|
||||||
|
ReactionBody source = new ReactionBody(MsgSubType.REACTION_LIKE, "alice", 0, 42, HASH);
|
||||||
|
ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check();
|
||||||
|
assertEquals(0, parsed.toForkNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void headerCarriesInitialBlockchainKey() {
|
||||||
|
HeaderBody source = new HeaderBody("alice", INITIAL_KEY);
|
||||||
|
HeaderBody parsed = new HeaderBody(HeaderBody.SUBTYPE_COMPAT, HeaderBody.VER, source.toBytes()).check();
|
||||||
|
assertEquals("alice", parsed.login);
|
||||||
|
assertArrayEquals(INITIAL_KEY, parsed.initialBlockchainKey32);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] hash(int seed) {
|
||||||
|
byte[] out = new byte[32];
|
||||||
|
Arrays.fill(out, (byte) seed);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,19 +66,20 @@ public final class AppConfig {
|
|||||||
|
|
||||||
/** Вернёт строку или пустую строку, если параметр не найден. */
|
/** Вернёт строку или пустую строку, если параметр не найден. */
|
||||||
public String getStringOrEmpty(String name) {
|
public String getStringOrEmpty(String name) {
|
||||||
String value = properties.getProperty(name);
|
String value = getParam(name);
|
||||||
return value == null ? "" : value.trim();
|
return value == null ? "" : value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Можно добавить методы для удобства */
|
/** Целочисленный параметр с тем же приоритетом: system property -> env -> properties. */
|
||||||
public int getInt(String name, int defaultValue) {
|
public int getInt(String name, int defaultValue) {
|
||||||
String v = properties.getProperty(name);
|
String v = getParam(name);
|
||||||
return v == null ? defaultValue : Integer.parseInt(v);
|
return v == null || v.isBlank() ? defaultValue : Integer.parseInt(v.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Boolean-параметр с тем же приоритетом: system property -> env -> properties. */
|
||||||
public boolean getBoolean(String name, boolean defaultValue) {
|
public boolean getBoolean(String name, boolean defaultValue) {
|
||||||
String v = properties.getProperty(name);
|
String v = getParam(name);
|
||||||
return v == null ? defaultValue : Boolean.parseBoolean(v);
|
return v == null || v.isBlank() ? defaultValue : Boolean.parseBoolean(v.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String toEnvName(String name) {
|
private static String toEnvName(String name) {
|
||||||
|
|||||||
+5
-2
@@ -17,8 +17,11 @@ public final class ShineSignatureConstants {
|
|||||||
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
|
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
|
||||||
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
|
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
|
||||||
|
|
||||||
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
/** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */
|
||||||
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:";
|
||||||
|
|
||||||
|
/** TAG в HeaderBody (genesis). ASCII "SHiNE". */
|
||||||
|
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNE";
|
||||||
|
|
||||||
/** DOMAIN для preimage при расчёте hash32 блока. ASCII "SHiNe". */
|
/** DOMAIN для preimage при расчёте hash32 блока. ASCII "SHiNe". */
|
||||||
public static final String BLOCK_HASH_DOMAIN = "SHiNe";
|
public static final String BLOCK_HASH_DOMAIN = "SHiNe";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# utils.crypto
|
# utils.crypto
|
||||||
|
|
||||||
Пакет отвечает за криптографию — подписи и хэши блоков.
|
Пакет отвечает за криптографию — подписи и хэши блоков.
|
||||||
Используется при создании и проверке целостности `.bch`-блоков.
|
Используется при создании и проверке криптографических сущностей SHiNE; пользовательские blockchain-файлы `.bch` больше не используются.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -17,44 +17,12 @@ import java.util.List;
|
|||||||
public final class DatabaseInitializer {
|
public final class DatabaseInitializer {
|
||||||
|
|
||||||
public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version";
|
public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version";
|
||||||
|
public static final String DB_FORMAT_MARKER_TABLE = "shine_database_metadata";
|
||||||
|
public static final String DB_FORMAT_MARKER = "SHiNE_DB_V2";
|
||||||
|
public static final int DB_FORMAT_VERSION = 2;
|
||||||
public static final int SCHEMA_VERSION_1 = 1;
|
public static final int SCHEMA_VERSION_1 = 1;
|
||||||
public static final int SCHEMA_VERSION_2 = 2;
|
public static final int SCHEMA_VERSION_29 = 29;
|
||||||
public static final int SCHEMA_VERSION_3 = 3;
|
|
||||||
public static final int SCHEMA_VERSION_4 = 4;
|
|
||||||
public static final int SCHEMA_VERSION_5 = 5;
|
|
||||||
public static final int SCHEMA_VERSION_6 = 6;
|
|
||||||
public static final int SCHEMA_VERSION_7 = 7;
|
|
||||||
public static final int SCHEMA_VERSION_8 = 8;
|
|
||||||
public static final int SCHEMA_VERSION_9 = 9;
|
|
||||||
public static final int SCHEMA_VERSION_10 = 10;
|
|
||||||
public static final int SCHEMA_VERSION_11 = 11;
|
|
||||||
public static final int SCHEMA_VERSION_12 = 12;
|
|
||||||
public static final int SCHEMA_VERSION_13 = 13;
|
|
||||||
public static final int SCHEMA_VERSION_14 = 14;
|
|
||||||
public static final int SCHEMA_VERSION_15 = 15;
|
|
||||||
public static final int SCHEMA_VERSION_16 = 16;
|
|
||||||
public static final int SCHEMA_VERSION_17 = 17;
|
|
||||||
public static final int SCHEMA_VERSION_18 = 18;
|
|
||||||
public static final int SCHEMA_VERSION_19 = 19;
|
|
||||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V5_RESOURCE = "postgres/migration_v5.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V6_RESOURCE = "postgres/migration_v6.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V9_RESOURCE = "postgres/migration_v9.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V10_RESOURCE = "postgres/migration_v10.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V11_RESOURCE = "postgres/migration_v11.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V12_RESOURCE = "postgres/migration_v12.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V13_RESOURCE = "postgres/migration_v13.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V14_RESOURCE = "postgres/migration_v14.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V15_RESOURCE = "postgres/migration_v15.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V16_RESOURCE = "postgres/migration_v16.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V17_RESOURCE = "postgres/migration_v17.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
|
|
||||||
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
|
|
||||||
|
|
||||||
private DatabaseInitializer() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -123,82 +91,31 @@ public final class DatabaseInitializer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try (Connection conn = openConnection(jdbcUrl, user, password)) {
|
try (Connection conn = openConnection(jdbcUrl, user, password)) {
|
||||||
if (!postgresSchemaVersionTableExists(conn)) {
|
boolean markerTableExists = postgresTableExists(conn, DB_FORMAT_MARKER_TABLE);
|
||||||
|
boolean schemaVersionTableExists = postgresTableExists(conn, DB_SCHEMA_VERSION_TABLE);
|
||||||
|
|
||||||
|
if (!markerTableExists) {
|
||||||
|
if (schemaVersionTableExists || currentSchemaHasBaseTables(conn)) {
|
||||||
|
throw new SQLException(
|
||||||
|
"Legacy/non-v2 SHiNE PostgreSQL database detected. "
|
||||||
|
+ "Stage 2 requires an empty database initialized as " + DB_FORMAT_MARKER
|
||||||
|
+ "; automatic migration from the old database is intentionally disabled.");
|
||||||
|
}
|
||||||
runSqlScript(conn, POSTGRES_SCHEMA_RESOURCE);
|
runSqlScript(conn, POSTGRES_SCHEMA_RESOURCE);
|
||||||
return;
|
markerTableExists = postgresTableExists(conn, DB_FORMAT_MARKER_TABLE);
|
||||||
|
schemaVersionTableExists = postgresTableExists(conn, DB_SCHEMA_VERSION_TABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!markerTableExists || !schemaVersionTableExists) {
|
||||||
|
throw new SQLException(
|
||||||
|
"Invalid SHiNE PostgreSQL database: required v2 metadata/schema tables are missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
validateDatabaseFormatV2(conn);
|
||||||
int currentVersion = readCurrentSchemaVersion(conn);
|
int currentVersion = readCurrentSchemaVersion(conn);
|
||||||
if (currentVersion < SCHEMA_VERSION_2) {
|
if (currentVersion != SCHEMA_VERSION_29) {
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V2_RESOURCE);
|
throw new SQLException(
|
||||||
currentVersion = SCHEMA_VERSION_2;
|
"Unsupported SHiNE DB v2 schema version: expected 29, found " + currentVersion);
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_3) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_3;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_4) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_4;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_5) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V5_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_5;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_6) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V6_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_6;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_7) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V7_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_7;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_8) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V8_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_8;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_9) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V9_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_9;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_10) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V10_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_10;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_11) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V11_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_11;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_12) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V12_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_12;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_13) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V13_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_13;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_14) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V14_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_14;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_15) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V15_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_15;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_16) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V16_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_16;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_17) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V17_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_17;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_18) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V18_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_18;
|
|
||||||
}
|
|
||||||
if (currentVersion < SCHEMA_VERSION_19) {
|
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE);
|
|
||||||
currentVersion = SCHEMA_VERSION_19;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,23 +127,60 @@ public final class DatabaseInitializer {
|
|||||||
return DriverManager.getConnection(jdbcUrl, user, password == null ? "" : password);
|
return DriverManager.getConnection(jdbcUrl, user, password == null ? "" : password);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean postgresSchemaVersionTableExists(Connection conn) throws SQLException {
|
private static boolean postgresTableExists(Connection conn, String tableName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM information_schema.tables
|
FROM information_schema.tables
|
||||||
WHERE table_schema = current_schema()
|
WHERE table_schema = current_schema()
|
||||||
AND table_name = ?
|
AND table_name = ?
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
)
|
)
|
||||||
""";
|
""";
|
||||||
try (var ps = conn.prepareStatement(sql)) {
|
try (var ps = conn.prepareStatement(sql)) {
|
||||||
ps.setString(1, DB_SCHEMA_VERSION_TABLE);
|
ps.setString(1, tableName);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
return rs.next() && rs.getBoolean(1);
|
return rs.next() && rs.getBoolean(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean currentSchemaHasBaseTables(Connection conn) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
|
||||||
|
return rs.next() && rs.getBoolean(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateDatabaseFormatV2(Connection conn) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT database_format, database_generation
|
||||||
|
FROM shine_database_metadata
|
||||||
|
WHERE id = 1
|
||||||
|
""";
|
||||||
|
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
|
||||||
|
if (!rs.next()) {
|
||||||
|
throw new SQLException(
|
||||||
|
"Invalid SHiNE PostgreSQL database: SHiNE_DB_V2 metadata row is missing.");
|
||||||
|
}
|
||||||
|
String format = rs.getString("database_format");
|
||||||
|
int generation = rs.getInt("database_generation");
|
||||||
|
if (!DB_FORMAT_MARKER.equals(format) || generation != DB_FORMAT_VERSION) {
|
||||||
|
throw new SQLException(
|
||||||
|
"Unsupported SHiNE PostgreSQL database format: expected " + DB_FORMAT_MARKER
|
||||||
|
+ " generation " + DB_FORMAT_VERSION
|
||||||
|
+ ", found format=" + format + ", generation=" + generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static int readCurrentSchemaVersion(Connection conn) throws SQLException {
|
private static int readCurrentSchemaVersion(Connection conn) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT schema_version
|
SELECT schema_version
|
||||||
|
|||||||
@@ -23,6 +23,46 @@ public final class KeyEncodingUtil {
|
|||||||
|
|
||||||
private KeyEncodingUtil() {}
|
private KeyEncodingUtil() {}
|
||||||
|
|
||||||
|
public static String encodeBase58(byte[] input) {
|
||||||
|
if (input == null || input.length == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
byte[] copy = java.util.Arrays.copyOf(input, input.length);
|
||||||
|
int zeros = 0;
|
||||||
|
while (zeros < copy.length && copy[zeros] == 0) {
|
||||||
|
zeros++;
|
||||||
|
}
|
||||||
|
byte[] encoded = new byte[copy.length * 2];
|
||||||
|
int outputStart = encoded.length;
|
||||||
|
int inputStart = zeros;
|
||||||
|
while (inputStart < copy.length) {
|
||||||
|
int remainder = divmod58(copy, inputStart);
|
||||||
|
if (copy[inputStart] == 0) {
|
||||||
|
inputStart++;
|
||||||
|
}
|
||||||
|
encoded[--outputStart] = (byte) BASE58_ALPHABET.charAt(remainder);
|
||||||
|
}
|
||||||
|
while (outputStart < encoded.length && encoded[outputStart] == (byte) BASE58_ALPHABET.charAt(0)) {
|
||||||
|
outputStart++;
|
||||||
|
}
|
||||||
|
while (--zeros >= 0) {
|
||||||
|
encoded[--outputStart] = (byte) BASE58_ALPHABET.charAt(0);
|
||||||
|
}
|
||||||
|
return new String(encoded, outputStart, encoded.length - outputStart, java.nio.charset.StandardCharsets.US_ASCII);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String base58KeyToBase64_32(String rawKey) {
|
||||||
|
if (rawKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String value = rawKey.trim();
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
byte[] decoded = tryDecodeBase58_32(value);
|
||||||
|
return decoded == null ? value : Base64.getEncoder().encodeToString(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
public static String normalizeKeyToBase64_32(String rawKey) {
|
public static String normalizeKeyToBase64_32(String rawKey) {
|
||||||
if (rawKey == null) {
|
if (rawKey == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -102,6 +142,17 @@ public final class KeyEncodingUtil {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int divmod58(byte[] number, int startAt) {
|
||||||
|
int remainder = 0;
|
||||||
|
for (int i = startAt; i < number.length; i++) {
|
||||||
|
int digit256 = number[i] & 0xFF;
|
||||||
|
int temp = remainder * 256 + digit256;
|
||||||
|
number[i] = (byte) (temp / 58);
|
||||||
|
remainder = temp % 58;
|
||||||
|
}
|
||||||
|
return remainder;
|
||||||
|
}
|
||||||
|
|
||||||
private static int divmod256(byte[] number58, int startAt) {
|
private static int divmod256(byte[] number58, int startAt) {
|
||||||
int remainder = 0;
|
int remainder = 0;
|
||||||
for (int i = startAt; i < number58.length; i++) {
|
for (int i = startAt; i < number58.length; i++) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package shine.db;
|
|||||||
* MsgSubType — единое место для ВСЕХ subType сообщений (msg_sub_type).
|
* MsgSubType — единое место для ВСЕХ subType сообщений (msg_sub_type).
|
||||||
*
|
*
|
||||||
* ВАЖНО:
|
* ВАЖНО:
|
||||||
* - Значения должны совпадать с body-классами (TextBody/ReactionBody/ConnectionBody/UserParamBody/HeaderBody).
|
* - Значения должны совпадать с body-классами (TextLineBody/TextReplyBody/TextRatingBody/ReactionBody/ConnectionBody/UserParamBody/HeaderBody).
|
||||||
* - После релиза менять числа нельзя (иначе ломается совместимость данных).
|
* - После релиза менять числа нельзя (иначе ломается совместимость данных).
|
||||||
*/
|
*/
|
||||||
public final class MsgSubType {
|
public final class MsgSubType {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public final class PostgresDbController {
|
|||||||
try {
|
try {
|
||||||
DatabaseInitializer.ensurePostgresSchemaInitialized(jdbcUrl, dbUser, dbPassword);
|
DatabaseInitializer.ensurePostgresSchemaInitialized(jdbcUrl, dbUser, dbPassword);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
throw new RuntimeException("PostgreSQL schema auto-init failed", e);
|
throw new RuntimeException("PostgreSQL database compatibility/init failed: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.delegate = new DriverManagerDbProvider(jdbcUrl, dbUser, dbPassword, connection -> {
|
this.delegate = new DriverManagerDbProvider(jdbcUrl, dbUser, dbPassword, connection -> {
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
|
||||||
|
import java.sql.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Persistent discovery/import queue for individual ANS-104 SHiNE DataItems found through Arweave. */
|
||||||
|
public final class ArweaveBlockImportDAO {
|
||||||
|
public static final String STATUS_PENDING = "PENDING";
|
||||||
|
public static final String STATUS_REJECTED = "REJECTED";
|
||||||
|
|
||||||
|
public record QueueItem(byte[] dataItemId, long blockHeight,
|
||||||
|
byte[] rawDataItem, String status, String lastError,
|
||||||
|
long firstSeenAtMs, long updatedAtMs) {}
|
||||||
|
|
||||||
|
private static volatile ArweaveBlockImportDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private ArweaveBlockImportDAO() {}
|
||||||
|
|
||||||
|
public static ArweaveBlockImportDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (ArweaveBlockImportDAO.class) {
|
||||||
|
if (instance == null) instance = new ArweaveBlockImportDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getLastBlockHeight() throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("SELECT last_block_height FROM arweave_block_sync_state WHERE id=1");
|
||||||
|
ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? rs.getLong(1) : 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastBlockHeight(long height, long nowMs) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
|
||||||
|
VALUES(1,?,?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET last_block_height=EXCLUDED.last_block_height, updated_at_ms=EXCLUDED.updated_at_ms
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setLong(1, Math.max(0L, height));
|
||||||
|
ps.setLong(2, nowMs);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */
|
||||||
|
public boolean enqueueIfMissing(byte[] dataItemId, long blockHeight, byte[] rawDataItem, long nowMs)
|
||||||
|
throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO arweave_block_import_queue(
|
||||||
|
data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||||
|
) VALUES(?,?,?,?, '',?,?)
|
||||||
|
ON CONFLICT(data_item_id) DO NOTHING
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setBytes(1, dataItemId);
|
||||||
|
ps.setLong(2, blockHeight);
|
||||||
|
ps.setBytes(3, rawDataItem);
|
||||||
|
ps.setString(4, STATUS_PENDING);
|
||||||
|
ps.setLong(5, nowMs);
|
||||||
|
ps.setLong(6, nowMs);
|
||||||
|
return ps.executeUpdate() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean exists(byte[] dataItemId) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("SELECT 1 FROM arweave_block_import_queue WHERE data_item_id=? LIMIT 1")) {
|
||||||
|
ps.setBytes(1, dataItemId);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<QueueItem> listPending(int limit) throws SQLException {
|
||||||
|
int safeLimit = Math.max(1, Math.min(limit, 100_000));
|
||||||
|
String sql = """
|
||||||
|
SELECT data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||||
|
FROM arweave_block_import_queue
|
||||||
|
WHERE status=?
|
||||||
|
ORDER BY block_height ASC, first_seen_at_ms ASC
|
||||||
|
LIMIT ?
|
||||||
|
""";
|
||||||
|
List<QueueItem> out = new ArrayList<>();
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, STATUS_PENDING);
|
||||||
|
ps.setInt(2, safeLimit);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
out.add(new QueueItem(
|
||||||
|
rs.getBytes("data_item_id"), rs.getLong("block_height"),
|
||||||
|
rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"),
|
||||||
|
rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPendingError(byte[] id, String error, long nowMs) throws SQLException {
|
||||||
|
updateStatus(id, STATUS_PENDING, error, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reject(byte[] id, String error, long nowMs) throws SQLException {
|
||||||
|
updateStatus(id, STATUS_REJECTED, error, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(byte[] id) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
|
||||||
|
"DELETE FROM arweave_block_import_queue WHERE data_item_id=?")) {
|
||||||
|
ps.setBytes(1, id);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateStatus(byte[] id, String status, String error, long nowMs) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
|
||||||
|
"UPDATE arweave_block_import_queue SET status=?,last_error=?,updated_at_ms=? WHERE data_item_id=?")) {
|
||||||
|
ps.setString(1, status);
|
||||||
|
ps.setString(2, trimError(error));
|
||||||
|
ps.setLong(3, nowMs);
|
||||||
|
ps.setBytes(4, id);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimError(String s) {
|
||||||
|
String v = s == null ? "" : s.replace('\n',' ').replace('\r',' ').trim();
|
||||||
|
return v.length() <= 500 ? v : v.substring(0,500);
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
-481
@@ -1,509 +1,124 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.DatabaseInitializer;
|
|
||||||
import shine.db.DbController;
|
import shine.db.DbController;
|
||||||
|
import java.sql.*;
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.PreparedStatement;
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BlockchainResyncCleanupDAO — подготовительный "жёсткий reset" одной blockchain-цепочки
|
* Atomic cleanup of one physical fork before deterministic replay.
|
||||||
* перед её полной повторной загрузкой от сервера-партнёра.
|
* Physical fork identity is (login,fork_number). Incoming logical state is never rebound/deleted.
|
||||||
*
|
|
||||||
* Что делает этот DAO:
|
|
||||||
* 1) в ОДНОЙ SQL-транзакции сначала аккуратно уменьшает чужие агрегаты,
|
|
||||||
* которые были увеличены блоками удаляемой цепочки:
|
|
||||||
* - likes_count
|
|
||||||
* - replies_count
|
|
||||||
* 2) затем удаляет все локальные записи самой цепочки и её производные состояния.
|
|
||||||
*
|
|
||||||
* Почему это вынесено в отдельный DAO-метод, а не в триггеры DELETE:
|
|
||||||
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
|
|
||||||
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
|
|
||||||
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
|
|
||||||
* - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию:
|
|
||||||
* SQL-транзакция и файловая система не коммитятся атомарно вместе;
|
|
||||||
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
|
|
||||||
* отдельным recovery/resync-слоем после успешного commit.
|
|
||||||
*
|
|
||||||
* Важный смысл текущей реализации:
|
|
||||||
* - мы НЕ трогаем current users слой (`solana_user_pda_current`) и НЕ трогаем DM-таблицы;
|
|
||||||
* - мы очищаем только блокчейн пользователя и derived-state, который строится из неё;
|
|
||||||
* - висячие cross-chain ссылки в чужих blocks допускаются как нормальное поведение системы.
|
|
||||||
*/
|
*/
|
||||||
public final class BlockchainResyncCleanupDAO {
|
public final class BlockchainResyncCleanupDAO {
|
||||||
|
|
||||||
private static final int BLOCKCHAIN_LOGIN_SUFFIX_LEN = 4; // "-001"
|
|
||||||
|
|
||||||
private static volatile BlockchainResyncCleanupDAO instance;
|
private static volatile BlockchainResyncCleanupDAO instance;
|
||||||
|
private final DbController db=DbController.getInstance();
|
||||||
|
private BlockchainResyncCleanupDAO(){}
|
||||||
|
public static BlockchainResyncCleanupDAO getInstance(){ if(instance==null) synchronized(BlockchainResyncCleanupDAO.class){if(instance==null)instance=new BlockchainResyncCleanupDAO();} return instance; }
|
||||||
|
|
||||||
private final DbController db = DbController.getInstance();
|
|
||||||
|
|
||||||
private BlockchainResyncCleanupDAO() {}
|
|
||||||
|
|
||||||
public static BlockchainResyncCleanupDAO getInstance() {
|
|
||||||
if (instance == null) {
|
|
||||||
synchronized (BlockchainResyncCleanupDAO.class) {
|
|
||||||
if (instance == null) instance = new BlockchainResyncCleanupDAO();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Полностью очищает одну blockchain-цепочку и локальные derived-state, собранные из неё.
|
|
||||||
*
|
|
||||||
* Порядок внутри транзакции намеренно такой:
|
|
||||||
* 1. Сначала уменьшаем чужие likes_count для тех целей, где финальное состояние
|
|
||||||
* реакции этой цепочки было LIKE.
|
|
||||||
* 2. Сначала уменьшаем чужие replies_count для reply-блоков этой цепочки.
|
|
||||||
* 3. После этого удаляем локальные derived-state самой цепочки.
|
|
||||||
* 4. В конце удаляем blocks и blockchain_state.
|
|
||||||
*
|
|
||||||
* Это правильно потому, что агрегаты (`message_stats`) должны видеть исходные blocks
|
|
||||||
* и reactions_state на момент пересчёта. Если удалить blocks раньше, мы потеряем
|
|
||||||
* источник правды для корректного уменьшения счётчиков.
|
|
||||||
*
|
|
||||||
* Метод идемпотентен по смыслу:
|
|
||||||
* - если часть данных уже удалена раньше, повторный вызов просто удалит "0 строк";
|
|
||||||
* - если blockchain_state уже отсутствует, login берём из blockchainName.
|
|
||||||
*
|
|
||||||
* Отдельно важно:
|
|
||||||
* - здесь НЕТ удаления .bch/.tmp_bch;
|
|
||||||
* - здесь НЕТ повторной загрузки цепочки;
|
|
||||||
* - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow.
|
|
||||||
*/
|
|
||||||
public CleanupResult cleanupBlockchainForFullResync(String blockchainName) throws SQLException {
|
public CleanupResult cleanupBlockchainForFullResync(String blockchainName) throws SQLException {
|
||||||
if (blockchainName == null || blockchainName.isBlank()) {
|
ChainRef ref=chainRef(blockchainName);
|
||||||
throw new IllegalArgumentException("blockchainName is blank");
|
try(Connection c=db.getConnection()){
|
||||||
}
|
boolean ac=c.getAutoCommit(); c.setAutoCommit(false);
|
||||||
|
try{
|
||||||
try (Connection c = db.getConnection()) {
|
// Targets liked by this actor must survive long enough to recount after actor rows are removed.
|
||||||
boolean oldAutoCommit = c.getAutoCommit();
|
try(Statement st=c.createStatement()){
|
||||||
c.setAutoCommit(false);
|
st.execute("CREATE TEMP TABLE IF NOT EXISTS shine_cleanup_targets(to_login TEXT,to_block_number INTEGER,to_block_hash BYTEA,PRIMARY KEY(to_login,to_block_number,to_block_hash)) ON COMMIT DROP");
|
||||||
try {
|
st.execute("TRUNCATE shine_cleanup_targets");
|
||||||
String login = resolveLoginForCleanup(c, blockchainName);
|
st.execute("CREATE TEMP TABLE IF NOT EXISTS shine_cleanup_channels(owner_bch_name TEXT,root_number INTEGER,root_hash BYTEA,PRIMARY KEY(owner_bch_name,root_number,root_hash)) ON COMMIT DROP");
|
||||||
|
st.execute("TRUNCATE shine_cleanup_channels");
|
||||||
int likesAdjusted = decreaseForeignLikesCount(c, blockchainName);
|
st.execute("CREATE TEMP TABLE IF NOT EXISTS shine_cleanup_users(login TEXT PRIMARY KEY) ON COMMIT DROP");
|
||||||
int repliesAdjusted = decreaseForeignRepliesCount(c, blockchainName);
|
st.execute("TRUNCATE shine_cleanup_users");
|
||||||
|
|
||||||
int deletedMessageStats = deleteMessageStatsForOwnTargets(c, blockchainName);
|
|
||||||
int deletedReactionsState = deleteReactionsStateForActorChain(c, blockchainName);
|
|
||||||
int deletedConnectionsState = deleteConnectionsStateForLogin(c, login);
|
|
||||||
int deletedUsersParams = deleteUsersParamsForLogin(c, login);
|
|
||||||
int deletedChannelNames = deleteChannelNamesForOwnerChain(c, blockchainName);
|
|
||||||
int deletedChat200State = deleteChat200StateForOwnerChain(c, blockchainName);
|
|
||||||
int deletedChat200Members = deleteChat200MembersForOwnerChain(c, blockchainName);
|
|
||||||
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
|
|
||||||
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
|
|
||||||
|
|
||||||
rebuildStatsState(c);
|
|
||||||
|
|
||||||
c.commit();
|
|
||||||
|
|
||||||
return new CleanupResult(
|
|
||||||
login,
|
|
||||||
likesAdjusted,
|
|
||||||
repliesAdjusted,
|
|
||||||
deletedMessageStats,
|
|
||||||
deletedReactionsState,
|
|
||||||
deletedConnectionsState,
|
|
||||||
deletedUsersParams,
|
|
||||||
deletedChannelNames,
|
|
||||||
deletedChat200State,
|
|
||||||
deletedChat200Members,
|
|
||||||
deletedBlocks,
|
|
||||||
deletedBlockchainState
|
|
||||||
);
|
|
||||||
} catch (Exception e) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
if (e instanceof SQLException sqlEx) throw sqlEx;
|
|
||||||
throw new SQLException("Не удалось очистить blockchain для полного resync: " + blockchainName, e);
|
|
||||||
} finally {
|
|
||||||
try { c.setAutoCommit(oldAutoCommit); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
|
|
||||||
String sql = """
|
|
||||||
SELECT login
|
|
||||||
FROM blockchain_state
|
|
||||||
WHERE blockchain_name = ?
|
|
||||||
LIMIT 1
|
|
||||||
""";
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, blockchainName);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
|
||||||
if (rs.next()) {
|
|
||||||
String login = rs.getString("login");
|
|
||||||
if (login != null && !login.isBlank()) {
|
|
||||||
return login;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
try(PreparedStatement ps=c.prepareStatement("""
|
||||||
}
|
INSERT INTO shine_cleanup_targets(to_login,to_block_number,to_block_hash)
|
||||||
|
SELECT DISTINCT to_login,to_block_number,to_block_hash FROM reactions_state
|
||||||
|
WHERE LOWER(from_login)=LOWER(?) AND reaction_type=1 AND last_sub_type=1
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""")){ ps.setString(1,ref.login()); ps.executeUpdate(); }
|
||||||
|
|
||||||
String loginFromName = loginFromBlockchainName(blockchainName);
|
try(PreparedStatement ps=c.prepareStatement("""
|
||||||
if (loginFromName == null || loginFromName.isBlank()) {
|
INSERT INTO shine_cleanup_users(login)
|
||||||
throw new IllegalArgumentException("Cannot derive login from blockchainName: " + blockchainName);
|
SELECT DISTINCT to_login FROM connections_state
|
||||||
}
|
WHERE LOWER(login)=LOWER(?) AND to_login IS NOT NULL AND btrim(to_login)<>''
|
||||||
return loginFromName;
|
ON CONFLICT DO NOTHING
|
||||||
}
|
""")){ps.setString(1,ref.login());ps.executeUpdate();}
|
||||||
|
|
||||||
/**
|
try(PreparedStatement ps=c.prepareStatement("""
|
||||||
* DAO остаётся в модуле БД и не тянет зависимость на blockchain-utils модуль.
|
INSERT INTO shine_cleanup_channels(owner_bch_name,root_number,root_hash)
|
||||||
* Поэтому здесь локально повторяем минимальное правило имени chain:
|
SELECT DISTINCT cn.owner_bch_name,cn.channel_root_block_number,cn.channel_root_block_hash
|
||||||
* login + "-NNN".
|
FROM connections_state cs JOIN channel_names_state cn
|
||||||
*/
|
ON LOWER(cn.owner_login)=LOWER(cs.to_login) AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
||||||
private String loginFromBlockchainName(String blockchainName) {
|
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30
|
||||||
if (blockchainName == null) return null;
|
ON CONFLICT DO NOTHING
|
||||||
|
""")){ps.setString(1,ref.login());ps.executeUpdate();}
|
||||||
|
|
||||||
String s = blockchainName.trim();
|
// Followers of channels owned by this login must be refreshed after those channel projections are removed.
|
||||||
if (s.length() <= BLOCKCHAIN_LOGIN_SUFFIX_LEN) return null;
|
try(PreparedStatement ps=c.prepareStatement("""
|
||||||
|
INSERT INTO shine_cleanup_users(login)
|
||||||
|
SELECT DISTINCT cs.login
|
||||||
|
FROM connections_state cs
|
||||||
|
JOIN channel_names_state cn
|
||||||
|
ON LOWER(cn.owner_login)=LOWER(?)
|
||||||
|
AND LOWER(cs.to_login)=LOWER(cn.owner_login)
|
||||||
|
AND cs.to_block_number=cn.channel_root_block_number
|
||||||
|
AND cs.to_block_hash=cn.channel_root_block_hash
|
||||||
|
WHERE cs.rel_type=30
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""")){ps.setString(1,ref.login());ps.executeUpdate();}
|
||||||
|
|
||||||
int dashPos = s.length() - BLOCKCHAIN_LOGIN_SUFFIX_LEN;
|
int deletedReactions=delete(c,"DELETE FROM reactions_state WHERE LOWER(from_login)=LOWER(?)",ref.login());
|
||||||
if (s.charAt(dashPos) != '-') return null;
|
recountAffectedLikes(c);
|
||||||
|
int deletedConnections=delete(c,"DELETE FROM connections_state WHERE LOWER(login)=LOWER(?)",ref.login());
|
||||||
|
refreshAffectedChannelStats(c);
|
||||||
|
int deletedUsersParams=delete(c,"DELETE FROM users_params WHERE LOWER(login)=LOWER(?)",ref.login());
|
||||||
|
try(PreparedStatement ps=c.prepareStatement("SELECT shine_refresh_user_profile(?)")){ps.setString(1,ref.login());ps.execute();}
|
||||||
|
try(PreparedStatement ps=c.prepareStatement("SELECT shine_refresh_related_user_stats(?)")){ps.setString(1,ref.login());ps.execute();}
|
||||||
|
// Channel/chat projections are owned by login; replay recreates them. Incoming social stats are intentionally untouched.
|
||||||
|
delete(c,"DELETE FROM channel_stats_state WHERE LOWER(owner_login)=LOWER(?)",ref.login());
|
||||||
|
int deletedChannelNames=delete(c,"DELETE FROM channel_names_state WHERE LOWER(owner_login)=LOWER(?)",ref.login());
|
||||||
|
refreshAffectedUserStats(c);
|
||||||
|
int deletedChat200=delete(c,"DELETE FROM chat200_state WHERE LOWER(owner_login)=LOWER(?)",ref.login());
|
||||||
|
int deletedChatMembers=delete(c,"DELETE FROM chat200_members_state WHERE owner_bch_name LIKE ?",ref.login()+"-%");
|
||||||
|
try(PreparedStatement ps=c.prepareStatement("SELECT shine_refresh_user_stats(?)")){ps.setString(1,ref.login());ps.execute();}
|
||||||
|
|
||||||
for (int i = dashPos + 1; i < s.length(); i++) {
|
int deletedBlocks;
|
||||||
char ch = s.charAt(i);
|
try(PreparedStatement ps=c.prepareStatement("DELETE FROM blocks_store WHERE LOWER(login)=LOWER(?) AND fork_number=?")){
|
||||||
if (ch < '0' || ch > '9') return null;
|
ps.setString(1,ref.login()); ps.setInt(2,ref.fork()); deletedBlocks=ps.executeUpdate();
|
||||||
}
|
}
|
||||||
return s.substring(0, dashPos);
|
int deletedState;
|
||||||
}
|
try(PreparedStatement ps=c.prepareStatement("DELETE FROM blockchain_state_store WHERE LOWER(login)=LOWER(?) AND fork_number=?")){
|
||||||
|
ps.setString(1,ref.login()); ps.setInt(2,ref.fork()); deletedState=ps.executeUpdate();
|
||||||
/**
|
}
|
||||||
* Уменьшаем likes_count только для ЧУЖИХ целей.
|
c.commit();
|
||||||
*
|
return new CleanupResult(ref.login(),deletedReactions,deletedConnections,deletedUsersParams,deletedChannelNames,deletedChat200,deletedChatMembers,deletedBlocks,deletedState);
|
||||||
* Логика:
|
}catch(Exception e){ try{c.rollback();}catch(Exception ignored){} if(e instanceof SQLException x)throw x; throw new SQLException("Stage2 fork cleanup failed: "+blockchainName,e); }
|
||||||
* - если у удаляемой цепочки финальное состояние реакции на цель = LIKE,
|
finally{try{c.setAutoCommit(ac);}catch(Exception ignored){}}
|
||||||
* значит при полном удалении цепочки этот активный лайк исчезает;
|
|
||||||
* - значит у message_stats этой чужой цели нужно сделать -1;
|
|
||||||
* - для целей внутри этой же chain этого делать не нужно, потому что сами цели
|
|
||||||
* тоже будут удалены вместе с цепочкой.
|
|
||||||
*/
|
|
||||||
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
|
|
||||||
String sql = """
|
|
||||||
UPDATE message_stats
|
|
||||||
SET likes_count = GREATEST(
|
|
||||||
0,
|
|
||||||
likes_count - COALESCE((
|
|
||||||
SELECT COUNT(*)::int
|
|
||||||
FROM reactions_state rs
|
|
||||||
WHERE rs.from_bch_name = ?
|
|
||||||
AND rs.reaction_type = ?
|
|
||||||
AND rs.last_sub_type = ?
|
|
||||||
AND rs.to_login = message_stats.to_login
|
|
||||||
AND rs.to_bch_name = message_stats.to_bch_name
|
|
||||||
AND rs.to_block_number = message_stats.to_block_number
|
|
||||||
AND rs.to_block_hash = message_stats.to_block_hash
|
|
||||||
AND rs.to_bch_name <> ?
|
|
||||||
), 0)
|
|
||||||
)
|
|
||||||
WHERE EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM reactions_state rs
|
|
||||||
WHERE rs.from_bch_name = ?
|
|
||||||
AND rs.reaction_type = ?
|
|
||||||
AND rs.last_sub_type = ?
|
|
||||||
AND rs.to_login = message_stats.to_login
|
|
||||||
AND rs.to_bch_name = message_stats.to_bch_name
|
|
||||||
AND rs.to_block_number = message_stats.to_block_number
|
|
||||||
AND rs.to_block_hash = message_stats.to_block_hash
|
|
||||||
AND rs.to_bch_name <> ?
|
|
||||||
)
|
|
||||||
""";
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
int i = 1;
|
|
||||||
ps.setString(i++, blockchainName);
|
|
||||||
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
|
||||||
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
|
||||||
ps.setString(i++, blockchainName);
|
|
||||||
ps.setString(i++, blockchainName);
|
|
||||||
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
|
||||||
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
|
|
||||||
ps.setString(i++, blockchainName);
|
|
||||||
return ps.executeUpdate();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private void recountAffectedLikes(Connection c)throws SQLException{
|
||||||
* Уменьшаем replies_count только для ЧУЖИХ целей.
|
try(PreparedStatement ps=c.prepareStatement("""
|
||||||
*
|
|
||||||
* Если reply этой цепочки ссылался на сообщение из другой цепочки,
|
|
||||||
* значит после удаления blocks этой цепочки чужой replies_count должен уменьшиться.
|
|
||||||
* Reply на собственные сообщения здесь игнорируем: целевая цепочка тоже будет удалена.
|
|
||||||
*/
|
|
||||||
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
|
|
||||||
String sql = """
|
|
||||||
UPDATE message_stats
|
|
||||||
SET replies_count = GREATEST(
|
|
||||||
0,
|
|
||||||
replies_count - COALESCE((
|
|
||||||
SELECT COUNT(*)::int
|
|
||||||
FROM blocks b
|
|
||||||
WHERE b.bch_name = ?
|
|
||||||
AND b.msg_type = 1
|
|
||||||
AND b.msg_sub_type = ?
|
|
||||||
AND b.to_login = message_stats.to_login
|
|
||||||
AND b.to_bch_name = message_stats.to_bch_name
|
|
||||||
AND b.to_block_number = message_stats.to_block_number
|
|
||||||
AND b.to_block_hash = message_stats.to_block_hash
|
|
||||||
AND b.to_bch_name <> ?
|
|
||||||
), 0)
|
|
||||||
)
|
|
||||||
WHERE EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM blocks b
|
|
||||||
WHERE b.bch_name = ?
|
|
||||||
AND b.msg_type = 1
|
|
||||||
AND b.msg_sub_type = ?
|
|
||||||
AND b.to_login = message_stats.to_login
|
|
||||||
AND b.to_bch_name = message_stats.to_bch_name
|
|
||||||
AND b.to_block_number = message_stats.to_block_number
|
|
||||||
AND b.to_block_hash = message_stats.to_block_hash
|
|
||||||
AND b.to_bch_name <> ?
|
|
||||||
)
|
|
||||||
""";
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
int i = 1;
|
|
||||||
ps.setString(i++, blockchainName);
|
|
||||||
ps.setInt(i++, DatabaseInitializer.TEXT_REPLY);
|
|
||||||
ps.setString(i++, blockchainName);
|
|
||||||
ps.setString(i++, blockchainName);
|
|
||||||
ps.setInt(i++, DatabaseInitializer.TEXT_REPLY);
|
|
||||||
ps.setString(i++, blockchainName);
|
|
||||||
return ps.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Статистика сообщений самой удаляемой цепочки после reset не нужна,
|
|
||||||
* потому что её цели исчезают вместе с chain source data.
|
|
||||||
*/
|
|
||||||
private int deleteMessageStatsForOwnTargets(Connection c, String blockchainName) throws SQLException {
|
|
||||||
return executeDelete(c, """
|
|
||||||
DELETE FROM message_stats
|
|
||||||
WHERE to_bch_name = ?
|
|
||||||
""", blockchainName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* reactions_state хранит финальное состояние реакций АКТОРА.
|
|
||||||
* После удаления всей цепочки актор этой цепочки исчезает, поэтому
|
|
||||||
* достаточно удалить все строки по from_bch_name.
|
|
||||||
*/
|
|
||||||
private int deleteReactionsStateForActorChain(Connection c, String blockchainName) throws SQLException {
|
|
||||||
return executeDelete(c, """
|
|
||||||
DELETE FROM reactions_state
|
|
||||||
WHERE from_bch_name = ?
|
|
||||||
""", blockchainName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* connections_state — текущее состояние связей, выставленных этим login.
|
|
||||||
* Чистим по владельцу состояния.
|
|
||||||
*/
|
|
||||||
private int deleteConnectionsStateForLogin(Connection c, String login) throws SQLException {
|
|
||||||
return executeDelete(c, """
|
|
||||||
DELETE FROM connections_state
|
|
||||||
WHERE LOWER(login) = LOWER(?)
|
|
||||||
""", login);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* users_params — актуальные параметры, собранные из блоков пользователя.
|
|
||||||
*/
|
|
||||||
private int deleteUsersParamsForLogin(Connection c, String login) throws SQLException {
|
|
||||||
return executeDelete(c, """
|
|
||||||
DELETE FROM users_params
|
|
||||||
WHERE LOWER(login) = LOWER(?)
|
|
||||||
""", login);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Каналы принадлежат owner_bch_name.
|
|
||||||
*/
|
|
||||||
private int deleteChannelNamesForOwnerChain(Connection c, String blockchainName) throws SQLException {
|
|
||||||
return executeDelete(c, """
|
|
||||||
DELETE FROM channel_names_state
|
|
||||||
WHERE owner_bch_name = ?
|
|
||||||
""", blockchainName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int deleteChat200StateForOwnerChain(Connection c, String blockchainName) throws SQLException {
|
|
||||||
return executeDelete(c, """
|
|
||||||
DELETE FROM chat200_state
|
|
||||||
WHERE owner_bch_name = ?
|
|
||||||
""", blockchainName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int deleteChat200MembersForOwnerChain(Connection c, String blockchainName) throws SQLException {
|
|
||||||
return executeDelete(c, """
|
|
||||||
DELETE FROM chat200_members_state
|
|
||||||
WHERE owner_bch_name = ?
|
|
||||||
""", blockchainName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* blocks удаляем в конце, потому что до этого шага они нужны как источник правды
|
|
||||||
* для уменьшения replies_count.
|
|
||||||
*/
|
|
||||||
private int deleteBlocksForChain(Connection c, String blockchainName) throws SQLException {
|
|
||||||
return executeDelete(c, """
|
|
||||||
DELETE FROM blocks
|
|
||||||
WHERE bch_name = ?
|
|
||||||
""", blockchainName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* blockchain_state удаляем после blocks, чтобы не нарушать FK-связь blocks -> blockchain_state.
|
|
||||||
*/
|
|
||||||
private int deleteBlockchainStateForChain(Connection c, String blockchainName) throws SQLException {
|
|
||||||
return executeDelete(c, """
|
|
||||||
DELETE FROM blockchain_state
|
|
||||||
WHERE blockchain_name = ?
|
|
||||||
""", blockchainName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void rebuildStatsState(Connection c) throws SQLException {
|
|
||||||
try (PreparedStatement truncate = c.prepareStatement("""
|
|
||||||
TRUNCATE TABLE user_stats_state, channel_stats_state
|
|
||||||
""")) {
|
|
||||||
truncate.executeUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
|
||||||
INSERT INTO user_stats_state (
|
|
||||||
login,
|
|
||||||
owned_public_channels_count,
|
|
||||||
following_users_count,
|
|
||||||
following_channels_count,
|
|
||||||
close_friends_count,
|
|
||||||
updated_at_ms
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
u.login,
|
|
||||||
COALESCE(own.owned_public_channels_count, 0),
|
|
||||||
COALESCE(fu.following_users_count, 0),
|
|
||||||
COALESCE(fc.following_channels_count, 0),
|
|
||||||
COALESCE(cf.close_friends_count, 0),
|
|
||||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
|
||||||
FROM solana_user_pda_current u
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
|
|
||||||
FROM channel_names_state
|
|
||||||
WHERE channel_type_code = 1
|
|
||||||
GROUP BY owner_login
|
|
||||||
) own ON LOWER(own.owner_login) = LOWER(u.login)
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT login, COUNT(*)::INTEGER AS following_users_count
|
|
||||||
FROM connections_state
|
|
||||||
WHERE rel_type = 30
|
|
||||||
AND to_block_number = 0
|
|
||||||
GROUP BY login
|
|
||||||
) fu ON LOWER(fu.login) = LOWER(u.login)
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
|
|
||||||
FROM connections_state cs
|
|
||||||
JOIN channel_names_state cn
|
|
||||||
ON cn.owner_bch_name = cs.to_bch_name
|
|
||||||
AND cn.channel_root_block_number = cs.to_block_number
|
|
||||||
AND cn.channel_root_block_hash = cs.to_block_hash
|
|
||||||
WHERE cs.rel_type = 30
|
|
||||||
AND cn.channel_type_code = 1
|
|
||||||
GROUP BY cs.login
|
|
||||||
) fc ON LOWER(fc.login) = LOWER(u.login)
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT login, COUNT(*)::INTEGER AS close_friends_count
|
|
||||||
FROM connections_state
|
|
||||||
WHERE rel_type = 10
|
|
||||||
GROUP BY login
|
|
||||||
) cf ON LOWER(cf.login) = LOWER(u.login)
|
|
||||||
""")) {
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(login) FROM solana_user_pda_current")) {
|
|
||||||
ps.execute();
|
|
||||||
}
|
|
||||||
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_stats(login) FROM solana_user_pda_current")) {
|
|
||||||
ps.execute();
|
|
||||||
}
|
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
|
||||||
UPDATE message_stats ms SET
|
UPDATE message_stats ms SET
|
||||||
primary_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1 AND rs.to_login=ms.to_login AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash),
|
||||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
primary_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1 AND rs.to_login=ms.to_login AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash AND shine_is_primary(rs.from_login)),
|
||||||
AND shine_is_primary(rs.from_login)),
|
shining_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1 AND rs.to_login=ms.to_login AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login))
|
||||||
shining_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
WHERE EXISTS(SELECT 1 FROM shine_cleanup_targets t WHERE t.to_login=ms.to_login AND t.to_block_number=ms.to_block_number AND t.to_block_hash=ms.to_block_hash)
|
||||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
""")){ps.executeUpdate();}
|
||||||
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login))
|
}
|
||||||
""")) {
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
private void refreshAffectedChannelStats(Connection c)throws SQLException{
|
||||||
INSERT INTO channel_stats_state (
|
try(PreparedStatement q=c.prepareStatement("SELECT owner_bch_name,root_number,root_hash FROM shine_cleanup_channels"); ResultSet rs=q.executeQuery(); PreparedStatement p=c.prepareStatement("SELECT shine_refresh_channel_follow_stats(?,?,?)")){
|
||||||
owner_bch_name,
|
while(rs.next()){p.setString(1,rs.getString(1));p.setInt(2,rs.getInt(2));p.setBytes(3,rs.getBytes(3));p.execute();}
|
||||||
channel_root_block_number,
|
|
||||||
channel_root_block_hash,
|
|
||||||
owner_login,
|
|
||||||
channel_type_code,
|
|
||||||
subscribers_count,
|
|
||||||
updated_at_ms
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
cn.owner_bch_name,
|
|
||||||
cn.channel_root_block_number,
|
|
||||||
cn.channel_root_block_hash,
|
|
||||||
cn.owner_login,
|
|
||||||
cn.channel_type_code,
|
|
||||||
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
|
|
||||||
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
|
||||||
FROM channel_names_state cn
|
|
||||||
LEFT JOIN connections_state cs
|
|
||||||
ON cs.rel_type = 30
|
|
||||||
AND cs.to_bch_name = cn.owner_bch_name
|
|
||||||
AND cs.to_block_number = cn.channel_root_block_number
|
|
||||||
AND cs.to_block_hash = cn.channel_root_block_hash
|
|
||||||
WHERE cn.channel_type_code = 1
|
|
||||||
GROUP BY
|
|
||||||
cn.owner_bch_name,
|
|
||||||
cn.channel_root_block_number,
|
|
||||||
cn.channel_root_block_hash,
|
|
||||||
cn.owner_login,
|
|
||||||
cn.channel_type_code
|
|
||||||
""")) {
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int executeDelete(Connection c, String sql, String value) throws SQLException {
|
private void refreshAffectedUserStats(Connection c)throws SQLException{
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try(PreparedStatement q=c.prepareStatement("SELECT login FROM shine_cleanup_users"); ResultSet rs=q.executeQuery(); PreparedStatement p=c.prepareStatement("SELECT shine_refresh_user_stats(?)")){
|
||||||
ps.setString(1, value);
|
while(rs.next()){p.setString(1,rs.getString(1));p.execute();}
|
||||||
return ps.executeUpdate();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private int delete(Connection c,String sql,String v)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,v);return ps.executeUpdate();}}
|
||||||
* Технический результат cleanup-операции.
|
private record ChainRef(String login,int fork){}
|
||||||
* Нужен для будущего логирования и ручной диагностики resync-flow.
|
private static ChainRef chainRef(String n){ if(n==null)throw new IllegalArgumentException("blockchainName is null"); String s=n.trim();int d=s.lastIndexOf('-');if(d<=0||s.length()-d-1!=3)throw new IllegalArgumentException("Bad blockchainName: "+n);try{int f=Integer.parseInt(s.substring(d+1));if(f<1||f>999)throw new IllegalArgumentException("fork out of range");return new ChainRef(s.substring(0,d),f);}catch(NumberFormatException e){throw new IllegalArgumentException("Bad blockchainName: "+n,e);} }
|
||||||
*/
|
|
||||||
public record CleanupResult(
|
public record CleanupResult(String login,int deletedReactionsStateRows,int deletedConnectionsStateRows,int deletedUsersParamsRows,int deletedChannelNamesRows,int deletedChat200StateRows,int deletedChat200MembersRows,int deletedBlocksRows,int deletedBlockchainStateRows){}
|
||||||
String login,
|
|
||||||
int likesAdjustedRows,
|
|
||||||
int repliesAdjustedRows,
|
|
||||||
int deletedMessageStatsRows,
|
|
||||||
int deletedReactionsStateRows,
|
|
||||||
int deletedConnectionsStateRows,
|
|
||||||
int deletedUsersParamsRows,
|
|
||||||
int deletedChannelNamesRows,
|
|
||||||
int deletedChat200StateRows,
|
|
||||||
int deletedChat200MembersRows,
|
|
||||||
int deletedBlocksRows,
|
|
||||||
int deletedBlockchainStateRows
|
|
||||||
) {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ public final class BlockchainStateDAO {
|
|||||||
public BlockchainStateEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
public BlockchainStateEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
blockchain_name,
|
|
||||||
login,
|
login,
|
||||||
|
fork_number,
|
||||||
blockchain_key,
|
blockchain_key,
|
||||||
size_limit,
|
size_limit,
|
||||||
file_size_bytes,
|
file_size_bytes,
|
||||||
@@ -43,11 +43,13 @@ public final class BlockchainStateDAO {
|
|||||||
last_block_hash,
|
last_block_hash,
|
||||||
updated_at_ms
|
updated_at_ms
|
||||||
FROM blockchain_state
|
FROM blockchain_state
|
||||||
WHERE blockchain_name = ?
|
WHERE login = ? AND fork_number = ?
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, blockchainName);
|
ChainRef r=chainRef(blockchainName);
|
||||||
|
ps.setString(1, r.login());
|
||||||
|
ps.setInt(2, r.fork());
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (!rs.next()) return null;
|
if (!rs.next()) return null;
|
||||||
return mapRow(rs);
|
return mapRow(rs);
|
||||||
@@ -66,8 +68,8 @@ public final class BlockchainStateDAO {
|
|||||||
public List<BlockchainStateEntry> listAll(Connection c) throws SQLException {
|
public List<BlockchainStateEntry> listAll(Connection c) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
blockchain_name,
|
|
||||||
login,
|
login,
|
||||||
|
fork_number,
|
||||||
blockchain_key,
|
blockchain_key,
|
||||||
size_limit,
|
size_limit,
|
||||||
file_size_bytes,
|
file_size_bytes,
|
||||||
@@ -75,7 +77,7 @@ public final class BlockchainStateDAO {
|
|||||||
last_block_hash,
|
last_block_hash,
|
||||||
updated_at_ms
|
updated_at_ms
|
||||||
FROM blockchain_state
|
FROM blockchain_state
|
||||||
ORDER BY LOWER(blockchain_name)
|
ORDER BY LOWER(login), fork_number
|
||||||
""";
|
""";
|
||||||
|
|
||||||
List<BlockchainStateEntry> result = new ArrayList<>();
|
List<BlockchainStateEntry> result = new ArrayList<>();
|
||||||
@@ -98,9 +100,9 @@ public final class BlockchainStateDAO {
|
|||||||
/** UPSERT с внешним соединением. Соединение НЕ закрывает. */
|
/** UPSERT с внешним соединением. Соединение НЕ закрывает. */
|
||||||
public void upsert(Connection c, BlockchainStateEntry e) throws SQLException {
|
public void upsert(Connection c, BlockchainStateEntry e) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO blockchain_state (
|
INSERT INTO blockchain_state_store (
|
||||||
blockchain_name,
|
|
||||||
login,
|
login,
|
||||||
|
fork_number,
|
||||||
blockchain_key,
|
blockchain_key,
|
||||||
size_limit,
|
size_limit,
|
||||||
file_size_bytes,
|
file_size_bytes,
|
||||||
@@ -108,9 +110,8 @@ public final class BlockchainStateDAO {
|
|||||||
last_block_hash,
|
last_block_hash,
|
||||||
updated_at_ms
|
updated_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(blockchain_name)
|
ON CONFLICT(login, fork_number)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
login = excluded.login,
|
|
||||||
blockchain_key = excluded.blockchain_key,
|
blockchain_key = excluded.blockchain_key,
|
||||||
size_limit = excluded.size_limit,
|
size_limit = excluded.size_limit,
|
||||||
file_size_bytes = excluded.file_size_bytes,
|
file_size_bytes = excluded.file_size_bytes,
|
||||||
@@ -122,8 +123,8 @@ public final class BlockchainStateDAO {
|
|||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
int i = 1;
|
int i = 1;
|
||||||
|
|
||||||
ps.setString(i++, e.getBlockchainName());
|
|
||||||
ps.setString(i++, nn(e.getLogin()));
|
ps.setString(i++, nn(e.getLogin()));
|
||||||
|
ps.setInt(i++, e.getForkNumber());
|
||||||
ps.setString(i++, nn(e.getBlockchainKey()));
|
ps.setString(i++, nn(e.getBlockchainKey()));
|
||||||
|
|
||||||
ps.setLong(i++, e.getSizeLimit());
|
ps.setLong(i++, e.getSizeLimit());
|
||||||
@@ -148,9 +149,9 @@ public final class BlockchainStateDAO {
|
|||||||
*/
|
*/
|
||||||
public boolean insertIfMissing(Connection c, BlockchainStateEntry e) throws SQLException {
|
public boolean insertIfMissing(Connection c, BlockchainStateEntry e) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO blockchain_state (
|
INSERT INTO blockchain_state_store (
|
||||||
blockchain_name,
|
|
||||||
login,
|
login,
|
||||||
|
fork_number,
|
||||||
blockchain_key,
|
blockchain_key,
|
||||||
size_limit,
|
size_limit,
|
||||||
file_size_bytes,
|
file_size_bytes,
|
||||||
@@ -158,14 +159,14 @@ public final class BlockchainStateDAO {
|
|||||||
last_block_hash,
|
last_block_hash,
|
||||||
updated_at_ms
|
updated_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(blockchain_name) DO NOTHING
|
ON CONFLICT(login, fork_number) DO NOTHING
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
int i = 1;
|
int i = 1;
|
||||||
|
|
||||||
ps.setString(i++, e.getBlockchainName());
|
|
||||||
ps.setString(i++, nn(e.getLogin()));
|
ps.setString(i++, nn(e.getLogin()));
|
||||||
|
ps.setInt(i++, e.getForkNumber());
|
||||||
ps.setString(i++, nn(e.getBlockchainKey()));
|
ps.setString(i++, nn(e.getBlockchainKey()));
|
||||||
|
|
||||||
ps.setLong(i++, e.getSizeLimit());
|
ps.setLong(i++, e.getSizeLimit());
|
||||||
@@ -191,20 +192,22 @@ public final class BlockchainStateDAO {
|
|||||||
*/
|
*/
|
||||||
public boolean tryIncreaseFileSizeWithinLimit(Connection c, String blockchainName, long deltaBytes, long nowMs) throws SQLException {
|
public boolean tryIncreaseFileSizeWithinLimit(Connection c, String blockchainName, long deltaBytes, long nowMs) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
UPDATE blockchain_state
|
UPDATE blockchain_state_store
|
||||||
SET
|
SET
|
||||||
file_size_bytes = file_size_bytes + ?,
|
file_size_bytes = file_size_bytes + ?,
|
||||||
updated_at_ms = ?
|
updated_at_ms = ?
|
||||||
WHERE
|
WHERE
|
||||||
blockchain_name = ?
|
login = ? AND fork_number = ?
|
||||||
AND (file_size_bytes + ?) <= size_limit
|
AND (file_size_bytes + ?) <= size_limit
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setLong(1, deltaBytes);
|
ps.setLong(1, deltaBytes);
|
||||||
ps.setLong(2, nowMs);
|
ps.setLong(2, nowMs);
|
||||||
ps.setString(3, blockchainName);
|
ChainRef r=chainRef(blockchainName);
|
||||||
ps.setLong(4, deltaBytes);
|
ps.setString(3, r.login());
|
||||||
|
ps.setInt(4, r.fork());
|
||||||
|
ps.setLong(5, deltaBytes);
|
||||||
return ps.executeUpdate() > 0;
|
return ps.executeUpdate() > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,8 +215,8 @@ public final class BlockchainStateDAO {
|
|||||||
private BlockchainStateEntry mapRow(ResultSet rs) throws SQLException {
|
private BlockchainStateEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
BlockchainStateEntry e = new BlockchainStateEntry();
|
BlockchainStateEntry e = new BlockchainStateEntry();
|
||||||
|
|
||||||
e.setBlockchainName(rs.getString("blockchain_name"));
|
|
||||||
e.setLogin(rs.getString("login"));
|
e.setLogin(rs.getString("login"));
|
||||||
|
e.setForkNumber(rs.getInt("fork_number"));
|
||||||
e.setBlockchainKey(rs.getString("blockchain_key"));
|
e.setBlockchainKey(rs.getString("blockchain_key"));
|
||||||
|
|
||||||
e.setSizeLimit(rs.getLong("size_limit"));
|
e.setSizeLimit(rs.getLong("size_limit"));
|
||||||
@@ -227,6 +230,15 @@ public final class BlockchainStateDAO {
|
|||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record ChainRef(String login,int fork) {}
|
||||||
|
private static ChainRef chainRef(String name) {
|
||||||
|
if(name==null) throw new IllegalArgumentException("blockchainName is null");
|
||||||
|
String s=name.trim(); int d=s.lastIndexOf('-');
|
||||||
|
if(d<=0 || s.length()-d-1 != 3) throw new IllegalArgumentException("Bad blockchainName: "+name);
|
||||||
|
try { int f=Integer.parseInt(s.substring(d+1)); if(f<1||f>999) throw new IllegalArgumentException("fork out of range"); return new ChainRef(s.substring(0,d),f); }
|
||||||
|
catch(NumberFormatException e){ throw new IllegalArgumentException("Bad blockchainName: "+name,e); }
|
||||||
|
}
|
||||||
|
|
||||||
private static void setBytesNullable(PreparedStatement ps, int index, byte[] b) throws SQLException {
|
private static void setBytesNullable(PreparedStatement ps, int index, byte[] b) throws SQLException {
|
||||||
if (b != null) ps.setBytes(index, b);
|
if (b != null) ps.setBytes(index, b);
|
||||||
else ps.setNull(index, Types.BINARY);
|
else ps.setNull(index, Types.BINARY);
|
||||||
|
|||||||
@@ -9,286 +9,160 @@ import java.sql.*;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/** DAO for SHiNE Frame v1 blocks. block_bytes contains the complete ANS-104 DataItem. */
|
||||||
* DAO для таблицы blocks (новый формат).
|
|
||||||
*
|
|
||||||
* Правило:
|
|
||||||
* - методы с Connection НЕ закрывают соединение
|
|
||||||
* - методы без Connection сами открывают и закрывают соединение
|
|
||||||
*
|
|
||||||
* Ключ:
|
|
||||||
* - (bch_name, block_number) — уникальная пара в рамках общей БД сервера.
|
|
||||||
*/
|
|
||||||
public final class BlocksDAO {
|
public final class BlocksDAO {
|
||||||
|
|
||||||
private static volatile BlocksDAO instance;
|
private static volatile BlocksDAO instance;
|
||||||
private final DbController db = DbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class);
|
private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class);
|
||||||
|
|
||||||
private BlocksDAO() { }
|
private BlocksDAO() { }
|
||||||
|
|
||||||
public static BlocksDAO getInstance() {
|
public static BlocksDAO getInstance() {
|
||||||
if (instance == null) {
|
if (instance == null) synchronized (BlocksDAO.class) { if (instance == null) instance = new BlocksDAO(); }
|
||||||
synchronized (BlocksDAO.class) {
|
|
||||||
if (instance == null) instance = new BlocksDAO();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- INSERT --------------------
|
|
||||||
|
|
||||||
/** Вставка с внешним соединением. Соединение НЕ закрывает. */
|
|
||||||
public void insert(Connection c, BlockEntry e) throws SQLException {
|
public void insert(Connection c, BlockEntry e) throws SQLException {
|
||||||
log.info("DBG BlockEntry: type={} sub={} lineCode={} prevLineNumber={} thisLineNumber={} prevLineHashLen={}",
|
|
||||||
e.getMsgType(), e.getMsgSubType(),
|
|
||||||
e.getLineCode(), e.getPrevLineNumber(), e.getThisLineNumber(),
|
|
||||||
e.getPrevLineHash() == null ? null : e.getPrevLineHash().length
|
|
||||||
);
|
|
||||||
|
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO blocks (
|
INSERT INTO blocks_store (
|
||||||
login,
|
login,fork_number,block_number,msg_type,msg_sub_type,block_bytes,
|
||||||
bch_name,
|
to_login,to_fork_number,to_block_number,to_block_hash,
|
||||||
block_number,
|
block_hash,block_signature,data_item_id,
|
||||||
msg_type,
|
arweave_publish_pending,arweave_published_at_ms,
|
||||||
msg_sub_type,
|
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||||
block_bytes,
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
to_login,
|
|
||||||
to_bch_name,
|
|
||||||
to_block_number,
|
|
||||||
to_block_hash,
|
|
||||||
block_hash,
|
|
||||||
block_signature,
|
|
||||||
edited_by_block_number,
|
|
||||||
line_code,
|
|
||||||
prev_line_number,
|
|
||||||
prev_line_hash,
|
|
||||||
this_line_number
|
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
int i = 1;
|
int i = 1;
|
||||||
|
|
||||||
ps.setString(i++, e.getLogin());
|
ps.setString(i++, e.getLogin());
|
||||||
ps.setString(i++, e.getBchName());
|
ps.setInt(i++, e.getForkNumber());
|
||||||
ps.setInt(i++, e.getBlockNumber());
|
ps.setInt(i++, e.getBlockNumber());
|
||||||
|
|
||||||
ps.setInt(i++, e.getMsgType());
|
ps.setInt(i++, e.getMsgType());
|
||||||
ps.setInt(i++, e.getMsgSubType());
|
ps.setInt(i++, e.getMsgSubType());
|
||||||
|
|
||||||
ps.setBytes(i++, e.getBlockBytes());
|
ps.setBytes(i++, e.getBlockBytes());
|
||||||
|
setNullableString(ps, i++, e.getToLogin());
|
||||||
if (e.getToLogin() != null) ps.setString(i++, e.getToLogin());
|
setNullableInt(ps, i++, e.getToForkNumber());
|
||||||
else ps.setNull(i++, Types.VARCHAR);
|
setNullableInt(ps, i++, e.getToBlockNumber());
|
||||||
|
setNullableBytes(ps, i++, e.getToBlockHash());
|
||||||
if (e.getToBchName() != null) ps.setString(i++, e.getToBchName());
|
|
||||||
else ps.setNull(i++, Types.VARCHAR);
|
|
||||||
|
|
||||||
if (e.getToBlockNumber() != null) ps.setInt(i++, e.getToBlockNumber());
|
|
||||||
else ps.setNull(i++, Types.INTEGER);
|
|
||||||
|
|
||||||
if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash());
|
|
||||||
else ps.setNull(i++, Types.BINARY);
|
|
||||||
|
|
||||||
ps.setBytes(i++, e.getBlockHash());
|
ps.setBytes(i++, e.getBlockHash());
|
||||||
ps.setBytes(i++, e.getBlockSignature());
|
ps.setBytes(i++, e.getBlockSignature());
|
||||||
|
ps.setBytes(i++, e.getDataItemId());
|
||||||
if (e.getEditedByBlockNumber() != null) ps.setInt(i++, e.getEditedByBlockNumber());
|
ps.setBoolean(i++, e.isArweavePublishPending());
|
||||||
else ps.setNull(i++, Types.INTEGER);
|
if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs());
|
||||||
|
setNullableInt(ps, i++, e.getEditedByBlockNumber());
|
||||||
// NEW: line_code
|
setNullableInt(ps, i++, e.getLineCode());
|
||||||
if (e.getLineCode() != null) ps.setInt(i++, e.getLineCode());
|
setNullableInt(ps, i++, e.getPrevLineNumber());
|
||||||
else ps.setNull(i++, Types.INTEGER);
|
setNullableBytes(ps, i++, e.getPrevLineHash());
|
||||||
|
setNullableInt(ps, i++, e.getThisLineNumber());
|
||||||
if (e.getPrevLineNumber() != null) ps.setInt(i++, e.getPrevLineNumber());
|
|
||||||
else ps.setNull(i++, Types.INTEGER);
|
|
||||||
|
|
||||||
if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash());
|
|
||||||
else ps.setNull(i++, Types.BINARY);
|
|
||||||
|
|
||||||
if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber());
|
|
||||||
else ps.setNull(i++, Types.INTEGER);
|
|
||||||
|
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Вставка без внешнего соединения. Сам открывает/закрывает. */
|
public void insert(BlockEntry e) throws SQLException { try (Connection c = db.getConnection()) { insert(c, e); } }
|
||||||
public void insert(BlockEntry e) throws SQLException {
|
|
||||||
try (Connection c = db.getConnection()) {
|
public boolean existsByDataItemId(byte[] dataItemId) throws SQLException {
|
||||||
insert(c, e);
|
if (dataItemId == null || dataItemId.length != 32) return false;
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("SELECT 1 FROM blocks WHERE data_item_id=? LIMIT 1")) {
|
||||||
|
ps.setBytes(1, dataItemId);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- SELECT: HASH BY NUMBER --------------------
|
public List<BlockEntry> listPendingArweave(int limit) throws SQLException {
|
||||||
|
int safeLimit = Math.max(1, Math.min(limit, 100_000));
|
||||||
/** Получить block_hash по (bch_name, block_number). Нужен для линейной проверки. */
|
|
||||||
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT block_hash
|
SELECT login,fork_number,block_number,msg_type,msg_sub_type,block_bytes,
|
||||||
|
to_login,to_fork_number,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
|
||||||
|
arweave_publish_pending,arweave_published_at_ms,
|
||||||
|
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name = ? AND block_number = ?
|
WHERE arweave_publish_pending = TRUE
|
||||||
LIMIT 1
|
ORDER BY login ASC, fork_number ASC, block_number ASC
|
||||||
|
LIMIT ?
|
||||||
""";
|
""";
|
||||||
|
List<BlockEntry> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, bchName);
|
ps.setInt(1, safeLimit);
|
||||||
ps.setInt(2, blockNumber);
|
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
|
||||||
if (!rs.next()) return null;
|
|
||||||
return rs.getBytes("block_hash");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException {
|
public void markArweavePublished(List<byte[]> dataItemIds, long publishedAtMs) throws SQLException {
|
||||||
|
if (dataItemIds == null || dataItemIds.isEmpty()) return;
|
||||||
|
String sql = """
|
||||||
|
UPDATE blocks_store
|
||||||
|
SET arweave_publish_pending=FALSE, arweave_published_at_ms=?
|
||||||
|
WHERE data_item_id=?
|
||||||
|
""";
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
return getHashByNumber(c, bchName, blockNumber);
|
c.setAutoCommit(false);
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
for (byte[] id : dataItemIds) {
|
||||||
|
ps.setLong(1, publishedAtMs); ps.setBytes(2, id); ps.addBatch();
|
||||||
|
}
|
||||||
|
ps.executeBatch(); c.commit();
|
||||||
|
} catch (Exception e) { c.rollback(); throw e; }
|
||||||
|
finally { c.setAutoCommit(true); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- SELECT: FULL ENTRY --------------------
|
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("SELECT block_hash FROM blocks WHERE login=? AND fork_number=? AND block_number=? LIMIT 1")) {
|
||||||
|
ChainRef r=chainRef(bchName); ps.setString(1,r.login()); ps.setInt(2,r.fork()); ps.setInt(3,blockNumber);
|
||||||
|
try (ResultSet rs=ps.executeQuery()) { return rs.next()?rs.getBytes(1):null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException { try(Connection c=db.getConnection()){return getHashByNumber(c,bchName,blockNumber);} }
|
||||||
|
|
||||||
public BlockEntry getByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
public BlockEntry getByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
||||||
String sql = """
|
String sql = baseSelect()+" WHERE login=? AND fork_number=? AND block_number=? LIMIT 1";
|
||||||
SELECT
|
ChainRef r=chainRef(bchName);
|
||||||
login,
|
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,r.login()); ps.setInt(2,r.fork()); ps.setInt(3,blockNumber); try(ResultSet rs=ps.executeQuery()){return rs.next()?mapRow(rs):null;} }
|
||||||
bch_name,
|
}
|
||||||
block_number,
|
public BlockEntry getByNumber(String bchName,int blockNumber)throws SQLException{try(Connection c=db.getConnection()){return getByNumber(c,bchName,blockNumber);} }
|
||||||
msg_type,
|
|
||||||
msg_sub_type,
|
|
||||||
block_bytes,
|
|
||||||
to_login,
|
|
||||||
to_bch_name,
|
|
||||||
to_block_number,
|
|
||||||
to_block_hash,
|
|
||||||
block_hash,
|
|
||||||
block_signature,
|
|
||||||
edited_by_block_number,
|
|
||||||
line_code,
|
|
||||||
prev_line_number,
|
|
||||||
prev_line_hash,
|
|
||||||
this_line_number
|
|
||||||
FROM blocks
|
|
||||||
WHERE bch_name = ? AND block_number = ?
|
|
||||||
LIMIT 1
|
|
||||||
""";
|
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
public List<BlockEntry> listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} }
|
||||||
ps.setString(1, bchName);
|
public List<BlockEntry> listRangeByNumber(Connection c,String bchName,int from,int to)throws SQLException{
|
||||||
ps.setInt(2, blockNumber);
|
String sql=baseSelect()+" WHERE login=? AND fork_number=? AND block_number>=? AND block_number<=? ORDER BY block_number ASC";
|
||||||
|
List<BlockEntry> out=new ArrayList<>();
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
ChainRef r=chainRef(bchName);
|
||||||
if (!rs.next()) return null;
|
try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,r.login());ps.setInt(2,r.fork());ps.setInt(3,from);ps.setInt(4,to);try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(mapRow(rs));}}
|
||||||
return mapRow(rs);
|
return out;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public BlockEntry getByNumber(String bchName, int blockNumber) throws SQLException {
|
private static String baseSelect(){return """
|
||||||
try (Connection c = db.getConnection()) {
|
SELECT login,fork_number,block_number,msg_type,msg_sub_type,block_bytes,
|
||||||
return getByNumber(c, bchName, blockNumber);
|
to_login,to_fork_number,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
|
||||||
}
|
arweave_publish_pending,arweave_published_at_ms,
|
||||||
}
|
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||||
|
FROM blocks
|
||||||
public List<BlockEntry> listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
|
""";}
|
||||||
try (Connection c = db.getConnection()) {
|
|
||||||
return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<BlockEntry> listRangeByNumber(Connection c, String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
|
|
||||||
String sql = """
|
|
||||||
SELECT
|
|
||||||
login,
|
|
||||||
bch_name,
|
|
||||||
block_number,
|
|
||||||
msg_type,
|
|
||||||
msg_sub_type,
|
|
||||||
block_bytes,
|
|
||||||
to_login,
|
|
||||||
to_bch_name,
|
|
||||||
to_block_number,
|
|
||||||
to_block_hash,
|
|
||||||
block_hash,
|
|
||||||
block_signature,
|
|
||||||
edited_by_block_number,
|
|
||||||
line_code,
|
|
||||||
prev_line_number,
|
|
||||||
prev_line_hash,
|
|
||||||
this_line_number
|
|
||||||
FROM blocks
|
|
||||||
WHERE bch_name = ?
|
|
||||||
AND block_number >= ?
|
|
||||||
AND block_number <= ?
|
|
||||||
ORDER BY block_number ASC
|
|
||||||
""";
|
|
||||||
|
|
||||||
List<BlockEntry> result = new ArrayList<>();
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, bchName);
|
|
||||||
ps.setInt(2, fromBlockNumberInclusive);
|
|
||||||
ps.setInt(3, toBlockNumberInclusive);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
|
||||||
while (rs.next()) {
|
|
||||||
result.add(mapRow(rs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------- INTERNAL --------------------
|
|
||||||
|
|
||||||
private BlockEntry mapRow(ResultSet rs) throws SQLException {
|
|
||||||
BlockEntry e = new BlockEntry();
|
|
||||||
|
|
||||||
e.setLogin(rs.getString("login"));
|
|
||||||
e.setBchName(rs.getString("bch_name"));
|
|
||||||
e.setBlockNumber(rs.getInt("block_number"));
|
|
||||||
|
|
||||||
e.setMsgType(rs.getInt("msg_type"));
|
|
||||||
e.setMsgSubType(rs.getInt("msg_sub_type"));
|
|
||||||
|
|
||||||
e.setBlockBytes(rs.getBytes("block_bytes"));
|
|
||||||
|
|
||||||
String toLogin = rs.getString("to_login");
|
|
||||||
if (rs.wasNull()) toLogin = null;
|
|
||||||
e.setToLogin(toLogin);
|
|
||||||
|
|
||||||
String toBchName = rs.getString("to_bch_name");
|
|
||||||
if (rs.wasNull()) toBchName = null;
|
|
||||||
e.setToBchName(toBchName);
|
|
||||||
|
|
||||||
Integer toBlockNumber = (Integer) rs.getObject("to_block_number");
|
|
||||||
e.setToBlockNumber(toBlockNumber);
|
|
||||||
|
|
||||||
byte[] toHash = rs.getBytes("to_block_hash");
|
|
||||||
if (rs.wasNull()) toHash = null;
|
|
||||||
e.setToBlockHash(toHash);
|
|
||||||
|
|
||||||
e.setBlockHash(rs.getBytes("block_hash"));
|
|
||||||
e.setBlockSignature(rs.getBytes("block_signature"));
|
|
||||||
|
|
||||||
Integer editedBy = (Integer) rs.getObject("edited_by_block_number");
|
|
||||||
e.setEditedByBlockNumber(editedBy);
|
|
||||||
|
|
||||||
// NEW: line_code
|
|
||||||
Integer lineCode = (Integer) rs.getObject("line_code");
|
|
||||||
e.setLineCode(lineCode);
|
|
||||||
|
|
||||||
Integer prevLn = (Integer) rs.getObject("prev_line_number");
|
|
||||||
e.setPrevLineNumber(prevLn);
|
|
||||||
|
|
||||||
byte[] prevLh = rs.getBytes("prev_line_hash");
|
|
||||||
if (rs.wasNull()) prevLh = null;
|
|
||||||
e.setPrevLineHash(prevLh);
|
|
||||||
|
|
||||||
Integer thisLn = (Integer) rs.getObject("this_line_number");
|
|
||||||
e.setThisLineNumber(thisLn);
|
|
||||||
|
|
||||||
|
private BlockEntry mapRow(ResultSet rs)throws SQLException{
|
||||||
|
BlockEntry e=new BlockEntry();
|
||||||
|
e.setLogin(rs.getString("login")); e.setForkNumber(rs.getInt("fork_number")); e.setBlockNumber(rs.getInt("block_number"));
|
||||||
|
e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes"));
|
||||||
|
e.setToLogin(rs.getString("to_login")); e.setToForkNumber((Integer)rs.getObject("to_fork_number")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash"));
|
||||||
|
e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id"));
|
||||||
|
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms"));
|
||||||
|
e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number"));
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record ChainRef(String login, int fork) {}
|
||||||
|
private static ChainRef chainRef(String name) {
|
||||||
|
if (name == null) throw new IllegalArgumentException("blockchainName is null");
|
||||||
|
String s=name.trim(); int dash=s.lastIndexOf('-');
|
||||||
|
if (dash <= 0 || s.length()-dash-1 != 3) throw new IllegalArgumentException("Bad blockchainName: "+name);
|
||||||
|
try {
|
||||||
|
int fork=Integer.parseInt(s.substring(dash+1));
|
||||||
|
if (fork < 1 || fork > 999) throw new IllegalArgumentException("fork out of range: "+name);
|
||||||
|
return new ChainRef(s.substring(0,dash),fork);
|
||||||
|
} catch(NumberFormatException ex){ throw new IllegalArgumentException("Bad blockchainName: "+name,ex); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setNullableString(PreparedStatement ps,int i,String v)throws SQLException{if(v==null)ps.setNull(i,Types.VARCHAR);else ps.setString(i,v);}
|
||||||
|
private static void setNullableInt(PreparedStatement ps,int i,Integer v)throws SQLException{if(v==null)ps.setNull(i,Types.INTEGER);else ps.setInt(i,v);}
|
||||||
|
private static void setNullableBytes(PreparedStatement ps,int i,byte[] v)throws SQLException{if(v==null)ps.setNull(i,Types.BINARY);else ps.setBytes(i,v);}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,4 +120,15 @@ public final class ChannelNameStateDAO {
|
|||||||
insert(c, entry);
|
insert(c, entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public String getSlugByOwnerAndRoot(String ownerBchName, int rootBlockNumber) throws SQLException {
|
||||||
|
String sql = "SELECT slug FROM channel_names_state WHERE owner_bch_name=? AND channel_root_block_number=? LIMIT 1";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerBchName);
|
||||||
|
ps.setInt(2, rootBlockNumber);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? rs.getString(1) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server projection for per-user channel read/unread state.
|
||||||
|
*
|
||||||
|
* Important rules:
|
||||||
|
* - rows are created only by a signed client SetChannelReadState;
|
||||||
|
* - channel publications increment unread_count only for rows that already exist;
|
||||||
|
* - read_count is monotonic and unread_count is reduced by the accepted read delta;
|
||||||
|
* - unread_count is never part of the client signature and is server-owned.
|
||||||
|
*/
|
||||||
|
public final class ChannelReadStateDAO {
|
||||||
|
private static volatile ChannelReadStateDAO instance;
|
||||||
|
|
||||||
|
private ChannelReadStateDAO() {}
|
||||||
|
|
||||||
|
public static ChannelReadStateDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (ChannelReadStateDAO.class) {
|
||||||
|
if (instance == null) instance = new ChannelReadStateDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReadState(long readCount, long unreadCount, long readUpdatedAtMs,
|
||||||
|
String clientKey, String readSignature) {}
|
||||||
|
|
||||||
|
public record UpsertResult(boolean applied, long readCount, long unreadCount) {}
|
||||||
|
|
||||||
|
public ReadState get(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT read_count, unread_count, read_updated_at_ms, client_key, read_signature
|
||||||
|
FROM channel_read_state
|
||||||
|
WHERE LOWER(viewer_login)=LOWER(?) AND LOWER(owner_login)=LOWER(?) AND channel_name=?
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, viewerLogin);
|
||||||
|
ps.setString(2, loginFromChainName(ownerBchName));
|
||||||
|
ps.setString(3, channelName);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
return new ReadState(
|
||||||
|
Math.max(0L, rs.getLong("read_count")),
|
||||||
|
Math.max(0L, rs.getLong("unread_count")),
|
||||||
|
rs.getLong("read_updated_at_ms"),
|
||||||
|
rs.getString("client_key"),
|
||||||
|
rs.getString("read_signature"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getUnreadCount(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
|
||||||
|
ReadState state = get(c, viewerLogin, ownerBchName, channelName);
|
||||||
|
return state == null ? 0L : state.unreadCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public long sumUnreadCount(Connection c, String viewerLogin) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT COALESCE(SUM(crs.unread_count),0)
|
||||||
|
FROM channel_read_state crs
|
||||||
|
WHERE LOWER(crs.viewer_login)=LOWER(?)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM channel_names_state cns
|
||||||
|
JOIN connections_state cs
|
||||||
|
ON LOWER(cs.login)=LOWER(crs.viewer_login)
|
||||||
|
AND cs.rel_type=30
|
||||||
|
AND LOWER(cs.to_login)=LOWER(cns.owner_login)
|
||||||
|
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||||
|
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||||
|
WHERE LOWER(cns.owner_login)=LOWER(crs.owner_login)
|
||||||
|
AND cns.slug=crs.channel_name
|
||||||
|
AND cns.channel_type_code=1
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, viewerLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the requested public channel exists and that viewer currently follows it.
|
||||||
|
*/
|
||||||
|
public boolean hasActiveSubscription(Connection c, String viewerLogin, String ownerBchName, String channelName,
|
||||||
|
int followRelType) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT 1
|
||||||
|
FROM channel_names_state cns
|
||||||
|
JOIN connections_state cs
|
||||||
|
ON LOWER(cs.login)=LOWER(?)
|
||||||
|
AND cs.rel_type=?
|
||||||
|
AND LOWER(cs.to_login)=LOWER(cns.owner_login)
|
||||||
|
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||||
|
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||||
|
WHERE cns.owner_bch_name=?
|
||||||
|
AND cns.slug=?
|
||||||
|
AND cns.channel_type_code=1
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, viewerLogin);
|
||||||
|
ps.setInt(2, followRelType);
|
||||||
|
ps.setString(3, ownerBchName);
|
||||||
|
ps.setString(4, channelName);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolvePublicChannelNameByRoot(Connection c, String ownerBchName, int rootBlockNumber) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT slug
|
||||||
|
FROM channel_names_state
|
||||||
|
WHERE owner_bch_name=? AND channel_root_block_number=? AND channel_type_code=1
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerBchName);
|
||||||
|
ps.setInt(2, rootBlockNumber);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? rs.getString("slug") : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public Integer resolvePublicChannelRootByName(Connection c, String ownerBchName, String channelName) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT channel_root_block_number
|
||||||
|
FROM channel_names_state
|
||||||
|
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerBchName);
|
||||||
|
ps.setString(2, channelName);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? rs.getInt("channel_root_block_number") : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolveOwnerLogin(Connection c, String ownerBchName, String channelName) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT owner_login
|
||||||
|
FROM channel_names_state
|
||||||
|
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerBchName);
|
||||||
|
ps.setString(2, channelName);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? rs.getString("owner_login") : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts the first client baseline with unread=0, or advances an existing read watermark.
|
||||||
|
* Older timestamps and backwards read_count values are ignored.
|
||||||
|
*/
|
||||||
|
public UpsertResult upsertSignedReadIfNewer(Connection c,
|
||||||
|
String viewerLogin,
|
||||||
|
String ownerLogin,
|
||||||
|
String ownerBchName,
|
||||||
|
String channelName,
|
||||||
|
long readCount,
|
||||||
|
long readUpdatedAtMs,
|
||||||
|
String clientKey,
|
||||||
|
String readSignature,
|
||||||
|
long nowMs) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO channel_read_state (
|
||||||
|
viewer_login, owner_login, owner_bch_name, channel_name,
|
||||||
|
read_count, unread_count,
|
||||||
|
read_updated_at_ms, client_key, read_signature, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (viewer_login, owner_login, channel_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
owner_login = EXCLUDED.owner_login,
|
||||||
|
owner_bch_name = EXCLUDED.owner_bch_name,
|
||||||
|
unread_count = GREATEST(
|
||||||
|
0,
|
||||||
|
channel_read_state.unread_count -
|
||||||
|
GREATEST(0, EXCLUDED.read_count - channel_read_state.read_count)
|
||||||
|
),
|
||||||
|
read_count = GREATEST(channel_read_state.read_count, EXCLUDED.read_count),
|
||||||
|
read_updated_at_ms = EXCLUDED.read_updated_at_ms,
|
||||||
|
client_key = EXCLUDED.client_key,
|
||||||
|
read_signature = EXCLUDED.read_signature,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms
|
||||||
|
WHERE channel_read_state.read_updated_at_ms < EXCLUDED.read_updated_at_ms
|
||||||
|
AND EXCLUDED.read_count >= channel_read_state.read_count
|
||||||
|
RETURNING read_count, unread_count
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, viewerLogin);
|
||||||
|
ps.setString(2, ownerLogin);
|
||||||
|
ps.setString(3, ownerBchName);
|
||||||
|
ps.setString(4, channelName);
|
||||||
|
ps.setLong(5, Math.max(0L, readCount));
|
||||||
|
ps.setLong(6, readUpdatedAtMs);
|
||||||
|
ps.setString(7, clientKey);
|
||||||
|
ps.setString(8, readSignature);
|
||||||
|
ps.setLong(9, nowMs);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return new UpsertResult(true,
|
||||||
|
Math.max(0L, rs.getLong("read_count")),
|
||||||
|
Math.max(0L, rs.getLong("unread_count")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ReadState current = get(c, viewerLogin, ownerBchName, channelName);
|
||||||
|
return new UpsertResult(false,
|
||||||
|
current == null ? 0L : current.readCount(),
|
||||||
|
current == null ? 0L : current.unreadCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increments existing rows for current subscribers of one public channel.
|
||||||
|
* Missing channel_read_state rows are deliberately NOT created.
|
||||||
|
* Returns affected viewer logins for post-commit counter pushes.
|
||||||
|
*/
|
||||||
|
public List<String> incrementUnreadForSubscribers(Connection c,
|
||||||
|
String ownerBchName,
|
||||||
|
String channelName,
|
||||||
|
int followRelType,
|
||||||
|
long nowMs) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
UPDATE channel_read_state crs
|
||||||
|
SET unread_count = crs.unread_count + 1,
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE LOWER(crs.owner_login)=LOWER(?)
|
||||||
|
AND crs.channel_name = ?
|
||||||
|
AND LOWER(crs.viewer_login) <> LOWER(crs.owner_login)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM channel_names_state cns
|
||||||
|
JOIN connections_state cs
|
||||||
|
ON LOWER(cs.login)=LOWER(crs.viewer_login)
|
||||||
|
AND cs.rel_type=?
|
||||||
|
AND LOWER(cs.to_login)=LOWER(cns.owner_login)
|
||||||
|
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||||
|
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||||
|
WHERE LOWER(cns.owner_login)=LOWER(crs.owner_login)
|
||||||
|
AND cns.slug=crs.channel_name
|
||||||
|
AND cns.channel_type_code=1
|
||||||
|
)
|
||||||
|
RETURNING viewer_login
|
||||||
|
""";
|
||||||
|
List<String> affected = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setLong(1, nowMs);
|
||||||
|
ps.setString(2, loginFromChainName(ownerBchName));
|
||||||
|
ps.setString(3, channelName);
|
||||||
|
ps.setInt(4, followRelType);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) affected.add(rs.getString("viewer_login"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return affected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete the projection row when a channel FOLLOW is explicitly removed. */
|
||||||
|
public boolean deleteForUnfollowTarget(Connection c, String viewerLogin, String ownerBchName,
|
||||||
|
int rootBlockNumber) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
DELETE FROM channel_read_state crs
|
||||||
|
USING channel_names_state cns
|
||||||
|
WHERE LOWER(crs.viewer_login)=LOWER(?)
|
||||||
|
AND LOWER(crs.owner_login)=LOWER(?)
|
||||||
|
AND LOWER(cns.owner_login)=LOWER(crs.owner_login)
|
||||||
|
AND cns.slug=crs.channel_name
|
||||||
|
AND cns.channel_root_block_number=?
|
||||||
|
AND cns.channel_type_code=1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, viewerLogin);
|
||||||
|
ps.setString(2, loginFromChainName(ownerBchName));
|
||||||
|
ps.setInt(3, rootBlockNumber);
|
||||||
|
return ps.executeUpdate() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private static String loginFromChainName(String bch){ if(bch==null)return null; int d=bch.lastIndexOf('-'); return d>0?bch.substring(0,d):bch; }
|
||||||
|
|
||||||
|
}
|
||||||
@@ -39,19 +39,14 @@ public final class ConnectionsStateDAO {
|
|||||||
*/
|
*/
|
||||||
public List<String> listOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
public List<String> listOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login
|
SELECT COALESCE(u_login.login, cs.to_login) AS friend_login
|
||||||
FROM connections_state cs
|
FROM connections_state cs
|
||||||
LEFT JOIN %s
|
LEFT JOIN %s
|
||||||
ON LOWER(u_login.login) = LOWER(cs.to_login)
|
ON LOWER(u_login.login) = LOWER(cs.to_login)
|
||||||
LEFT JOIN %s
|
|
||||||
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
|
|
||||||
WHERE LOWER(cs.login) = LOWER(?)
|
WHERE LOWER(cs.login) = LOWER(?)
|
||||||
AND cs.rel_type = ?
|
AND cs.rel_type = ?
|
||||||
ORDER BY friend_login
|
ORDER BY friend_login
|
||||||
""".formatted(
|
""".formatted(CurrentUsersSql.usersSubquery("u_login"));
|
||||||
CurrentUsersSql.usersSubquery("u_login"),
|
|
||||||
CurrentUsersSql.usersSubquery("u_bch")
|
|
||||||
);
|
|
||||||
|
|
||||||
List<String> out = new ArrayList<>();
|
List<String> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
@@ -73,19 +68,14 @@ public final class ConnectionsStateDAO {
|
|||||||
FROM connections_state cs
|
FROM connections_state cs
|
||||||
LEFT JOIN %s
|
LEFT JOIN %s
|
||||||
ON LOWER(u_login.login) = LOWER(cs.to_login)
|
ON LOWER(u_login.login) = LOWER(cs.to_login)
|
||||||
LEFT JOIN %s
|
|
||||||
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
|
|
||||||
WHERE LOWER(cs.login) = LOWER(?)
|
WHERE LOWER(cs.login) = LOWER(?)
|
||||||
AND cs.rel_type = ?
|
AND cs.rel_type = ?
|
||||||
AND (
|
AND (
|
||||||
LOWER(cs.to_login) = LOWER(?)
|
LOWER(cs.to_login) = LOWER(?)
|
||||||
OR LOWER(COALESCE(u_login.login, u_bch.login, cs.to_login)) = LOWER(?)
|
OR LOWER(COALESCE(u_login.login, cs.to_login)) = LOWER(?)
|
||||||
)
|
)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""".formatted(
|
""".formatted(CurrentUsersSql.usersSubquery("u_login"));
|
||||||
CurrentUsersSql.usersSubquery("u_login"),
|
|
||||||
CurrentUsersSql.usersSubquery("u_bch")
|
|
||||||
);
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, loginAnyCase);
|
ps.setString(1, loginAnyCase);
|
||||||
ps.setInt(2, relType);
|
ps.setInt(2, relType);
|
||||||
@@ -104,33 +94,12 @@ public final class ConnectionsStateDAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT COALESCE(u_actor.login, cs.login) AS friend_login
|
SELECT COALESCE(u_actor.login, cs.login) AS friend_login
|
||||||
FROM connections_state cs
|
FROM connections_state cs
|
||||||
LEFT JOIN %s
|
LEFT JOIN %s ON LOWER(u_actor.login)=LOWER(cs.login)
|
||||||
ON LOWER(u_actor.login) = LOWER(cs.login)
|
WHERE LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=?
|
||||||
LEFT JOIN %s
|
|
||||||
ON LOWER(u_target.login) = LOWER(?)
|
|
||||||
WHERE (
|
|
||||||
LOWER(cs.to_login) = LOWER(?)
|
|
||||||
OR (u_target.blockchain_name IS NOT NULL AND LOWER(cs.to_bch_name) = LOWER(u_target.blockchain_name))
|
|
||||||
)
|
|
||||||
AND cs.rel_type = ?
|
|
||||||
ORDER BY friend_login
|
ORDER BY friend_login
|
||||||
""".formatted(
|
""".formatted(CurrentUsersSql.usersSubquery("u_actor"));
|
||||||
CurrentUsersSql.usersSubquery("u_actor"),
|
List<String> out=new ArrayList<>();
|
||||||
CurrentUsersSql.usersSubquery("u_target")
|
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,loginAnyCase); ps.setInt(2,relType); try(ResultSet rs=ps.executeQuery()){while(rs.next()){String v=rs.getString("friend_login");if(v!=null)out.add(v);}}}
|
||||||
);
|
|
||||||
|
|
||||||
List<String> out = new ArrayList<>();
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, loginAnyCase);
|
|
||||||
ps.setString(2, loginAnyCase);
|
|
||||||
ps.setInt(3, relType);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
|
||||||
while (rs.next()) {
|
|
||||||
String v = rs.getString("friend_login");
|
|
||||||
if (v != null) out.add(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,21 +158,17 @@ public final class ConnectionsStateDAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO connections_state (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
INSERT INTO connections_state (login, rel_type, to_login, to_block_number, to_block_hash)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) DO UPDATE SET
|
ON CONFLICT(login, rel_type, to_login, to_block_number, to_block_hash) DO NOTHING
|
||||||
to_bch_name=excluded.to_bch_name,
|
|
||||||
to_block_number=excluded.to_block_number,
|
|
||||||
to_block_hash=excluded.to_block_hash
|
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, login);
|
ps.setString(1, login);
|
||||||
ps.setInt(2, relType);
|
ps.setInt(2, relType);
|
||||||
ps.setString(3, toLogin);
|
ps.setString(3, toLogin);
|
||||||
ps.setString(4, toBchName);
|
if (toBlockNumber == null) ps.setNull(4, java.sql.Types.INTEGER); else ps.setInt(4, toBlockNumber);
|
||||||
if (toBlockNumber == null) ps.setNull(5, java.sql.Types.INTEGER); else ps.setInt(5, toBlockNumber);
|
ps.setBytes(5, toBlockHash);
|
||||||
ps.setBytes(6, toBlockHash);
|
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,16 +77,18 @@ public final class DmDialogStateDAO {
|
|||||||
ps.setString(1, cleanOwner);
|
ps.setString(1, cleanOwner);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
|
long lastMessageTimeMs = rs.getLong("last_message_time_ms");
|
||||||
|
int unreadCount = rs.getInt("unread_count");
|
||||||
DialogSummary row = new DialogSummary(
|
DialogSummary row = new DialogSummary(
|
||||||
rs.getString("owner_login"),
|
rs.getString("owner_login"),
|
||||||
rs.getString("peer_login"),
|
rs.getString("peer_login"),
|
||||||
normalizeRelationFlag(rs.getString("relation_flag")),
|
normalizeRelationFlag(rs.getString("relation_flag")),
|
||||||
rs.getString("last_message_blob_b64"),
|
rs.getString("last_message_blob_b64"),
|
||||||
rs.getLong("last_message_time_ms"),
|
lastMessageTimeMs,
|
||||||
rs.getInt("unread_count"),
|
unreadCount,
|
||||||
rs.getLong("last_read_receipt_time_ms"),
|
rs.getLong("last_read_receipt_time_ms"),
|
||||||
rs.getLong("updated_at_ms"),
|
rs.getLong("updated_at_ms"),
|
||||||
true
|
lastMessageTimeMs > 0 || unreadCount > 0
|
||||||
);
|
);
|
||||||
byPeer.put(normKey(row.peerLogin()), row);
|
byPeer.put(normKey(row.peerLogin()), row);
|
||||||
}
|
}
|
||||||
@@ -364,6 +366,8 @@ public final class DmDialogStateDAO {
|
|||||||
String nextRelation = current.relationFlag();
|
String nextRelation = current.relationFlag();
|
||||||
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
|
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
|
||||||
nextRelation = "close_friend";
|
nextRelation = "close_friend";
|
||||||
|
} else if ("friend".equalsIgnoreCase(relationFlag) || "friend".equalsIgnoreCase(nextRelation)) {
|
||||||
|
nextRelation = "friend";
|
||||||
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
|
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
|
||||||
nextRelation = "contact";
|
nextRelation = "contact";
|
||||||
} else {
|
} else {
|
||||||
@@ -440,7 +444,7 @@ public final class DmDialogStateDAO {
|
|||||||
|
|
||||||
private String normalizeRelationFlag(String value) {
|
private String normalizeRelationFlag(String value) {
|
||||||
String clean = normalize(value).toLowerCase(Locale.ROOT);
|
String clean = normalize(value).toLowerCase(Locale.ROOT);
|
||||||
if ("close_friend".equals(clean) || "contact".equals(clean)) return clean;
|
if ("close_friend".equals(clean) || "friend".equals(clean) || "contact".equals(clean)) return clean;
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Хранилище candidate-блоков будущего fork во время ротации ключей. */
|
||||||
|
public final class KeyRotationCandidateBlocksDAO {
|
||||||
|
private static volatile KeyRotationCandidateBlocksDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private KeyRotationCandidateBlocksDAO() { }
|
||||||
|
|
||||||
|
public static KeyRotationCandidateBlocksDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (KeyRotationCandidateBlocksDAO.class) {
|
||||||
|
if (instance == null) instance = new KeyRotationCandidateBlocksDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Идемпотентно сохраняет один candidate-блок.
|
||||||
|
* Повтор того же blockNumber допустим только при полном совпадении hash и DataItem id.
|
||||||
|
*/
|
||||||
|
public KeyRotationCandidateBlockEntry insertOrGet(Connection c, KeyRotationCandidateBlockEntry entry) throws SQLException {
|
||||||
|
KeyRotationCandidateBlockEntry existing = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber());
|
||||||
|
if (existing != null) {
|
||||||
|
if (Arrays.equals(existing.getBlockHash(), entry.getBlockHash())
|
||||||
|
&& Arrays.equals(existing.getDataItemId(), entry.getDataItemId())) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
throw new SQLException("Candidate block conflict at number=" + entry.getBlockNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO key_rotation_candidate_blocks (
|
||||||
|
rotation_session_id, login, candidate_blockchain_name,
|
||||||
|
block_number, block_hash, data_item_id, block_bytes,
|
||||||
|
arweave_publish_pending, arweave_published_at_ms, created_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, NULL, ?)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||||
|
int i = 1;
|
||||||
|
ps.setLong(i++, entry.getRotationSessionId());
|
||||||
|
ps.setString(i++, entry.getLogin());
|
||||||
|
ps.setString(i++, entry.getCandidateBlockchainName());
|
||||||
|
ps.setInt(i++, entry.getBlockNumber());
|
||||||
|
ps.setBytes(i++, entry.getBlockHash());
|
||||||
|
ps.setBytes(i++, entry.getDataItemId());
|
||||||
|
ps.setBytes(i++, entry.getBlockBytes());
|
||||||
|
ps.setLong(i++, entry.getCreatedAtMs());
|
||||||
|
ps.executeUpdate();
|
||||||
|
try (ResultSet keys = ps.getGeneratedKeys()) {
|
||||||
|
if (!keys.next()) throw new SQLException("candidate block insert returned no id");
|
||||||
|
entry.setId(keys.getLong(1));
|
||||||
|
}
|
||||||
|
} catch (SQLException insertFailure) {
|
||||||
|
// Конкурирующий повтор из другой сессии мог успеть вставиться между SELECT и INSERT.
|
||||||
|
KeyRotationCandidateBlockEntry raced = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber());
|
||||||
|
if (raced != null
|
||||||
|
&& Arrays.equals(raced.getBlockHash(), entry.getBlockHash())
|
||||||
|
&& Arrays.equals(raced.getDataItemId(), entry.getDataItemId())) {
|
||||||
|
return raced;
|
||||||
|
}
|
||||||
|
throw insertFailure;
|
||||||
|
}
|
||||||
|
entry.setArweavePublishPending(true);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRotationCandidateBlockEntry getByNumber(Connection c, long sessionId, int blockNumber) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT * FROM key_rotation_candidate_blocks
|
||||||
|
WHERE rotation_session_id = ? AND block_number = ?
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, sessionId);
|
||||||
|
ps.setInt(2, blockNumber);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? map(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countStored(Connection c, long sessionId) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT COUNT(*) FROM key_rotation_candidate_blocks WHERE rotation_session_id = ?
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, sessionId);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getInt(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countPublished(Connection c, long sessionId) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT COUNT(*) FROM key_rotation_candidate_blocks
|
||||||
|
WHERE rotation_session_id = ? AND arweave_publish_pending = FALSE
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, sessionId);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getInt(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все candidate-блоки одной ротации в строгом порядке block_number. */
|
||||||
|
public List<KeyRotationCandidateBlockEntry> listBySession(Connection c, long sessionId) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT * FROM key_rotation_candidate_blocks
|
||||||
|
WHERE rotation_session_id = ?
|
||||||
|
ORDER BY block_number
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, sessionId);
|
||||||
|
List<KeyRotationCandidateBlockEntry> out = new ArrayList<>();
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(map(rs));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Candidate-блоки имеют приоритет перед обычной очередью publisher-а. */
|
||||||
|
public List<KeyRotationCandidateBlockEntry> listPendingArweave(int limit) throws SQLException {
|
||||||
|
if (limit <= 0) return List.of();
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT * FROM key_rotation_candidate_blocks
|
||||||
|
WHERE arweave_publish_pending = TRUE
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT ?
|
||||||
|
""")) {
|
||||||
|
ps.setInt(1, limit);
|
||||||
|
List<KeyRotationCandidateBlockEntry> out = new ArrayList<>();
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(map(rs));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Помечает DataItem опубликованными и синхронизирует progress_current ротации
|
||||||
|
* с реальным количеством DataItem, подтверждённых publisher-ом.
|
||||||
|
*/
|
||||||
|
public void markArweavePublished(List<byte[]> dataItemIds, long publishedAtMs) throws SQLException {
|
||||||
|
if (dataItemIds == null || dataItemIds.isEmpty()) return;
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
java.util.Set<Long> sessions = new java.util.HashSet<>();
|
||||||
|
try (PreparedStatement find = c.prepareStatement("""
|
||||||
|
SELECT rotation_session_id
|
||||||
|
FROM key_rotation_candidate_blocks
|
||||||
|
WHERE data_item_id = ?
|
||||||
|
""")) {
|
||||||
|
for (byte[] id : dataItemIds) {
|
||||||
|
find.setBytes(1, id);
|
||||||
|
try (ResultSet rs = find.executeQuery()) {
|
||||||
|
if (rs.next()) sessions.add(rs.getLong(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_candidate_blocks
|
||||||
|
SET arweave_publish_pending = FALSE,
|
||||||
|
arweave_published_at_ms = COALESCE(arweave_published_at_ms, ?)
|
||||||
|
WHERE data_item_id = ?
|
||||||
|
""")) {
|
||||||
|
for (byte[] id : dataItemIds) {
|
||||||
|
ps.setLong(1, publishedAtMs);
|
||||||
|
ps.setBytes(2, id);
|
||||||
|
ps.addBatch();
|
||||||
|
}
|
||||||
|
ps.executeBatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement progress = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions s
|
||||||
|
SET progress_current = LEAST(s.progress_total, (
|
||||||
|
SELECT COUNT(*)::INTEGER
|
||||||
|
FROM key_rotation_candidate_blocks b
|
||||||
|
WHERE b.rotation_session_id = s.id
|
||||||
|
AND b.arweave_publish_pending = FALSE
|
||||||
|
)),
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE s.id = ? AND s.status = 'COPYING_CHAIN'
|
||||||
|
""")) {
|
||||||
|
for (Long sessionId : sessions) {
|
||||||
|
progress.setLong(1, publishedAtMs);
|
||||||
|
progress.setLong(2, sessionId);
|
||||||
|
progress.addBatch();
|
||||||
|
}
|
||||||
|
progress.executeBatch();
|
||||||
|
}
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
if (e instanceof SQLException sql) throw sql;
|
||||||
|
throw new SQLException("Failed to mark candidate DataItems published", e);
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyRotationCandidateBlockEntry map(ResultSet rs) throws SQLException {
|
||||||
|
KeyRotationCandidateBlockEntry e = new KeyRotationCandidateBlockEntry();
|
||||||
|
e.setId(rs.getLong("id"));
|
||||||
|
e.setRotationSessionId(rs.getLong("rotation_session_id"));
|
||||||
|
e.setLogin(rs.getString("login"));
|
||||||
|
e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name"));
|
||||||
|
e.setBlockNumber(rs.getInt("block_number"));
|
||||||
|
e.setBlockHash(rs.getBytes("block_hash"));
|
||||||
|
e.setDataItemId(rs.getBytes("data_item_id"));
|
||||||
|
e.setBlockBytes(rs.getBytes("block_bytes"));
|
||||||
|
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending"));
|
||||||
|
e.setArweavePublishedAtMs((Long) rs.getObject("arweave_published_at_ms"));
|
||||||
|
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DAO для серверной машины смены ключей.
|
||||||
|
*
|
||||||
|
* Все методы изменения статуса синхронно обновляют и key_rotation_sessions,
|
||||||
|
* и solana_user_pda_current.rotation_status / rotation_session_id.
|
||||||
|
*/
|
||||||
|
public final class KeyRotationSessionsDAO {
|
||||||
|
|
||||||
|
private static volatile KeyRotationSessionsDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private KeyRotationSessionsDAO() { }
|
||||||
|
|
||||||
|
public static KeyRotationSessionsDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (KeyRotationSessionsDAO.class) {
|
||||||
|
if (instance == null) instance = new KeyRotationSessionsDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Атомарно создаёт первую серверную запись ротации сразу в COPYING_CHAIN.
|
||||||
|
* PREPARING в БД отсутствует: до этого момента всё является локальным UI-состоянием.
|
||||||
|
*/
|
||||||
|
public KeyRotationSessionEntry createCopyingSession(KeyRotationSessionEntry entry) throws SQLException {
|
||||||
|
validateNewSession(entry);
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry created = createCopyingSession(c, entry);
|
||||||
|
c.commit();
|
||||||
|
return created;
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
if (e instanceof SQLException sql) throw sql;
|
||||||
|
throw new SQLException("Failed to create key rotation session", e);
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRotationSessionEntry createCopyingSession(Connection c, KeyRotationSessionEntry entry) throws SQLException {
|
||||||
|
validateNewSession(entry);
|
||||||
|
|
||||||
|
String currentStatus;
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT rotation_status
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
WHERE login = ?
|
||||||
|
FOR UPDATE
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, entry.getLogin());
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) throw new SQLException("Unknown login for key rotation: " + entry.getLogin());
|
||||||
|
currentStatus = rs.getString(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!KeyRotationStatus.NONE.name().equals(currentStatus)) {
|
||||||
|
throw new SQLException("Key rotation already active for login=" + entry.getLogin() + ", status=" + currentStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = entry.getCreatedAtMs() > 0 ? entry.getCreatedAtMs() : System.currentTimeMillis();
|
||||||
|
entry.setCreatedAtMs(now);
|
||||||
|
entry.setUpdatedAtMs(now);
|
||||||
|
entry.setStatus(KeyRotationStatus.COPYING_CHAIN);
|
||||||
|
if (entry.getComment() == null) entry.setComment("");
|
||||||
|
if (entry.getWalletMigrationStatus() == null) entry.setWalletMigrationStatus("PENDING");
|
||||||
|
if (entry.getMessageMigrationStatus() == null) entry.setMessageMigrationStatus("PENDING");
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO key_rotation_sessions (
|
||||||
|
login, status,
|
||||||
|
source_blockchain_name, candidate_blockchain_name,
|
||||||
|
old_root_key, old_blockchain_key, old_client_key,
|
||||||
|
new_root_key, new_blockchain_key, new_client_key,
|
||||||
|
fork_from_block, fork_from_hash,
|
||||||
|
source_tip_block, source_tip_hash,
|
||||||
|
reason_code, comment,
|
||||||
|
progress_current, progress_total,
|
||||||
|
pda_rotation_signature,
|
||||||
|
wallet_migration_status, message_migration_status,
|
||||||
|
last_error, last_error_at_ms, retry_count,
|
||||||
|
created_at_ms, updated_at_ms, completed_at_ms, aborted_at_ms
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
|
||||||
|
long id;
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||||
|
int i = 1;
|
||||||
|
ps.setString(i++, entry.getLogin());
|
||||||
|
ps.setString(i++, entry.getStatus().name());
|
||||||
|
ps.setString(i++, entry.getSourceBlockchainName());
|
||||||
|
ps.setString(i++, entry.getCandidateBlockchainName());
|
||||||
|
ps.setString(i++, entry.getOldRootKey());
|
||||||
|
ps.setString(i++, entry.getOldBlockchainKey());
|
||||||
|
ps.setString(i++, entry.getOldClientKey());
|
||||||
|
ps.setString(i++, entry.getNewRootKey());
|
||||||
|
ps.setString(i++, entry.getNewBlockchainKey());
|
||||||
|
ps.setString(i++, entry.getNewClientKey());
|
||||||
|
ps.setInt(i++, entry.getForkFromBlock());
|
||||||
|
ps.setBytes(i++, entry.getForkFromHash());
|
||||||
|
ps.setInt(i++, entry.getSourceTipBlock());
|
||||||
|
ps.setBytes(i++, entry.getSourceTipHash());
|
||||||
|
ps.setShort(i++, entry.getReasonCode());
|
||||||
|
ps.setString(i++, entry.getComment());
|
||||||
|
ps.setInt(i++, entry.getProgressCurrent());
|
||||||
|
ps.setInt(i++, entry.getProgressTotal());
|
||||||
|
ps.setString(i++, entry.getPdaRotationSignature());
|
||||||
|
ps.setString(i++, entry.getWalletMigrationStatus());
|
||||||
|
ps.setString(i++, entry.getMessageMigrationStatus());
|
||||||
|
ps.setString(i++, entry.getLastError());
|
||||||
|
if (entry.getLastErrorAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getLastErrorAtMs());
|
||||||
|
ps.setInt(i++, entry.getRetryCount());
|
||||||
|
ps.setLong(i++, entry.getCreatedAtMs());
|
||||||
|
ps.setLong(i++, entry.getUpdatedAtMs());
|
||||||
|
if (entry.getCompletedAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getCompletedAtMs());
|
||||||
|
if (entry.getAbortedAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getAbortedAtMs());
|
||||||
|
ps.executeUpdate();
|
||||||
|
try (ResultSet keys = ps.getGeneratedKeys()) {
|
||||||
|
if (!keys.next()) throw new SQLException("key_rotation_sessions insert returned no id");
|
||||||
|
id = keys.getLong(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE solana_user_pda_current
|
||||||
|
SET rotation_status = ?, rotation_session_id = ?
|
||||||
|
WHERE login = ? AND rotation_status = 'NONE'
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, KeyRotationStatus.COPYING_CHAIN.name());
|
||||||
|
ps.setLong(2, id);
|
||||||
|
ps.setString(3, entry.getLogin());
|
||||||
|
if (ps.executeUpdate() != 1) {
|
||||||
|
throw new SQLException("Failed to attach key rotation session to login=" + entry.getLogin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.setId(id);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRotationSessionEntry getById(long id) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return getById(c, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRotationSessionEntry getById(Connection c, long id) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(selectBase() + " WHERE id = ?")) {
|
||||||
|
ps.setLong(1, id);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? mapRow(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRotationSessionEntry getActiveByLogin(String login) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return getActiveByLogin(c, login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRotationSessionEntry getActiveByLogin(Connection c, String login) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(selectBase() + """
|
||||||
|
WHERE login = ?
|
||||||
|
AND status NOT IN ('COMPLETE', 'ABORTED')
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? mapRow(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает несколько сессий одного состояния для фоновых workers. */
|
||||||
|
public java.util.List<KeyRotationSessionEntry> listByStatus(KeyRotationStatus status, int limit) throws SQLException {
|
||||||
|
if (status == null || status == KeyRotationStatus.NONE) return java.util.List.of();
|
||||||
|
int safeLimit = Math.max(1, Math.min(limit, 100));
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement(selectBase() + """
|
||||||
|
WHERE status = ?
|
||||||
|
ORDER BY updated_at_ms, id
|
||||||
|
LIMIT ?
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, status.name());
|
||||||
|
ps.setInt(2, safeLimit);
|
||||||
|
java.util.List<KeyRotationSessionEntry> out = new java.util.ArrayList<>();
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Атомарный переход state machine. Смена статуса выполняется только из expected.
|
||||||
|
*/
|
||||||
|
public KeyRotationSessionEntry transition(long id,
|
||||||
|
KeyRotationStatus expected,
|
||||||
|
KeyRotationStatus next) throws SQLException {
|
||||||
|
if (expected == null || next == null || !expected.canTransitionTo(next)) {
|
||||||
|
throw new IllegalArgumentException("Forbidden key rotation transition: " + expected + " -> " + next);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
|
||||||
|
if (current == null) throw new SQLException("Unknown key rotation session id=" + id);
|
||||||
|
if (current.getStatus() != expected) {
|
||||||
|
throw new SQLException("Key rotation status mismatch: expected=" + expected + ", actual=" + current.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Long completed = current.getCompletedAtMs();
|
||||||
|
if (next == KeyRotationStatus.COMPLETE) completed = now;
|
||||||
|
Long aborted = current.getAbortedAtMs();
|
||||||
|
if (next == KeyRotationStatus.ABORTED) aborted = now;
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET status = ?, updated_at_ms = ?, completed_at_ms = ?, aborted_at_ms = ?
|
||||||
|
WHERE id = ? AND status = ?
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, next.name());
|
||||||
|
ps.setLong(2, now);
|
||||||
|
if (completed == null) ps.setNull(3, java.sql.Types.BIGINT); else ps.setLong(3, completed);
|
||||||
|
if (aborted == null) ps.setNull(4, java.sql.Types.BIGINT); else ps.setLong(4, aborted);
|
||||||
|
ps.setLong(5, id);
|
||||||
|
ps.setString(6, expected.name());
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent key rotation transition for id=" + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyRotationStatus userStatus = next.userStatus();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE solana_user_pda_current
|
||||||
|
SET rotation_status = ?, rotation_session_id = ?
|
||||||
|
WHERE login = ? AND rotation_session_id = ?
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, userStatus.name());
|
||||||
|
if (userStatus == KeyRotationStatus.NONE) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, id);
|
||||||
|
ps.setString(3, current.getLogin());
|
||||||
|
ps.setLong(4, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate-копии нужны только пока ротация активна. В Arweave они уже опубликованы,
|
||||||
|
// а в PostgreSQL после COMPLETE/ABORTED временные строки больше не нужны.
|
||||||
|
if (next.isTerminalSessionStatus()) {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(
|
||||||
|
"DELETE FROM key_rotation_candidate_blocks WHERE rotation_session_id = ?")) {
|
||||||
|
ps.setLong(1, id);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.commit();
|
||||||
|
current.setStatus(next);
|
||||||
|
current.setUpdatedAtMs(now);
|
||||||
|
current.setCompletedAtMs(completed);
|
||||||
|
current.setAbortedAtMs(aborted);
|
||||||
|
return current;
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
if (e instanceof SQLException sql) throw sql;
|
||||||
|
throw new SQLException("Failed key rotation transition", e);
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateProgress(long id, int current, int total) throws SQLException {
|
||||||
|
if (current < 0 || total < 0 || current > total) {
|
||||||
|
throw new IllegalArgumentException("Invalid rotation progress: " + current + "/" + total);
|
||||||
|
}
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET progress_current = ?, progress_total = ?, updated_at_ms = ?
|
||||||
|
WHERE id = ? AND status = 'COPYING_CHAIN'
|
||||||
|
""")) {
|
||||||
|
ps.setInt(1, current);
|
||||||
|
ps.setInt(2, total);
|
||||||
|
ps.setLong(3, System.currentTimeMillis());
|
||||||
|
ps.setLong(4, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Rotation progress can only be updated in COPYING_CHAIN, id=" + id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Атомарно фиксирует tx signature и переводит CHAIN_READY -> ROTATING_PDA.
|
||||||
|
* Это точка, после которой Abort запрещён: отправленная Solana-транзакция может подтвердиться позже.
|
||||||
|
*/
|
||||||
|
public KeyRotationSessionEntry beginPdaRotation(long id, String signature) throws SQLException {
|
||||||
|
if (signature == null || signature.isBlank()) throw new IllegalArgumentException("signature is empty");
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
|
||||||
|
if (current == null) throw new SQLException("Unknown key rotation session id=" + id);
|
||||||
|
if (current.getStatus() != KeyRotationStatus.CHAIN_READY) {
|
||||||
|
throw new SQLException("PDA rotation requires CHAIN_READY, actual=" + current.getStatus());
|
||||||
|
}
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET status = 'ROTATING_PDA', pda_rotation_signature = ?, updated_at_ms = ?,
|
||||||
|
last_error = NULL, last_error_at_ms = NULL
|
||||||
|
WHERE id = ? AND status = 'CHAIN_READY'
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, signature);
|
||||||
|
ps.setLong(2, now);
|
||||||
|
ps.setLong(3, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation start for id=" + id);
|
||||||
|
}
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE solana_user_pda_current
|
||||||
|
SET rotation_status = 'ROTATING_PDA', rotation_session_id = ?
|
||||||
|
WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'CHAIN_READY'
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, id);
|
||||||
|
ps.setString(2, current.getLogin());
|
||||||
|
ps.setLong(3, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin());
|
||||||
|
}
|
||||||
|
c.commit();
|
||||||
|
current.setStatus(KeyRotationStatus.ROTATING_PDA);
|
||||||
|
current.setPdaRotationSignature(signature);
|
||||||
|
current.setUpdatedAtMs(now);
|
||||||
|
current.setLastError(null);
|
||||||
|
current.setLastErrorAtMs(null);
|
||||||
|
return current;
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
if (e instanceof SQLException sql) throw sql;
|
||||||
|
throw new SQLException("Failed to begin PDA rotation", e);
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Если Solana sync уже записал в current-PDA весь ожидаемый новый набор ключей,
|
||||||
|
* атомарно переводит ROTATING_PDA -> PDA_ROTATED. Иначе возвращает null.
|
||||||
|
*/
|
||||||
|
public KeyRotationSessionEntry tryMarkPdaRotatedFromCurrentState(long id) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
|
||||||
|
if (current == null || current.getStatus() != KeyRotationStatus.ROTATING_PDA) {
|
||||||
|
c.rollback();
|
||||||
|
return current != null && current.getStatus() == KeyRotationStatus.PDA_ROTATED ? current : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String root;
|
||||||
|
String blockchain;
|
||||||
|
String client;
|
||||||
|
String blockchainName;
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT root_key, blockchain_key, client_key, blockchain_name
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
WHERE login = ?
|
||||||
|
FOR UPDATE
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, current.getLogin());
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) { c.rollback(); return null; }
|
||||||
|
root = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("root_key"));
|
||||||
|
blockchain = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key"));
|
||||||
|
client = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("client_key"));
|
||||||
|
blockchainName = rs.getString("blockchain_name");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current.getNewRootKey().equals(root)
|
||||||
|
|| !current.getNewBlockchainKey().equals(blockchain)
|
||||||
|
|| !current.getNewClientKey().equals(client)
|
||||||
|
|| !current.getCandidateBlockchainName().equals(blockchainName)) {
|
||||||
|
c.rollback();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET status = 'PDA_ROTATED', updated_at_ms = ?, last_error = NULL, last_error_at_ms = NULL
|
||||||
|
WHERE id = ? AND status = 'ROTATING_PDA'
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, now);
|
||||||
|
ps.setLong(2, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation confirm for id=" + id);
|
||||||
|
}
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE solana_user_pda_current
|
||||||
|
SET rotation_status = 'PDA_ROTATED', rotation_session_id = ?
|
||||||
|
WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'ROTATING_PDA'
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, id);
|
||||||
|
ps.setString(2, current.getLogin());
|
||||||
|
ps.setLong(3, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to current PDA login=" + current.getLogin());
|
||||||
|
}
|
||||||
|
c.commit();
|
||||||
|
current.setStatus(KeyRotationStatus.PDA_ROTATED);
|
||||||
|
current.setUpdatedAtMs(now);
|
||||||
|
current.setLastError(null);
|
||||||
|
current.setLastErrorAtMs(null);
|
||||||
|
return current;
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
if (e instanceof SQLException sql) throw sql;
|
||||||
|
throw new SQLException("Failed to confirm rotated PDA", e);
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPdaRotationSignature(long id, String signature) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET pda_rotation_signature = ?, updated_at_ms = ?
|
||||||
|
WHERE id = ? AND status = 'ROTATING_PDA'
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, signature);
|
||||||
|
ps.setLong(2, System.currentTimeMillis());
|
||||||
|
ps.setLong(3, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("PDA signature requires ROTATING_PDA, id=" + id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фиксирует результат необязательного этапа wallet/DM.
|
||||||
|
* Имя колонки выбирается только из закрытого списка — внешние значения в SQL не подставляются.
|
||||||
|
*/
|
||||||
|
public void setMigrationSubStatus(long id, String column, String value) throws SQLException {
|
||||||
|
String safeColumn = switch (String.valueOf(column)) {
|
||||||
|
case "wallet_migration_status" -> "wallet_migration_status";
|
||||||
|
case "message_migration_status" -> "message_migration_status";
|
||||||
|
default -> throw new IllegalArgumentException("Unsupported migration status column: " + column);
|
||||||
|
};
|
||||||
|
String safeValue = switch (String.valueOf(value)) {
|
||||||
|
case "PENDING", "COMPLETE", "SKIPPED", "NOT_IMPLEMENTED" -> String.valueOf(value);
|
||||||
|
default -> throw new IllegalArgumentException("Unsupported migration status value: " + value);
|
||||||
|
};
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement(
|
||||||
|
"UPDATE key_rotation_sessions SET " + safeColumn + " = ?, updated_at_ms = ? WHERE id = ?")) {
|
||||||
|
ps.setString(1, safeValue);
|
||||||
|
ps.setLong(2, System.currentTimeMillis());
|
||||||
|
ps.setLong(3, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Unknown key rotation session id=" + id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordError(long id, String error) throws SQLException {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET last_error = ?, last_error_at_ms = ?, retry_count = retry_count + 1, updated_at_ms = ?
|
||||||
|
WHERE id = ? AND status NOT IN ('COMPLETE', 'ABORTED')
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, error);
|
||||||
|
ps.setLong(2, now);
|
||||||
|
ps.setLong(3, now);
|
||||||
|
ps.setLong(4, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Cannot record error for inactive rotation id=" + id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearError(long id) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET last_error = NULL, last_error_at_ms = NULL, updated_at_ms = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, System.currentTimeMillis());
|
||||||
|
ps.setLong(2, id);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private KeyRotationSessionEntry getByIdForUpdate(Connection c, long id) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(selectBase() + " WHERE id = ? FOR UPDATE")) {
|
||||||
|
ps.setLong(1, id);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? mapRow(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String selectBase() {
|
||||||
|
return """
|
||||||
|
SELECT
|
||||||
|
id, login, status,
|
||||||
|
source_blockchain_name, candidate_blockchain_name,
|
||||||
|
old_root_key, old_blockchain_key, old_client_key,
|
||||||
|
new_root_key, new_blockchain_key, new_client_key,
|
||||||
|
fork_from_block, fork_from_hash,
|
||||||
|
source_tip_block, source_tip_hash,
|
||||||
|
reason_code, comment,
|
||||||
|
progress_current, progress_total,
|
||||||
|
pda_rotation_signature,
|
||||||
|
wallet_migration_status, message_migration_status,
|
||||||
|
last_error, last_error_at_ms, retry_count,
|
||||||
|
created_at_ms, updated_at_ms, completed_at_ms, aborted_at_ms
|
||||||
|
FROM key_rotation_sessions
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyRotationSessionEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
KeyRotationSessionEntry e = new KeyRotationSessionEntry();
|
||||||
|
e.setId(rs.getLong("id"));
|
||||||
|
e.setLogin(rs.getString("login"));
|
||||||
|
e.setStatus(KeyRotationStatus.valueOf(rs.getString("status")));
|
||||||
|
e.setSourceBlockchainName(rs.getString("source_blockchain_name"));
|
||||||
|
e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name"));
|
||||||
|
e.setOldRootKey(rs.getString("old_root_key"));
|
||||||
|
e.setOldBlockchainKey(rs.getString("old_blockchain_key"));
|
||||||
|
e.setOldClientKey(rs.getString("old_client_key"));
|
||||||
|
e.setNewRootKey(rs.getString("new_root_key"));
|
||||||
|
e.setNewBlockchainKey(rs.getString("new_blockchain_key"));
|
||||||
|
e.setNewClientKey(rs.getString("new_client_key"));
|
||||||
|
e.setForkFromBlock(rs.getInt("fork_from_block"));
|
||||||
|
e.setForkFromHash(rs.getBytes("fork_from_hash"));
|
||||||
|
e.setSourceTipBlock(rs.getInt("source_tip_block"));
|
||||||
|
e.setSourceTipHash(rs.getBytes("source_tip_hash"));
|
||||||
|
e.setReasonCode(rs.getShort("reason_code"));
|
||||||
|
e.setComment(rs.getString("comment"));
|
||||||
|
e.setProgressCurrent(rs.getInt("progress_current"));
|
||||||
|
e.setProgressTotal(rs.getInt("progress_total"));
|
||||||
|
e.setPdaRotationSignature(rs.getString("pda_rotation_signature"));
|
||||||
|
e.setWalletMigrationStatus(rs.getString("wallet_migration_status"));
|
||||||
|
e.setMessageMigrationStatus(rs.getString("message_migration_status"));
|
||||||
|
e.setLastError(rs.getString("last_error"));
|
||||||
|
long lastErrorAt = rs.getLong("last_error_at_ms");
|
||||||
|
e.setLastErrorAtMs(rs.wasNull() ? null : lastErrorAt);
|
||||||
|
e.setRetryCount(rs.getInt("retry_count"));
|
||||||
|
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||||
|
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||||
|
long completed = rs.getLong("completed_at_ms");
|
||||||
|
e.setCompletedAtMs(rs.wasNull() ? null : completed);
|
||||||
|
long aborted = rs.getLong("aborted_at_ms");
|
||||||
|
e.setAbortedAtMs(rs.wasNull() ? null : aborted);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateNewSession(KeyRotationSessionEntry e) {
|
||||||
|
if (e == null) throw new IllegalArgumentException("entry is null");
|
||||||
|
if (blank(e.getLogin())) throw new IllegalArgumentException("login is required");
|
||||||
|
if (blank(e.getSourceBlockchainName()) || blank(e.getCandidateBlockchainName())) {
|
||||||
|
throw new IllegalArgumentException("source/candidate blockchain names are required");
|
||||||
|
}
|
||||||
|
if (blank(e.getOldRootKey()) || blank(e.getOldBlockchainKey()) || blank(e.getOldClientKey())
|
||||||
|
|| blank(e.getNewRootKey()) || blank(e.getNewBlockchainKey()) || blank(e.getNewClientKey())) {
|
||||||
|
throw new IllegalArgumentException("all old/new public keys are required");
|
||||||
|
}
|
||||||
|
if (e.getForkFromHash() == null || e.getForkFromHash().length != 32) {
|
||||||
|
throw new IllegalArgumentException("forkFromHash must be 32 bytes");
|
||||||
|
}
|
||||||
|
if (e.getSourceTipHash() == null || e.getSourceTipHash().length != 32) {
|
||||||
|
throw new IllegalArgumentException("sourceTipHash must be 32 bytes");
|
||||||
|
}
|
||||||
|
if (e.getForkFromBlock() < 0 || e.getSourceTipBlock() < e.getForkFromBlock()) {
|
||||||
|
throw new IllegalArgumentException("invalid fork/source tip block numbers");
|
||||||
|
}
|
||||||
|
if (e.getReasonCode() < 1 || e.getReasonCode() > 4) {
|
||||||
|
throw new IllegalArgumentException("reasonCode must be 1..4");
|
||||||
|
}
|
||||||
|
String comment = e.getComment() == null ? "" : e.getComment();
|
||||||
|
if (comment.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1024) {
|
||||||
|
throw new IllegalArgumentException("comment must be <= 1024 UTF-8 bytes");
|
||||||
|
}
|
||||||
|
if (e.getProgressCurrent() < 0 || e.getProgressTotal() < e.getProgressCurrent()) {
|
||||||
|
throw new IllegalArgumentException("invalid initial progress");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean blank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-1
@@ -37,6 +37,27 @@ public final class SolanaUserPdaCurrentDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public SolanaUserPdaCurrentEntry getByLogin(String login) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
login,
|
||||||
|
blockchain_name,
|
||||||
|
blockchain_key,
|
||||||
|
paid_limit_bytes
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
WHERE LOWER(login) = LOWER(?)
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
return mapRow(rs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
@@ -60,11 +81,36 @@ public final class SolanaUserPdaCurrentDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a user/fork by the 32-byte Ed25519 blockchain key used as ANS-104 owner. */
|
||||||
|
public SolanaUserPdaCurrentEntry getByBlockchainKey(byte[] owner32) throws SQLException {
|
||||||
|
if (owner32 == null || owner32.length != 32) return null;
|
||||||
|
String wanted = KeyEncodingUtil.encodeBase58(owner32);
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
u.login,
|
||||||
|
u.login || '-' || LPAD(((fork_item.value ->> 'forkIndex')::integer + 1)::text, 3, '0') AS blockchain_name,
|
||||||
|
fork_item.value ->> 'blockchainKey' AS blockchain_key,
|
||||||
|
(fork_item.value ->> 'paidLimitBytes')::bigint AS paid_limit_bytes
|
||||||
|
FROM solana_user_pda_current u
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(NULLIF(u.blockchain_forks_json, ''), '[]')::jsonb) AS fork_item(value)
|
||||||
|
WHERE fork_item.value ->> 'blockchainKey' = ?
|
||||||
|
AND ((fork_item.value ->> 'forkIndex')::integer + 1) BETWEEN 1 AND 999
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, wanted);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
return mapRow(rs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException {
|
private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry();
|
SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry();
|
||||||
entry.setLogin(rs.getString("login"));
|
entry.setLogin(rs.getString("login"));
|
||||||
entry.setBlockchainName(rs.getString("blockchain_name"));
|
entry.setBlockchainName(rs.getString("blockchain_name"));
|
||||||
entry.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key")));
|
entry.setBlockchainKey(KeyEncodingUtil.base58KeyToBase64_32(rs.getString("blockchain_key")));
|
||||||
entry.setPaidLimitBytes(rs.getLong("paid_limit_bytes"));
|
entry.setPaidLimitBytes(rs.getLong("paid_limit_bytes"));
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,11 +106,12 @@ public final class SubscriptionsDAO {
|
|||||||
WITH subs AS (
|
WITH subs AS (
|
||||||
-- 1) FOLLOW-каналы
|
-- 1) FOLLOW-каналы
|
||||||
SELECT
|
SELECT
|
||||||
cs.to_login AS channel_login,
|
cs.to_login AS channel_login,
|
||||||
cs.to_bch_name AS channel_bch_name
|
cn.owner_bch_name AS channel_bch_name
|
||||||
FROM connections_state cs
|
FROM connections_state cs
|
||||||
WHERE cs.login = ?
|
JOIN channel_names_state cn ON LOWER(cn.owner_login)=LOWER(cs.to_login)
|
||||||
AND cs.rel_type = ?
|
AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
||||||
|
WHERE cs.login = ? AND cs.rel_type = ?
|
||||||
|
|
||||||
UNION
|
UNION
|
||||||
|
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
|
||||||
|
public final class UserNotificationSeenStateDAO {
|
||||||
|
private static final UserNotificationSeenStateDAO INSTANCE = new UserNotificationSeenStateDAO();
|
||||||
|
private UserNotificationSeenStateDAO() {}
|
||||||
|
public static UserNotificationSeenStateDAO getInstance() { return INSTANCE; }
|
||||||
|
|
||||||
|
public long getSeenAt(Connection c, String login, String category) throws Exception {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("SELECT seen_at_ms FROM user_notification_seen_state WHERE owner_login=? AND category=?")) {
|
||||||
|
ps.setString(1, login); ps.setString(2, category);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long advance(Connection c, String login, String category, long seenAtMs, long signedAtMs, byte[] signedBlob) throws Exception {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO user_notification_seen_state(owner_login, category, seen_at_ms, signed_blob, signed_at_ms, updated_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(owner_login, category) DO UPDATE SET
|
||||||
|
seen_at_ms=EXCLUDED.seen_at_ms, signed_blob=EXCLUDED.signed_blob,
|
||||||
|
signed_at_ms=EXCLUDED.signed_at_ms, updated_at_ms=EXCLUDED.updated_at_ms
|
||||||
|
WHERE user_notification_seen_state.seen_at_ms < EXCLUDED.seen_at_ms
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login); ps.setString(2, category); ps.setLong(3, seenAtMs);
|
||||||
|
ps.setBytes(4, signedBlob); ps.setLong(5, signedAtMs); ps.setLong(6, System.currentTimeMillis());
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
return getSeenAt(c, login, category);
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -84,6 +84,33 @@ public final class UserNotificationsStateDAO {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<UserNotificationEntry> listVisible(Connection c, String ownerLogin, String kind, long seenAtMs, long cutoffMs) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||||
|
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||||
|
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||||
|
FROM user_notifications_state
|
||||||
|
WHERE owner_login = ? AND notification_kind = ?
|
||||||
|
AND (created_at_ms > ? OR created_at_ms >= ?)
|
||||||
|
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||||
|
""";
|
||||||
|
List<UserNotificationEntry> out = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||||
|
ps.setLong(4, Math.max(0, cutoffMs));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countUnseen(Connection c, String ownerLogin, String kind, long seenAtMs) throws SQLException {
|
||||||
|
String sql = "SELECT COUNT(*) FROM user_notifications_state WHERE owner_login = ? AND notification_kind = ? AND created_at_ms > ?";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
UserNotificationEntry e = new UserNotificationEntry();
|
UserNotificationEntry e = new UserNotificationEntry();
|
||||||
e.setOwnerLogin(rs.getString("owner_login"));
|
e.setOwnerLogin(rs.getString("owner_login"));
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import java.sql.SQLException;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import shine.db.MsgSubType;
|
||||||
|
|
||||||
/** Fast denormalized projection for user cards shown in dialogs/relations/profile lists. */
|
/** Fast denormalized projection for user cards shown in dialogs/relations/profile lists. */
|
||||||
public final class UserProfileStateDAO {
|
public final class UserProfileStateDAO {
|
||||||
private static volatile UserProfileStateDAO instance;
|
private static volatile UserProfileStateDAO instance;
|
||||||
@@ -31,6 +33,28 @@ public final class UserProfileStateDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective outgoing social relation with priority close_friend > friend > contact > none.
|
||||||
|
*
|
||||||
|
* Use the canonical relation lookup so all UI paths share the same normalized login matching.
|
||||||
|
* Stage 2 connections_state identity is logical and no longer contains a physical target fork.
|
||||||
|
*/
|
||||||
|
public String getEffectiveRelationType(Connection c, String ownerLogin, String targetLogin) throws SQLException {
|
||||||
|
if (ownerLogin == null || ownerLogin.isBlank() || targetLogin == null || targetLogin.isBlank()) return "none";
|
||||||
|
ConnectionsStateDAO relations = ConnectionsStateDAO.getInstance();
|
||||||
|
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
|
||||||
|
return "close_friend";
|
||||||
|
}
|
||||||
|
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_FRIEND)) {
|
||||||
|
return "friend";
|
||||||
|
}
|
||||||
|
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CONTACT)) {
|
||||||
|
return "contact";
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
public List<RelationCard> listRelations(Connection c, String ownerLogin, String listType, int limit, int offset) throws SQLException {
|
public List<RelationCard> listRelations(Connection c, String ownerLogin, String listType, int limit, int offset) throws SQLException {
|
||||||
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 100 : limit, 500));
|
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 100 : limit, 500));
|
||||||
int safeOffset = Math.max(0, offset);
|
int safeOffset = Math.max(0, offset);
|
||||||
@@ -94,14 +118,14 @@ public final class UserProfileStateDAO {
|
|||||||
sql="""
|
sql="""
|
||||||
SELECT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
SELECT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
||||||
FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(?) AND cn.channel_type_code=1
|
FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(?) AND cn.channel_type_code=1
|
||||||
ORDER BY LOWER(cn.display_name), cn.slug LIMIT ? OFFSET ?
|
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
|
||||||
""";
|
""";
|
||||||
} else if ("following".equals(mode)) {
|
} else if ("following".equals(mode)) {
|
||||||
sql="""
|
sql="""
|
||||||
SELECT DISTINCT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
SELECT DISTINCT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
|
||||||
FROM connections_state cs JOIN channel_names_state cn ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
FROM connections_state cs JOIN channel_names_state cn ON LOWER(cn.owner_login)=LOWER(cs.to_login) AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
||||||
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30 AND cn.channel_type_code=1
|
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30 AND cn.channel_type_code=1
|
||||||
ORDER BY LOWER(cn.display_name), cn.slug LIMIT ? OFFSET ?
|
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
|
||||||
""";
|
""";
|
||||||
} else throw new IllegalArgumentException("Unsupported channel mode: "+mode);
|
} else throw new IllegalArgumentException("Unsupported channel mode: "+mode);
|
||||||
List<ChannelCard> out=new ArrayList<>();
|
List<ChannelCard> out=new ArrayList<>();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package shine.db.entities;
|
|||||||
* Запись блока (таблица blocks) — обновлённая модель под новый формат.
|
* Запись блока (таблица blocks) — обновлённая модель под новый формат.
|
||||||
*
|
*
|
||||||
* Храним:
|
* Храним:
|
||||||
* - login, bch_name (как было в проекте, чтобы не ломать общую БД)
|
* - login + fork_number (physical fork identity; bch_name is derived only for display/API)
|
||||||
* - block_number (глобальный номер в этой цепочке)
|
* - block_number (глобальный номер в этой цепочке)
|
||||||
* - block_bytes (полный блок: preimage + signature)
|
* - block_bytes (полный блок: preimage + signature)
|
||||||
* - block_hash (32 байта вычисленный SHA-256(preimage))
|
* - block_hash (32 байта вычисленный SHA-256(preimage))
|
||||||
@@ -21,7 +21,7 @@ package shine.db.entities;
|
|||||||
public class BlockEntry {
|
public class BlockEntry {
|
||||||
|
|
||||||
private String login;
|
private String login;
|
||||||
private String bchName;
|
private int forkNumber;
|
||||||
|
|
||||||
private int blockNumber;
|
private int blockNumber;
|
||||||
|
|
||||||
@@ -31,12 +31,15 @@ public class BlockEntry {
|
|||||||
private byte[] blockBytes;
|
private byte[] blockBytes;
|
||||||
|
|
||||||
private String toLogin;
|
private String toLogin;
|
||||||
private String toBchName;
|
private Integer toForkNumber;
|
||||||
private Integer toBlockNumber;
|
private Integer toBlockNumber;
|
||||||
private byte[] toBlockHash;
|
private byte[] toBlockHash;
|
||||||
|
|
||||||
private byte[] blockHash;
|
private byte[] blockHash;
|
||||||
private byte[] blockSignature;
|
private byte[] blockSignature;
|
||||||
|
private byte[] dataItemId;
|
||||||
|
private boolean arweavePublishPending;
|
||||||
|
private Long arweavePublishedAtMs;
|
||||||
|
|
||||||
private Integer editedByBlockNumber;
|
private Integer editedByBlockNumber;
|
||||||
|
|
||||||
@@ -52,8 +55,14 @@ public class BlockEntry {
|
|||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = login; }
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
|
||||||
public String getBchName() { return bchName; }
|
public int getForkNumber() { return forkNumber; }
|
||||||
public void setBchName(String bchName) { this.bchName = bchName; }
|
public void setForkNumber(int forkNumber) { if (forkNumber < 1 || forkNumber > 999) throw new IllegalArgumentException("forkNumber must be 1..999"); this.forkNumber = forkNumber; }
|
||||||
|
|
||||||
|
/** Compatibility/display helper. DB does not store bch_name anymore. */
|
||||||
|
public String getBchName() {
|
||||||
|
if (login == null || login.isBlank() || forkNumber < 1 || forkNumber > 999) return null;
|
||||||
|
return login + "-" + String.format("%03d", forkNumber);
|
||||||
|
}
|
||||||
|
|
||||||
public int getBlockNumber() { return blockNumber; }
|
public int getBlockNumber() { return blockNumber; }
|
||||||
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||||
@@ -70,8 +79,14 @@ public class BlockEntry {
|
|||||||
public String getToLogin() { return toLogin; }
|
public String getToLogin() { return toLogin; }
|
||||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||||
|
|
||||||
public String getToBchName() { return toBchName; }
|
/** Derived historical display value only; never part of target identity. */
|
||||||
public void setToBchName(String toBchName) { this.toBchName = toBchName; }
|
public String getToBchName() {
|
||||||
|
if (toLogin == null || toLogin.isBlank() || toForkNumber == null || toForkNumber < 1 || toForkNumber > 999) return null;
|
||||||
|
return toLogin + "-" + String.format("%03d", toForkNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getToForkNumber() { return toForkNumber; }
|
||||||
|
public void setToForkNumber(Integer toForkNumber) { if (toForkNumber != null && (toForkNumber < 0 || toForkNumber > 999)) throw new IllegalArgumentException("toForkNumber must be 0..999"); this.toForkNumber = toForkNumber; }
|
||||||
|
|
||||||
public Integer getToBlockNumber() { return toBlockNumber; }
|
public Integer getToBlockNumber() { return toBlockNumber; }
|
||||||
public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; }
|
public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; }
|
||||||
@@ -85,6 +100,13 @@ public class BlockEntry {
|
|||||||
public byte[] getBlockSignature() { return blockSignature; }
|
public byte[] getBlockSignature() { return blockSignature; }
|
||||||
public void setBlockSignature(byte[] blockSignature) { this.blockSignature = blockSignature; }
|
public void setBlockSignature(byte[] blockSignature) { this.blockSignature = blockSignature; }
|
||||||
|
|
||||||
|
public byte[] getDataItemId() { return dataItemId; }
|
||||||
|
public void setDataItemId(byte[] dataItemId) { this.dataItemId = dataItemId; }
|
||||||
|
public boolean isArweavePublishPending() { return arweavePublishPending; }
|
||||||
|
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
|
||||||
|
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
|
||||||
|
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
|
||||||
|
|
||||||
public Integer getEditedByBlockNumber() { return editedByBlockNumber; }
|
public Integer getEditedByBlockNumber() { return editedByBlockNumber; }
|
||||||
public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; }
|
public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; }
|
||||||
|
|
||||||
|
|||||||
+17
-3
@@ -16,8 +16,8 @@ import java.util.Base64;
|
|||||||
*/
|
*/
|
||||||
public final class BlockchainStateEntry {
|
public final class BlockchainStateEntry {
|
||||||
|
|
||||||
private String blockchainName;
|
|
||||||
private String login;
|
private String login;
|
||||||
|
private int forkNumber;
|
||||||
|
|
||||||
private String blockchainKey; // Base64(32)
|
private String blockchainKey; // Base64(32)
|
||||||
|
|
||||||
@@ -31,8 +31,22 @@ public final class BlockchainStateEntry {
|
|||||||
|
|
||||||
public BlockchainStateEntry() {}
|
public BlockchainStateEntry() {}
|
||||||
|
|
||||||
public String getBlockchainName() { return blockchainName; }
|
public String getBlockchainName() {
|
||||||
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
if (login == null || login.isBlank() || forkNumber < 1 || forkNumber > 999) return null;
|
||||||
|
return login + "-" + String.format("%03d", forkNumber);
|
||||||
|
}
|
||||||
|
/** Compatibility setter for API/import code; DB stores login + forkNumber. */
|
||||||
|
public void setBlockchainName(String blockchainName) {
|
||||||
|
if (blockchainName == null) return;
|
||||||
|
int dash=blockchainName.lastIndexOf('-');
|
||||||
|
if (dash <= 0 || blockchainName.length()-dash-1 != 3) throw new IllegalArgumentException("Bad blockchainName: "+blockchainName);
|
||||||
|
int parsedFork=Integer.parseInt(blockchainName.substring(dash+1));
|
||||||
|
if (parsedFork < 1 || parsedFork > 999) throw new IllegalArgumentException("forkNumber must be 1..999");
|
||||||
|
this.login=blockchainName.substring(0,dash);
|
||||||
|
this.forkNumber=parsedFork;
|
||||||
|
}
|
||||||
|
public int getForkNumber() { return forkNumber; }
|
||||||
|
public void setForkNumber(int forkNumber) { if (forkNumber < 1 || forkNumber > 999) throw new IllegalArgumentException("forkNumber must be 1..999"); this.forkNumber = forkNumber; }
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = login; }
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate-блок будущего fork во время смены ключей.
|
||||||
|
*
|
||||||
|
* Эти записи намеренно не попадают в обычную таблицу blocks и поэтому
|
||||||
|
* не влияют на лайки, ответы, каналы и другие materialized state до
|
||||||
|
* финального переключения активного fork.
|
||||||
|
*/
|
||||||
|
public final class KeyRotationCandidateBlockEntry {
|
||||||
|
private long id;
|
||||||
|
private long rotationSessionId;
|
||||||
|
private String login;
|
||||||
|
private String candidateBlockchainName;
|
||||||
|
private int blockNumber;
|
||||||
|
private byte[] blockHash;
|
||||||
|
private byte[] dataItemId;
|
||||||
|
private byte[] blockBytes;
|
||||||
|
private boolean arweavePublishPending;
|
||||||
|
private Long arweavePublishedAtMs;
|
||||||
|
private long createdAtMs;
|
||||||
|
|
||||||
|
public long getId() { return id; }
|
||||||
|
public void setId(long id) { this.id = id; }
|
||||||
|
public long getRotationSessionId() { return rotationSessionId; }
|
||||||
|
public void setRotationSessionId(long rotationSessionId) { this.rotationSessionId = rotationSessionId; }
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public String getCandidateBlockchainName() { return candidateBlockchainName; }
|
||||||
|
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
|
||||||
|
public int getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
public byte[] getBlockHash() { return blockHash; }
|
||||||
|
public void setBlockHash(byte[] blockHash) { this.blockHash = blockHash; }
|
||||||
|
public byte[] getDataItemId() { return dataItemId; }
|
||||||
|
public void setDataItemId(byte[] dataItemId) { this.dataItemId = dataItemId; }
|
||||||
|
public byte[] getBlockBytes() { return blockBytes; }
|
||||||
|
public void setBlockBytes(byte[] blockBytes) { this.blockBytes = blockBytes; }
|
||||||
|
public boolean isArweavePublishPending() { return arweavePublishPending; }
|
||||||
|
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
|
||||||
|
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
|
||||||
|
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
|
||||||
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публичное/runtime-состояние одной смены ключей.
|
||||||
|
* Приватные ключи и пароли в этой сущности не хранятся никогда.
|
||||||
|
*/
|
||||||
|
public final class KeyRotationSessionEntry {
|
||||||
|
private long id;
|
||||||
|
private String login;
|
||||||
|
private KeyRotationStatus status;
|
||||||
|
private String sourceBlockchainName;
|
||||||
|
private String candidateBlockchainName;
|
||||||
|
private String oldRootKey;
|
||||||
|
private String oldBlockchainKey;
|
||||||
|
private String oldClientKey;
|
||||||
|
private String newRootKey;
|
||||||
|
private String newBlockchainKey;
|
||||||
|
private String newClientKey;
|
||||||
|
private int forkFromBlock;
|
||||||
|
private byte[] forkFromHash;
|
||||||
|
private int sourceTipBlock;
|
||||||
|
private byte[] sourceTipHash;
|
||||||
|
private short reasonCode;
|
||||||
|
private String comment;
|
||||||
|
private int progressCurrent;
|
||||||
|
private int progressTotal;
|
||||||
|
private String pdaRotationSignature;
|
||||||
|
private String walletMigrationStatus;
|
||||||
|
private String messageMigrationStatus;
|
||||||
|
private String lastError;
|
||||||
|
private Long lastErrorAtMs;
|
||||||
|
private int retryCount;
|
||||||
|
private long createdAtMs;
|
||||||
|
private long updatedAtMs;
|
||||||
|
private Long completedAtMs;
|
||||||
|
private Long abortedAtMs;
|
||||||
|
|
||||||
|
public long getId() { return id; }
|
||||||
|
public void setId(long id) { this.id = id; }
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public KeyRotationStatus getStatus() { return status; }
|
||||||
|
public void setStatus(KeyRotationStatus status) { this.status = status; }
|
||||||
|
public String getSourceBlockchainName() { return sourceBlockchainName; }
|
||||||
|
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
|
||||||
|
public String getCandidateBlockchainName() { return candidateBlockchainName; }
|
||||||
|
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
|
||||||
|
public String getOldRootKey() { return oldRootKey; }
|
||||||
|
public void setOldRootKey(String oldRootKey) { this.oldRootKey = oldRootKey; }
|
||||||
|
public String getOldBlockchainKey() { return oldBlockchainKey; }
|
||||||
|
public void setOldBlockchainKey(String oldBlockchainKey) { this.oldBlockchainKey = oldBlockchainKey; }
|
||||||
|
public String getOldClientKey() { return oldClientKey; }
|
||||||
|
public void setOldClientKey(String oldClientKey) { this.oldClientKey = oldClientKey; }
|
||||||
|
public String getNewRootKey() { return newRootKey; }
|
||||||
|
public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; }
|
||||||
|
public String getNewBlockchainKey() { return newBlockchainKey; }
|
||||||
|
public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; }
|
||||||
|
public String getNewClientKey() { return newClientKey; }
|
||||||
|
public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; }
|
||||||
|
public int getForkFromBlock() { return forkFromBlock; }
|
||||||
|
public void setForkFromBlock(int forkFromBlock) { this.forkFromBlock = forkFromBlock; }
|
||||||
|
public byte[] getForkFromHash() { return forkFromHash; }
|
||||||
|
public void setForkFromHash(byte[] forkFromHash) { this.forkFromHash = forkFromHash; }
|
||||||
|
public int getSourceTipBlock() { return sourceTipBlock; }
|
||||||
|
public void setSourceTipBlock(int sourceTipBlock) { this.sourceTipBlock = sourceTipBlock; }
|
||||||
|
public byte[] getSourceTipHash() { return sourceTipHash; }
|
||||||
|
public void setSourceTipHash(byte[] sourceTipHash) { this.sourceTipHash = sourceTipHash; }
|
||||||
|
public short getReasonCode() { return reasonCode; }
|
||||||
|
public void setReasonCode(short reasonCode) { this.reasonCode = reasonCode; }
|
||||||
|
public String getComment() { return comment; }
|
||||||
|
public void setComment(String comment) { this.comment = comment; }
|
||||||
|
public int getProgressCurrent() { return progressCurrent; }
|
||||||
|
public void setProgressCurrent(int progressCurrent) { this.progressCurrent = progressCurrent; }
|
||||||
|
public int getProgressTotal() { return progressTotal; }
|
||||||
|
public void setProgressTotal(int progressTotal) { this.progressTotal = progressTotal; }
|
||||||
|
public String getPdaRotationSignature() { return pdaRotationSignature; }
|
||||||
|
public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; }
|
||||||
|
public String getWalletMigrationStatus() { return walletMigrationStatus; }
|
||||||
|
public void setWalletMigrationStatus(String walletMigrationStatus) { this.walletMigrationStatus = walletMigrationStatus; }
|
||||||
|
public String getMessageMigrationStatus() { return messageMigrationStatus; }
|
||||||
|
public void setMessageMigrationStatus(String messageMigrationStatus) { this.messageMigrationStatus = messageMigrationStatus; }
|
||||||
|
public String getLastError() { return lastError; }
|
||||||
|
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||||
|
public Long getLastErrorAtMs() { return lastErrorAtMs; }
|
||||||
|
public void setLastErrorAtMs(Long lastErrorAtMs) { this.lastErrorAtMs = lastErrorAtMs; }
|
||||||
|
public int getRetryCount() { return retryCount; }
|
||||||
|
public void setRetryCount(int retryCount) { this.retryCount = retryCount; }
|
||||||
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||||
|
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||||
|
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||||
|
public Long getCompletedAtMs() { return completedAtMs; }
|
||||||
|
public void setCompletedAtMs(Long completedAtMs) { this.completedAtMs = completedAtMs; }
|
||||||
|
public Long getAbortedAtMs() { return abortedAtMs; }
|
||||||
|
public void setAbortedAtMs(Long abortedAtMs) { this.abortedAtMs = abortedAtMs; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояния серверной машины смены ключей.
|
||||||
|
*
|
||||||
|
* NONE используется только в solana_user_pda_current.rotation_status.
|
||||||
|
* COMPLETE/ABORTED остаются в истории key_rotation_sessions, после чего
|
||||||
|
* пользователь снова получает rotation_status=NONE.
|
||||||
|
*/
|
||||||
|
public enum KeyRotationStatus {
|
||||||
|
NONE,
|
||||||
|
COPYING_CHAIN,
|
||||||
|
CHAIN_READY,
|
||||||
|
ROTATING_PDA,
|
||||||
|
PDA_ROTATED,
|
||||||
|
REBUILDING_SERVER,
|
||||||
|
WALLET_MIGRATION,
|
||||||
|
MESSAGE_MIGRATION,
|
||||||
|
FINALIZING,
|
||||||
|
COMPLETE,
|
||||||
|
ABORTED;
|
||||||
|
|
||||||
|
public boolean isTerminalSessionStatus() {
|
||||||
|
return this == COMPLETE || this == ABORTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUserActiveStatus() {
|
||||||
|
return this != NONE && !isTerminalSessionStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Допустимые переходы одной ротации.
|
||||||
|
* Abort сознательно разрешён только до начала ROTATING_PDA: после отправки
|
||||||
|
* Solana-транзакции нельзя надёжно знать, не будет ли она подтверждена позже.
|
||||||
|
*/
|
||||||
|
public boolean canTransitionTo(KeyRotationStatus next) {
|
||||||
|
if (next == null) return false;
|
||||||
|
return switch (this) {
|
||||||
|
case COPYING_CHAIN -> EnumSet.of(CHAIN_READY, ABORTED).contains(next);
|
||||||
|
case CHAIN_READY -> EnumSet.of(ROTATING_PDA, ABORTED).contains(next);
|
||||||
|
case ROTATING_PDA -> next == PDA_ROTATED;
|
||||||
|
case PDA_ROTATED -> next == REBUILDING_SERVER;
|
||||||
|
case REBUILDING_SERVER -> next == WALLET_MIGRATION;
|
||||||
|
case WALLET_MIGRATION -> next == MESSAGE_MIGRATION;
|
||||||
|
case MESSAGE_MIGRATION -> next == FINALIZING;
|
||||||
|
case FINALIZING -> next == COMPLETE;
|
||||||
|
case NONE, COMPLETE, ABORTED -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Set<KeyRotationStatus> activeStatuses() {
|
||||||
|
return EnumSet.of(
|
||||||
|
COPYING_CHAIN,
|
||||||
|
CHAIN_READY,
|
||||||
|
ROTATING_PDA,
|
||||||
|
PDA_ROTATED,
|
||||||
|
REBUILDING_SERVER,
|
||||||
|
WALLET_MIGRATION,
|
||||||
|
MESSAGE_MIGRATION,
|
||||||
|
FINALIZING
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Статус, который должен храниться у пользователя для данной session-state. */
|
||||||
|
public KeyRotationStatus userStatus() {
|
||||||
|
return isTerminalSessionStatus() ? NONE : this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Notifications v2: three categories + signed seen watermarks.
|
||||||
|
|
||||||
|
ALTER TABLE user_notifications_state
|
||||||
|
DROP CONSTRAINT IF EXISTS user_notifications_state_notification_kind_check;
|
||||||
|
ALTER TABLE user_notifications_state
|
||||||
|
ADD CONSTRAINT user_notifications_state_notification_kind_check
|
||||||
|
CHECK (notification_kind IN ('reply', 'connection', 'event'));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||||
|
owner_login VARCHAR(60) NOT NULL,
|
||||||
|
category TEXT NOT NULL CHECK (category IN ('replies', 'connections', 'events')),
|
||||||
|
seen_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
signed_blob BYTEA NOT NULL,
|
||||||
|
signed_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_login, category)
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE db_schema_version SET schema_version = 20 WHERE id = 1;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Channel unread/read projection.
|
||||||
|
-- The row is created only by a signed SetChannelReadState from the client.
|
||||||
|
-- New channel publications only increment existing rows.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_read_state (
|
||||||
|
viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||||
|
owner_login TEXT NOT NULL,
|
||||||
|
owner_bch_name TEXT NOT NULL,
|
||||||
|
channel_name TEXT NOT NULL,
|
||||||
|
read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0),
|
||||||
|
unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0),
|
||||||
|
read_updated_at_ms BIGINT NOT NULL,
|
||||||
|
client_key TEXT NOT NULL,
|
||||||
|
read_signature TEXT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (viewer_login, owner_bch_name, channel_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer
|
||||||
|
ON channel_read_state(viewer_login);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel
|
||||||
|
ON channel_read_state(owner_bch_name, channel_name);
|
||||||
|
|
||||||
|
UPDATE db_schema_version SET schema_version = 21 WHERE id = 1;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS archive_chain_cursor (
|
||||||
|
blockchain_name TEXT PRIMARY KEY,
|
||||||
|
last_archived_source_block_number BIGINT NOT NULL,
|
||||||
|
last_archived_source_block_hash BYTEA NOT NULL,
|
||||||
|
last_archive_big_block_number BIGINT NOT NULL,
|
||||||
|
last_archive_big_block_hash BYTEA NOT NULL,
|
||||||
|
last_chunk_offset BIGINT NOT NULL,
|
||||||
|
last_chunk_size BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS archive_publish_job (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
big_block_number BIGINT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
local_archive_path TEXT,
|
||||||
|
archive_hash BYTEA,
|
||||||
|
arweave_tx_id BYTEA,
|
||||||
|
arweave_confirmations INTEGER,
|
||||||
|
solana_signature TEXT,
|
||||||
|
error_text TEXT,
|
||||||
|
updated_at_ms BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_status
|
||||||
|
ON archive_publish_job(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_big_block_number
|
||||||
|
ON archive_publish_job(big_block_number);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS archive_publish_job_chain (
|
||||||
|
job_id BIGINT NOT NULL REFERENCES archive_publish_job(id) ON DELETE CASCADE,
|
||||||
|
blockchain_name TEXT NOT NULL,
|
||||||
|
from_source_block_number BIGINT NOT NULL,
|
||||||
|
to_source_block_number BIGINT NOT NULL,
|
||||||
|
previous_source_block_hash BYTEA NOT NULL,
|
||||||
|
last_source_block_hash BYTEA NOT NULL,
|
||||||
|
previous_archive_big_block_number BIGINT,
|
||||||
|
previous_archive_big_block_hash BYTEA,
|
||||||
|
previous_chunk_offset BIGINT,
|
||||||
|
previous_chunk_size BIGINT,
|
||||||
|
new_chunk_offset BIGINT,
|
||||||
|
new_chunk_size BIGINT,
|
||||||
|
PRIMARY KEY (job_id, blockchain_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE solana_user_pda_history
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE solana_user_pda_history
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
UPDATE db_schema_version SET schema_version = 22, updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id = 1;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- v23: trusted SHINE-ARCHIVE import state + last chunk location index.
|
||||||
|
-- Solana archive head itself is already copied by the normal Solana users sync (v22).
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_imported BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_last_imported_tx_id TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
|
||||||
|
ON solana_user_pda_current(is_server, archive_imported)
|
||||||
|
WHERE archive_head_tx_id <> '';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS archive_blockchain_location (
|
||||||
|
blockchain_name TEXT PRIMARY KEY,
|
||||||
|
publisher_login TEXT NOT NULL,
|
||||||
|
arweave_tx_id TEXT NOT NULL,
|
||||||
|
archive_hash BYTEA NOT NULL,
|
||||||
|
big_block_number BIGINT NOT NULL,
|
||||||
|
chunk_offset BIGINT NOT NULL,
|
||||||
|
chunk_size BIGINT NOT NULL,
|
||||||
|
source_last_block_number BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher
|
||||||
|
ON archive_blockchain_location(publisher_login);
|
||||||
|
|
||||||
|
UPDATE db_schema_version
|
||||||
|
SET schema_version = 23,
|
||||||
|
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)
|
||||||
|
WHERE id = 1;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- Frame v1 intentionally has no backward compatibility with the old signed block format.
|
||||||
|
-- Refuse to migrate a populated blockchain database: test/dev data must be reset first.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM blocks LIMIT 1)
|
||||||
|
OR EXISTS (SELECT 1 FROM blockchain_state WHERE last_block_number >= 0 LIMIT 1) THEN
|
||||||
|
RAISE EXCEPTION 'migration_v24 requires an empty blockchain database; reset test/dev blockchain data before upgrading';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Старый SHINE-ARCHIVE v1 больше не используется.
|
||||||
|
DROP TABLE IF EXISTS archive_publish_job_chain CASCADE;
|
||||||
|
DROP TABLE IF EXISTS archive_publish_job CASCADE;
|
||||||
|
DROP TABLE IF EXISTS archive_chain_cursor CASCADE;
|
||||||
|
DROP TABLE IF EXISTS archive_blockchain_location CASCADE;
|
||||||
|
|
||||||
|
-- SHiNE Frame v1 / ANS-104 DataItem storage. No legacy .bch files are required.
|
||||||
|
ALTER TABLE blocks
|
||||||
|
ADD COLUMN IF NOT EXISTS data_item_id BYTEA;
|
||||||
|
ALTER TABLE blocks
|
||||||
|
ADD COLUMN IF NOT EXISTS arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
ALTER TABLE blocks
|
||||||
|
ADD COLUMN IF NOT EXISTS arweave_published_at_ms BIGINT;
|
||||||
|
ALTER TABLE blocks
|
||||||
|
ADD COLUMN IF NOT EXISTS arweave_root_tx_id TEXT;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
|
||||||
|
ON blocks(data_item_id)
|
||||||
|
WHERE data_item_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
|
||||||
|
ON blocks(block_number)
|
||||||
|
WHERE arweave_publish_pending = TRUE;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
|
||||||
|
id SMALLINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
last_block_height BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
|
||||||
|
VALUES(1,0,0) ON CONFLICT(id) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
|
||||||
|
data_item_id BYTEA PRIMARY KEY,
|
||||||
|
root_tx_id TEXT NOT NULL,
|
||||||
|
block_height BIGINT NOT NULL,
|
||||||
|
raw_data_item BYTEA NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
first_seen_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
|
||||||
|
ON arweave_block_import_queue(block_height,first_seen_at_ms)
|
||||||
|
WHERE status='PENDING';
|
||||||
|
|
||||||
|
UPDATE db_schema_version SET schema_version=24, updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id=1;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- PDA 1.2: keep the complete append-only blockchain-key/fork history alongside the active compatibility columns.
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS blockchain_forks_json TEXT NOT NULL DEFAULT '[]';
|
||||||
|
|
||||||
|
UPDATE db_schema_version
|
||||||
|
SET schema_version = 25,
|
||||||
|
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
WHERE id = 1;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
-- v26: серверная state machine смены ключей пользователя.
|
||||||
|
--
|
||||||
|
-- Важно:
|
||||||
|
-- - rotation_status / rotation_session_id являются ЛОКАЛЬНЫМ runtime-состоянием сервера,
|
||||||
|
-- а не полями Solana PDA;
|
||||||
|
-- - Solana sync не должен перезаписывать эти колонки при upsert PDA;
|
||||||
|
-- - приватные ключи здесь никогда не хранятся, только публичные ключи ротации.
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
|
||||||
|
rotation_status IN (
|
||||||
|
'NONE',
|
||||||
|
'COPYING_CHAIN',
|
||||||
|
'CHAIN_READY',
|
||||||
|
'ROTATING_PDA',
|
||||||
|
'PDA_ROTATED',
|
||||||
|
'REBUILDING_SERVER',
|
||||||
|
'WALLET_MIGRATION',
|
||||||
|
'MESSAGE_MIGRATION',
|
||||||
|
'FINALIZING'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
|
||||||
|
ON solana_user_pda_current(rotation_status)
|
||||||
|
WHERE rotation_status <> 'NONE';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL CHECK (
|
||||||
|
status IN (
|
||||||
|
'COPYING_CHAIN',
|
||||||
|
'CHAIN_READY',
|
||||||
|
'ROTATING_PDA',
|
||||||
|
'PDA_ROTATED',
|
||||||
|
'REBUILDING_SERVER',
|
||||||
|
'WALLET_MIGRATION',
|
||||||
|
'MESSAGE_MIGRATION',
|
||||||
|
'FINALIZING',
|
||||||
|
'COMPLETE',
|
||||||
|
'ABORTED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
source_blockchain_name TEXT NOT NULL,
|
||||||
|
candidate_blockchain_name TEXT NOT NULL,
|
||||||
|
|
||||||
|
old_root_key TEXT NOT NULL,
|
||||||
|
old_blockchain_key TEXT NOT NULL,
|
||||||
|
old_client_key TEXT NOT NULL,
|
||||||
|
new_root_key TEXT NOT NULL,
|
||||||
|
new_blockchain_key TEXT NOT NULL,
|
||||||
|
new_client_key TEXT NOT NULL,
|
||||||
|
|
||||||
|
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
|
||||||
|
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
|
||||||
|
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
|
||||||
|
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
|
||||||
|
|
||||||
|
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
|
||||||
|
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
|
||||||
|
|
||||||
|
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
|
||||||
|
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
|
||||||
|
|
||||||
|
pda_rotation_signature TEXT,
|
||||||
|
|
||||||
|
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||||
|
wallet_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
|
||||||
|
),
|
||||||
|
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||||
|
message_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
|
||||||
|
),
|
||||||
|
|
||||||
|
last_error TEXT,
|
||||||
|
last_error_at_ms BIGINT,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||||
|
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
completed_at_ms BIGINT,
|
||||||
|
aborted_at_ms BIGINT,
|
||||||
|
|
||||||
|
CHECK (progress_current <= progress_total),
|
||||||
|
CHECK (fork_from_block <= source_tip_block)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- У одного login одновременно может существовать только одна незавершённая ротация.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
|
||||||
|
ON key_rotation_sessions(login)
|
||||||
|
WHERE status NOT IN ('COMPLETE', 'ABORTED');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
|
||||||
|
ON key_rotation_sessions(login, created_at_ms DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
|
||||||
|
ON key_rotation_sessions(status);
|
||||||
|
|
||||||
|
-- Быстрый указатель из текущего пользователя на активную/последнюю runtime-сессию.
|
||||||
|
-- FK добавляется после создания key_rotation_sessions, чтобы не создавать циклический DDL порядок.
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
|
||||||
|
FOREIGN KEY (rotation_session_id)
|
||||||
|
REFERENCES key_rotation_sessions(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
UPDATE db_schema_version
|
||||||
|
SET schema_version = 26,
|
||||||
|
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
WHERE id = 1;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
candidate_blockchain_name TEXT NOT NULL,
|
||||||
|
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||||
|
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
|
||||||
|
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
|
||||||
|
block_bytes BYTEA NOT NULL,
|
||||||
|
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
arweave_published_at_ms BIGINT,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
UNIQUE (rotation_session_id, block_number),
|
||||||
|
UNIQUE (rotation_session_id, data_item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
|
||||||
|
ON key_rotation_candidate_blocks(id)
|
||||||
|
WHERE arweave_publish_pending = TRUE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
|
||||||
|
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
|
||||||
|
|
||||||
|
UPDATE db_schema_version
|
||||||
|
SET schema_version = 27,
|
||||||
|
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
WHERE id = 1;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
-- SHiNE PostgreSQL runtime schema v1
|
-- SHiNE PostgreSQL database format v2 / runtime schema revision 29
|
||||||
-- Дата: 2026-07-28
|
-- Stage 2 baseline. This file is only for a NEW, EMPTY database.
|
||||||
|
-- Дата: 2026-09-29
|
||||||
--
|
--
|
||||||
-- Назначение:
|
-- Назначение:
|
||||||
-- - поднять пустую PostgreSQL БД сервера SHiNE с нуля;
|
-- - поднять пустую PostgreSQL БД сервера SHiNE с нуля;
|
||||||
@@ -15,14 +16,42 @@
|
|||||||
|
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
|
-- Safety guard: DB v2 is a fresh-baseline format, not an in-place migration.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = current_schema()
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'SHiNE_DB_V2 bootstrap requires an empty PostgreSQL schema; legacy/in-place migration is disabled';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS db_schema_version (
|
CREATE TABLE IF NOT EXISTS db_schema_version (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
schema_version INTEGER NOT NULL,
|
schema_version INTEGER NOT NULL,
|
||||||
updated_at_ms BIGINT NOT NULL
|
updated_at_ms BIGINT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Hard database-generation marker. Stage 2 server MUST NOT run on an older SHiNE DB.
|
||||||
|
CREATE TABLE IF NOT EXISTS shine_database_metadata (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
database_format TEXT NOT NULL CHECK (database_format = 'SHiNE_DB_V2'),
|
||||||
|
database_generation INTEGER NOT NULL CHECK (database_generation = 2),
|
||||||
|
created_at_ms BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO shine_database_metadata(id, database_format, database_generation, created_at_ms)
|
||||||
|
VALUES (1, 'SHiNE_DB_V2', 2, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
COMMENT ON TABLE shine_database_metadata IS 'SHiNE database generation marker. Stage 2 requires SHiNE_DB_V2 / generation 2.';
|
||||||
|
|
||||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
VALUES (1, 18, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
VALUES (1, 29, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
schema_version = EXCLUDED.schema_version,
|
schema_version = EXCLUDED.schema_version,
|
||||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
@@ -84,6 +113,7 @@ CREATE TABLE IF NOT EXISTS solana_user_pda_current (
|
|||||||
blockchain_name TEXT NOT NULL,
|
blockchain_name TEXT NOT NULL,
|
||||||
blockchain_key TEXT NOT NULL,
|
blockchain_key TEXT NOT NULL,
|
||||||
paid_limit_bytes BIGINT NOT NULL,
|
paid_limit_bytes BIGINT NOT NULL,
|
||||||
|
blockchain_forks_json TEXT NOT NULL DEFAULT '[]',
|
||||||
used_bytes BIGINT NOT NULL,
|
used_bytes BIGINT NOT NULL,
|
||||||
last_block_number INTEGER NOT NULL,
|
last_block_number INTEGER NOT NULL,
|
||||||
last_block_hash TEXT NOT NULL,
|
last_block_hash TEXT NOT NULL,
|
||||||
@@ -291,12 +321,6 @@ AFTER TRUNCATE ON solana_user_pda_current
|
|||||||
FOR EACH STATEMENT
|
FOR EACH STATEMENT
|
||||||
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
|
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
|
|
||||||
CREATE TRIGGER trg_user_stats_state_ai
|
|
||||||
AFTER INSERT ON solana_user_pda_current
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION shine_user_stats_state_ai();
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS solana_user_pda_history (
|
CREATE TABLE IF NOT EXISTS solana_user_pda_history (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
tx_signature TEXT NOT NULL,
|
tx_signature TEXT NOT NULL,
|
||||||
@@ -425,6 +449,26 @@ CREATE INDEX IF NOT EXISTS idx_user_settings_login
|
|||||||
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
|
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
|
||||||
ON user_settings(setting_type, setting_key);
|
ON user_settings(setting_type, setting_key);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_read_state (
|
||||||
|
viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||||
|
owner_login TEXT NOT NULL,
|
||||||
|
owner_bch_name TEXT NOT NULL,
|
||||||
|
channel_name TEXT NOT NULL,
|
||||||
|
read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0),
|
||||||
|
unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0),
|
||||||
|
read_updated_at_ms BIGINT NOT NULL,
|
||||||
|
client_key TEXT NOT NULL,
|
||||||
|
read_signature TEXT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (viewer_login, owner_bch_name, channel_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer
|
||||||
|
ON channel_read_state(viewer_login);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel
|
||||||
|
ON channel_read_state(owner_bch_name, channel_name);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ip_geo_cache (
|
CREATE TABLE IF NOT EXISTS ip_geo_cache (
|
||||||
ip TEXT PRIMARY KEY,
|
ip TEXT PRIMARY KEY,
|
||||||
geo TEXT,
|
geo TEXT,
|
||||||
@@ -479,10 +523,15 @@ CREATE TABLE IF NOT EXISTS blocks (
|
|||||||
block_bytes BYTEA NOT NULL,
|
block_bytes BYTEA NOT NULL,
|
||||||
to_login TEXT,
|
to_login TEXT,
|
||||||
to_bch_name TEXT,
|
to_bch_name TEXT,
|
||||||
|
to_fork_number INTEGER CHECK (to_fork_number IS NULL OR (to_fork_number >= 0 AND to_fork_number <= 999)),
|
||||||
to_block_number INTEGER CHECK (to_block_number IS NULL OR to_block_number >= 0),
|
to_block_number INTEGER CHECK (to_block_number IS NULL OR to_block_number >= 0),
|
||||||
to_block_hash BYTEA,
|
to_block_hash BYTEA,
|
||||||
block_hash BYTEA NOT NULL,
|
block_hash BYTEA NOT NULL,
|
||||||
block_signature BYTEA NOT NULL,
|
block_signature BYTEA NOT NULL,
|
||||||
|
data_item_id BYTEA NOT NULL,
|
||||||
|
arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
arweave_published_at_ms BIGINT,
|
||||||
|
arweave_root_tx_id TEXT,
|
||||||
edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0),
|
edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0),
|
||||||
line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0),
|
line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0),
|
||||||
prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0),
|
prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0),
|
||||||
@@ -494,12 +543,41 @@ CREATE TABLE IF NOT EXISTS blocks (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number
|
CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number
|
||||||
ON blocks (bch_name, block_number);
|
ON blocks (bch_name, block_number);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
|
||||||
|
ON blocks(data_item_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
|
||||||
|
ON blocks(block_number)
|
||||||
|
WHERE arweave_publish_pending = TRUE;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_blocks_to_target
|
CREATE INDEX IF NOT EXISTS idx_blocks_to_target
|
||||||
ON blocks (to_login, to_bch_name, to_block_number);
|
ON blocks (to_login, to_bch_name, to_block_number);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_line
|
CREATE INDEX IF NOT EXISTS idx_blocks_by_line
|
||||||
ON blocks (bch_name, line_code, this_line_number);
|
ON blocks (bch_name, line_code, this_line_number);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
|
||||||
|
id SMALLINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
last_block_height BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
INSERT INTO arweave_block_sync_state(id, last_block_height, updated_at_ms)
|
||||||
|
VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
|
||||||
|
data_item_id BYTEA PRIMARY KEY,
|
||||||
|
root_tx_id TEXT NOT NULL,
|
||||||
|
block_height BIGINT NOT NULL,
|
||||||
|
raw_data_item BYTEA NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
first_seen_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
|
||||||
|
ON arweave_block_import_queue(block_height,first_seen_at_ms)
|
||||||
|
WHERE status='PENDING';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS connections_state (
|
CREATE TABLE IF NOT EXISTS connections_state (
|
||||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||||
rel_type INTEGER NOT NULL,
|
rel_type INTEGER NOT NULL,
|
||||||
@@ -765,7 +843,7 @@ CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
||||||
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
|
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection', 'event')),
|
||||||
created_at_ms BIGINT NOT NULL,
|
created_at_ms BIGINT NOT NULL,
|
||||||
source_login TEXT NOT NULL,
|
source_login TEXT NOT NULL,
|
||||||
source_bch_name TEXT NOT NULL,
|
source_bch_name TEXT NOT NULL,
|
||||||
@@ -1640,6 +1718,12 @@ AFTER INSERT ON blocks
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION shine_blocks_edit_apply_ai();
|
EXECUTE FUNCTION shine_blocks_edit_apply_ai();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
|
||||||
|
CREATE TRIGGER trg_user_stats_state_ai
|
||||||
|
AFTER INSERT ON solana_user_pda_current
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION shine_user_stats_state_ai();
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
|
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
|
||||||
CREATE TRIGGER trg_channel_names_state_stats_ai
|
CREATE TRIGGER trg_channel_names_state_stats_ai
|
||||||
AFTER INSERT ON channel_names_state
|
AFTER INSERT ON channel_names_state
|
||||||
@@ -1997,8 +2081,463 @@ UPDATE message_stats ms SET
|
|||||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||||
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login));
|
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login));
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||||
|
owner_login VARCHAR(60) NOT NULL,
|
||||||
|
category TEXT NOT NULL CHECK (category IN ('replies', 'connections', 'events')),
|
||||||
|
seen_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
signed_blob BYTEA NOT NULL,
|
||||||
|
signed_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_login, category)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE solana_user_pda_history
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE solana_user_pda_history
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_imported BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_last_imported_tx_id TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
|
||||||
|
ON solana_user_pda_current(is_server, archive_imported)
|
||||||
|
WHERE archive_head_tx_id <> '';
|
||||||
|
|
||||||
|
-- Server-local key rotation state machine (schema v26).
|
||||||
|
-- Эти поля не являются частью Solana PDA и не перезаписываются Solana sync upsert-ом.
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
|
||||||
|
rotation_status IN (
|
||||||
|
'NONE','COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
|
||||||
|
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
|
||||||
|
ON solana_user_pda_current(rotation_status)
|
||||||
|
WHERE rotation_status <> 'NONE';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL CHECK (
|
||||||
|
status IN (
|
||||||
|
'COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
|
||||||
|
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING',
|
||||||
|
'COMPLETE','ABORTED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
source_blockchain_name TEXT NOT NULL,
|
||||||
|
candidate_blockchain_name TEXT NOT NULL,
|
||||||
|
old_root_key TEXT NOT NULL,
|
||||||
|
old_blockchain_key TEXT NOT NULL,
|
||||||
|
old_client_key TEXT NOT NULL,
|
||||||
|
new_root_key TEXT NOT NULL,
|
||||||
|
new_blockchain_key TEXT NOT NULL,
|
||||||
|
new_client_key TEXT NOT NULL,
|
||||||
|
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
|
||||||
|
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
|
||||||
|
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
|
||||||
|
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
|
||||||
|
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
|
||||||
|
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
|
||||||
|
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
|
||||||
|
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
|
||||||
|
pda_rotation_signature TEXT,
|
||||||
|
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||||
|
wallet_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
|
||||||
|
),
|
||||||
|
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||||
|
message_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
|
||||||
|
),
|
||||||
|
last_error TEXT,
|
||||||
|
last_error_at_ms BIGINT,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
completed_at_ms BIGINT,
|
||||||
|
aborted_at_ms BIGINT,
|
||||||
|
CHECK (progress_current <= progress_total),
|
||||||
|
CHECK (fork_from_block <= source_tip_block)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
|
||||||
|
ON key_rotation_sessions(login)
|
||||||
|
WHERE status NOT IN ('COMPLETE','ABORTED');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
|
||||||
|
ON key_rotation_sessions(login, created_at_ms DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
|
||||||
|
ON key_rotation_sessions(status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
candidate_blockchain_name TEXT NOT NULL,
|
||||||
|
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||||
|
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
|
||||||
|
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
|
||||||
|
block_bytes BYTEA NOT NULL,
|
||||||
|
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
arweave_published_at_ms BIGINT,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
UNIQUE (rotation_session_id, block_number),
|
||||||
|
UNIQUE (rotation_session_id, data_item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
|
||||||
|
ON key_rotation_candidate_blocks(id)
|
||||||
|
WHERE arweave_publish_pending = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
|
||||||
|
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
|
||||||
|
FOREIGN KEY (rotation_session_id)
|
||||||
|
REFERENCES key_rotation_sessions(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Stage 2 final schema: numeric physical forks and logical cross-fork targets.
|
||||||
|
-- Stage 2: physical fork identity = (login,fork_number); logical target = (login,block_number,hash).
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD CONSTRAINT chk_solana_current_blockchain_name_fork
|
||||||
|
CHECK (blockchain_name ~ '-[0-9]{3}$'
|
||||||
|
AND substring(blockchain_name from '([0-9]{3})$')::INTEGER BETWEEN 1 AND 999);
|
||||||
|
|
||||||
|
ALTER TABLE blockchain_state ADD COLUMN IF NOT EXISTS fork_number INTEGER;
|
||||||
|
UPDATE blockchain_state
|
||||||
|
SET fork_number = NULLIF(regexp_replace(blockchain_name, '^.*-([0-9]{3})$', '\1'), blockchain_name)::INTEGER
|
||||||
|
WHERE fork_number IS NULL AND blockchain_name ~ '-[0-9]{3}$';
|
||||||
|
ALTER TABLE blockchain_state ALTER COLUMN fork_number SET NOT NULL;
|
||||||
|
ALTER TABLE blockchain_state ADD CONSTRAINT chk_blockchain_state_fork CHECK (fork_number BETWEEN 1 AND 999);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_blockchain_state_login_fork ON blockchain_state(login,fork_number);
|
||||||
|
|
||||||
|
ALTER TABLE blocks ADD COLUMN IF NOT EXISTS fork_number INTEGER;
|
||||||
|
UPDATE blocks b SET fork_number=s.fork_number FROM blockchain_state s WHERE b.bch_name=s.blockchain_name AND b.fork_number IS NULL;
|
||||||
|
ALTER TABLE blocks ALTER COLUMN fork_number SET NOT NULL;
|
||||||
|
ALTER TABLE blocks ADD CONSTRAINT chk_blocks_fork CHECK (fork_number BETWEEN 1 AND 999);
|
||||||
|
|
||||||
|
-- Triggers stay attached when tables are renamed; replace their functions below before new writes.
|
||||||
|
ALTER TABLE blocks DROP CONSTRAINT IF EXISTS blocks_bch_name_fkey;
|
||||||
|
ALTER TABLE blocks RENAME TO blocks_store;
|
||||||
|
ALTER TABLE blockchain_state RENAME TO blockchain_state_store;
|
||||||
|
ALTER TABLE blocks_store DROP COLUMN bch_name;
|
||||||
|
ALTER TABLE blocks_store DROP COLUMN to_bch_name;
|
||||||
|
ALTER TABLE blockchain_state_store DROP CONSTRAINT IF EXISTS blockchain_state_pkey;
|
||||||
|
ALTER TABLE blockchain_state_store DROP COLUMN blockchain_name;
|
||||||
|
ALTER TABLE blockchain_state_store ADD PRIMARY KEY(login,fork_number);
|
||||||
|
ALTER TABLE blocks_store ADD CONSTRAINT fk_blocks_physical_fork FOREIGN KEY(login,fork_number)
|
||||||
|
REFERENCES blockchain_state_store(login,fork_number);
|
||||||
|
DROP INDEX IF EXISTS idx_blocks_by_chain_number;
|
||||||
|
DROP INDEX IF EXISTS idx_blocks_to_target;
|
||||||
|
DROP INDEX IF EXISTS idx_blocks_by_line;
|
||||||
|
CREATE UNIQUE INDEX uq_blocks_physical_number ON blocks_store(login,fork_number,block_number);
|
||||||
|
CREATE INDEX idx_blocks_to_logical_target ON blocks_store(to_login,to_block_number,to_block_hash);
|
||||||
|
CREATE INDEX idx_blocks_by_line_v29 ON blocks_store(login,fork_number,line_code,this_line_number);
|
||||||
|
|
||||||
|
-- Compatibility views: names are derived, never stored.
|
||||||
|
CREATE VIEW blockchain_state AS
|
||||||
|
SELECT login || '-' || lpad(fork_number::text,3,'0') AS blockchain_name, s.* FROM blockchain_state_store s;
|
||||||
|
CREATE VIEW blocks AS
|
||||||
|
SELECT b.login,
|
||||||
|
b.login || '-' || lpad(b.fork_number::text,3,'0') AS bch_name,
|
||||||
|
b.fork_number,b.block_number,b.msg_type,b.msg_sub_type,b.block_bytes,
|
||||||
|
b.to_login,
|
||||||
|
CASE WHEN b.to_login IS NOT NULL AND b.to_fork_number BETWEEN 1 AND 999
|
||||||
|
THEN b.to_login || '-' || lpad(b.to_fork_number::text,3,'0') END AS to_bch_name,
|
||||||
|
b.to_fork_number,b.to_block_number,b.to_block_hash,b.block_hash,b.block_signature,b.data_item_id,
|
||||||
|
b.arweave_publish_pending,b.arweave_published_at_ms,b.arweave_root_tx_id,b.edited_by_block_number,
|
||||||
|
b.line_code,b.prev_line_number,b.prev_line_hash,b.this_line_number
|
||||||
|
FROM blocks_store b;
|
||||||
|
|
||||||
|
-- Channel read state keeps the signed/display owner_bch_name, but identity no longer depends on a fork.
|
||||||
|
ALTER TABLE channel_read_state DROP CONSTRAINT IF EXISTS channel_read_state_pkey;
|
||||||
|
ALTER TABLE channel_read_state ADD PRIMARY KEY(viewer_login,owner_login,channel_name);
|
||||||
|
|
||||||
|
-- Derived social state is cheap to rebuild and intentionally loses physical fork names.
|
||||||
|
DROP TABLE IF EXISTS reactions_state CASCADE;
|
||||||
|
DROP TABLE IF EXISTS message_stats CASCADE;
|
||||||
|
DROP TABLE IF EXISTS connections_state CASCADE;
|
||||||
|
CREATE TABLE connections_state(
|
||||||
|
login TEXT NOT NULL, rel_type INTEGER NOT NULL, to_login TEXT NOT NULL,
|
||||||
|
to_block_number INTEGER NOT NULL, to_block_hash BYTEA NOT NULL,
|
||||||
|
UNIQUE(login,rel_type,to_login,to_block_number,to_block_hash)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_connections_state_login_v29 ON connections_state(login);
|
||||||
|
CREATE INDEX idx_connections_state_to_login_v29 ON connections_state(to_login);
|
||||||
|
CREATE INDEX idx_connections_state_target_v29 ON connections_state(to_login,to_block_number,to_block_hash);
|
||||||
|
|
||||||
|
CREATE TABLE message_stats(
|
||||||
|
to_login TEXT NOT NULL, to_block_number INTEGER NOT NULL, to_block_hash BYTEA NOT NULL,
|
||||||
|
likes_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
primary_likes_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
shining_likes_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
replies_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(to_login,to_block_number,to_block_hash)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_message_stats_target_v29 ON message_stats(to_login,to_block_number,to_block_hash);
|
||||||
|
|
||||||
|
CREATE TABLE reactions_state(
|
||||||
|
from_login TEXT NOT NULL, reaction_type INTEGER NOT NULL,
|
||||||
|
to_login TEXT NOT NULL, to_block_number INTEGER NOT NULL, to_block_hash BYTEA NOT NULL,
|
||||||
|
last_sub_type INTEGER NOT NULL,
|
||||||
|
UNIQUE(from_login,reaction_type,to_login,to_block_number,to_block_hash)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_reactions_state_target_v29 ON reactions_state(to_login,to_block_number,to_block_hash);
|
||||||
|
CREATE INDEX idx_reactions_state_actor_v29 ON reactions_state(from_login,reaction_type);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS message_views_state;
|
||||||
|
CREATE TABLE message_views_state(
|
||||||
|
viewer_login TEXT NOT NULL, to_login TEXT NOT NULL, to_block_number INTEGER NOT NULL,
|
||||||
|
to_block_hash BYTEA NOT NULL, first_seen_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY(viewer_login,to_login,to_block_number,to_block_hash)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_message_views_target_v29 ON message_views_state(to_login,to_block_number,to_block_hash);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT) RETURNS VOID LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE v_now BIGINT:=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT);
|
||||||
|
BEGIN
|
||||||
|
IF p_login IS NULL OR btrim(p_login)='' THEN RETURN; END IF;
|
||||||
|
INSERT INTO user_stats_state(login,owned_public_channels_count,following_users_count,following_channels_count,close_friends_count,friends_count,
|
||||||
|
primary_confirmations_received_count,primary_confirmations_given_count,shine_confirmations_received_count,shine_confirmations_given_count,updated_at_ms)
|
||||||
|
SELECT p_login,
|
||||||
|
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cs.to_block_number=0),
|
||||||
|
(SELECT COUNT(*)::INT FROM connections_state cs JOIN channel_names_state cn ON LOWER(cn.owner_login)=LOWER(cs.to_login) AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14 AND NOT EXISTS(SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80 AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70 AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),v_now
|
||||||
|
ON CONFLICT(login) DO UPDATE SET owned_public_channels_count=EXCLUDED.owned_public_channels_count,following_users_count=EXCLUDED.following_users_count,following_channels_count=EXCLUDED.following_channels_count,close_friends_count=EXCLUDED.close_friends_count,friends_count=EXCLUDED.friends_count,primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,updated_at_ms=EXCLUDED.updated_at_ms;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_message_qualified_likes_for_actor(p_login TEXT) RETURNS VOID LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN SELECT DISTINCT to_login,to_block_number,to_block_hash FROM reactions_state WHERE LOWER(from_login)=LOWER(p_login) AND reaction_type=1 AND last_sub_type=1 LOOP
|
||||||
|
UPDATE message_stats ms SET
|
||||||
|
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1 AND x.to_login=r.to_login AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash AND shine_is_primary(x.from_login)),
|
||||||
|
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1 AND x.to_login=r.to_login AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash AND shine_is_primary(x.from_login) AND shine_is_shining(x.from_login))
|
||||||
|
WHERE ms.to_login=r.to_login AND ms.to_block_number=r.to_block_number AND ms.to_block_hash=r.to_block_hash;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_channel_follow_stats(p_bch_name TEXT,p_block_number INTEGER,p_block_hash BYTEA) RETURNS VOID LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE v_owner TEXT; v_type INTEGER; v_count INTEGER; v_now BIGINT:=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT);
|
||||||
|
BEGIN
|
||||||
|
SELECT owner_login,channel_type_code INTO v_owner,v_type FROM channel_names_state WHERE owner_bch_name=p_bch_name AND channel_root_block_number=p_block_number AND channel_root_block_hash=p_block_hash LIMIT 1;
|
||||||
|
IF v_owner IS NULL THEN RETURN; END IF;
|
||||||
|
SELECT COUNT(DISTINCT cs.login)::INTEGER INTO v_count FROM connections_state cs WHERE cs.rel_type=30 AND LOWER(cs.to_login)=LOWER(v_owner) AND cs.to_block_number=p_block_number AND cs.to_block_hash=p_block_hash;
|
||||||
|
INSERT INTO channel_stats_state(owner_bch_name,channel_root_block_number,channel_root_block_hash,owner_login,channel_type_code,subscribers_count,updated_at_ms)
|
||||||
|
VALUES(p_bch_name,p_block_number,p_block_hash,v_owner,v_type,COALESCE(v_count,0),v_now)
|
||||||
|
ON CONFLICT(owner_bch_name,channel_root_block_number,channel_root_block_hash) DO UPDATE SET subscribers_count=EXCLUDED.subscribers_count,owner_login=EXCLUDED.owner_login,channel_type_code=EXCLUDED.channel_type_code,updated_at_ms=EXCLUDED.updated_at_ms;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Channel-name projection must also use logical connection targets; the physical owner name is display/state only.
|
||||||
|
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
now_ms BIGINT;
|
||||||
|
public_subscribers_count INTEGER;
|
||||||
|
r RECORD;
|
||||||
|
BEGIN
|
||||||
|
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||||
|
|
||||||
|
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.channel_type_code = 1 THEN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM solana_user_pda_current su WHERE LOWER(su.login)=LOWER(NEW.owner_login)
|
||||||
|
) THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login, owned_public_channels_count, following_users_count, following_channels_count,
|
||||||
|
close_friends_count, updated_at_ms
|
||||||
|
) VALUES (NEW.owner_login, 1, 0, 0, 0, now_ms)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*)::INTEGER
|
||||||
|
INTO public_subscribers_count
|
||||||
|
FROM connections_state cs
|
||||||
|
WHERE cs.rel_type = 30
|
||||||
|
AND LOWER(cs.to_login)=LOWER(NEW.owner_login)
|
||||||
|
AND cs.to_block_number = NEW.channel_root_block_number
|
||||||
|
AND cs.to_block_hash = NEW.channel_root_block_hash;
|
||||||
|
|
||||||
|
INSERT INTO channel_stats_state (
|
||||||
|
owner_bch_name, channel_root_block_number, channel_root_block_hash,
|
||||||
|
owner_login, channel_type_code, subscribers_count, updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.owner_bch_name, NEW.channel_root_block_number, NEW.channel_root_block_hash,
|
||||||
|
NEW.owner_login, NEW.channel_type_code, COALESCE(public_subscribers_count, 0), now_ms
|
||||||
|
)
|
||||||
|
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||||
|
owner_login = EXCLUDED.owner_login,
|
||||||
|
channel_type_code = EXCLUDED.channel_type_code,
|
||||||
|
subscribers_count = EXCLUDED.subscribers_count,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
|
||||||
|
-- A channel may disappear during cleanup and reappear during replay. Recompute followers from logical state.
|
||||||
|
FOR r IN
|
||||||
|
SELECT DISTINCT cs.login
|
||||||
|
FROM connections_state cs
|
||||||
|
WHERE cs.rel_type = 30
|
||||||
|
AND LOWER(cs.to_login)=LOWER(NEW.owner_login)
|
||||||
|
AND cs.to_block_number = NEW.channel_root_block_number
|
||||||
|
AND cs.to_block_hash = NEW.channel_root_block_hash
|
||||||
|
LOOP
|
||||||
|
PERFORM shine_refresh_user_stats(r.login);
|
||||||
|
END LOOP;
|
||||||
|
ELSE
|
||||||
|
WITH pending AS (
|
||||||
|
SELECT cs.login, COUNT(*)::INTEGER AS cnt
|
||||||
|
FROM connections_state cs
|
||||||
|
WHERE cs.rel_type = 30
|
||||||
|
AND LOWER(cs.to_login)=LOWER(NEW.owner_login)
|
||||||
|
AND cs.to_block_number = NEW.channel_root_block_number
|
||||||
|
AND cs.to_block_hash = NEW.channel_root_block_hash
|
||||||
|
GROUP BY cs.login
|
||||||
|
)
|
||||||
|
UPDATE user_stats_state us
|
||||||
|
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
|
||||||
|
updated_at_ms = now_ms
|
||||||
|
FROM pending
|
||||||
|
WHERE LOWER(us.login)=LOWER(pending.login);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Physical chain integrity uses numeric fork identity.
|
||||||
|
CREATE OR REPLACE FUNCTION shine_blocks_line_integrity_bi() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE prev_block RECORD;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.line_code IS NULL AND NEW.prev_line_number IS NULL AND NEW.prev_line_hash IS NULL AND NEW.this_line_number IS NULL THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.msg_type NOT IN (0,1,3,4) THEN RAISE EXCEPTION 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE'; END IF;
|
||||||
|
IF NEW.line_code IS NULL OR NEW.prev_line_number IS NULL OR NEW.prev_line_hash IS NULL OR NEW.this_line_number IS NULL THEN RAISE EXCEPTION 'LINE_ERR_PARTIAL_FIELDS'; END IF;
|
||||||
|
SELECT * INTO prev_block FROM blocks_store WHERE login=NEW.login AND fork_number=NEW.fork_number AND block_number=NEW.prev_line_number LIMIT 1;
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'LINE_ERR_NO_PREV'; END IF;
|
||||||
|
IF prev_block.block_hash IS DISTINCT FROM NEW.prev_line_hash THEN RAISE EXCEPTION 'LINE_ERR_PREV_HASH_MISMATCH'; END IF;
|
||||||
|
IF NEW.prev_line_number<>NEW.line_code AND prev_block.line_code IS DISTINCT FROM NEW.line_code THEN RAISE EXCEPTION 'LINE_ERR_LINE_CODE_MISMATCH'; END IF;
|
||||||
|
IF NEW.prev_line_number=NEW.line_code THEN
|
||||||
|
IF NEW.this_line_number<>(CASE WHEN NEW.msg_type=1 THEN 0 ELSE 1 END) THEN RAISE EXCEPTION 'LINE_ERR_FIRST_STEP_BAD_THIS'; END IF; RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
IF prev_block.this_line_number IS NULL THEN RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; END IF;
|
||||||
|
IF NEW.msg_type=1 THEN
|
||||||
|
IF NEW.this_line_number<>prev_block.this_line_number AND NEW.this_line_number<>prev_block.this_line_number+1 THEN RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; END IF;
|
||||||
|
ELSE
|
||||||
|
IF NEW.this_line_number<>prev_block.this_line_number+1 THEN RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; END IF;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE positive_rel_type INTEGER; channel_bch TEXT;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.msg_type<>3 OR NEW.to_login IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.msg_sub_type IN (10,14,20,30,40,50,52,54,60,70,74,80) THEN
|
||||||
|
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=NEW.msg_sub_type AND to_login=NEW.to_login AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
|
||||||
|
INSERT INTO connections_state(login,rel_type,to_login,to_block_number,to_block_hash)
|
||||||
|
VALUES(NEW.login,NEW.msg_sub_type,NEW.to_login,NEW.to_block_number,NEW.to_block_hash);
|
||||||
|
ELSE
|
||||||
|
positive_rel_type := CASE NEW.msg_sub_type WHEN 11 THEN 10 WHEN 15 THEN 14 WHEN 21 THEN 20 WHEN 31 THEN 30 WHEN 41 THEN 40 WHEN 51 THEN 50 WHEN 53 THEN 52 WHEN 55 THEN 54 WHEN 61 THEN 60 WHEN 71 THEN 70 WHEN 75 THEN 74 WHEN 81 THEN 80 ELSE NULL END;
|
||||||
|
IF positive_rel_type IS NOT NULL THEN DELETE FROM connections_state WHERE login=NEW.login AND rel_type=positive_rel_type AND to_login=NEW.to_login AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash; END IF;
|
||||||
|
END IF;
|
||||||
|
PERFORM shine_refresh_user_stats(NEW.login);
|
||||||
|
PERFORM shine_refresh_user_stats(NEW.to_login);
|
||||||
|
IF NEW.msg_sub_type IN (30,31) AND NEW.to_block_number>0 THEN
|
||||||
|
SELECT owner_bch_name INTO channel_bch FROM channel_names_state WHERE LOWER(owner_login)=LOWER(NEW.to_login) AND channel_root_block_number=NEW.to_block_number AND channel_root_block_hash=NEW.to_block_hash LIMIT 1;
|
||||||
|
IF channel_bch IS NOT NULL THEN PERFORM shine_refresh_channel_follow_stats(channel_bch,NEW.to_block_number,NEW.to_block_hash); END IF;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE previous_sub_type INTEGER;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.msg_type<>2 OR NEW.msg_sub_type NOT IN (1,2) THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.to_login IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
|
||||||
|
INSERT INTO message_stats(to_login,to_block_number,to_block_hash) VALUES(NEW.to_login,NEW.to_block_number,NEW.to_block_hash)
|
||||||
|
ON CONFLICT(to_login,to_block_number,to_block_hash) DO NOTHING;
|
||||||
|
SELECT last_sub_type INTO previous_sub_type FROM reactions_state WHERE from_login=NEW.login AND reaction_type=1 AND to_login=NEW.to_login AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash LIMIT 1;
|
||||||
|
IF NEW.msg_sub_type=1 AND previous_sub_type IS DISTINCT FROM 1 THEN
|
||||||
|
UPDATE message_stats SET likes_count=likes_count+1,
|
||||||
|
primary_likes_count=primary_likes_count+CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END,
|
||||||
|
shining_likes_count=shining_likes_count+CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END
|
||||||
|
WHERE to_login=NEW.to_login AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
|
||||||
|
ELSIF NEW.msg_sub_type=2 AND previous_sub_type=1 THEN
|
||||||
|
UPDATE message_stats SET likes_count=GREATEST(0,likes_count-1),
|
||||||
|
primary_likes_count=GREATEST(0,primary_likes_count-CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END),
|
||||||
|
shining_likes_count=GREATEST(0,shining_likes_count-CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END)
|
||||||
|
WHERE to_login=NEW.to_login AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
|
||||||
|
END IF;
|
||||||
|
INSERT INTO reactions_state(from_login,reaction_type,to_login,to_block_number,to_block_hash,last_sub_type)
|
||||||
|
VALUES(NEW.login,1,NEW.to_login,NEW.to_block_number,NEW.to_block_hash,NEW.msg_sub_type)
|
||||||
|
ON CONFLICT(from_login,reaction_type,to_login,to_block_number,to_block_hash) DO UPDATE SET last_sub_type=EXCLUDED.last_sub_type;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Existing REPLY counter semantics are preserved; only the removed physical target-name column is adapted.
|
||||||
|
CREATE OR REPLACE FUNCTION shine_blocks_message_stats_reply_ai() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.msg_type<>1 OR NEW.msg_sub_type<>20 THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.to_login IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
|
||||||
|
INSERT INTO message_stats(to_login,to_block_number,to_block_hash,replies_count)
|
||||||
|
VALUES(NEW.to_login,NEW.to_block_number,NEW.to_block_hash,1)
|
||||||
|
ON CONFLICT(to_login,to_block_number,to_block_hash) DO UPDATE SET replies_count=message_stats.replies_count+1;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- EDIT applies to own physical fork; no edits_count aggregate is maintained.
|
||||||
|
CREATE OR REPLACE FUNCTION shine_blocks_edit_apply_ai() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.msg_type<>1 OR NEW.msg_sub_type NOT IN (11,21) THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
|
||||||
|
UPDATE blocks_store SET edited_by_block_number=NEW.block_number
|
||||||
|
WHERE login=NEW.login AND fork_number=NEW.fork_number AND block_number=NEW.to_block_number AND block_hash=NEW.to_block_hash;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Triggers moved with blocks_store on rename; make sure they exist there.
|
||||||
|
DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi ON blocks_store;
|
||||||
|
CREATE TRIGGER trg_blocks_line_integrity_bi BEFORE INSERT ON blocks_store FOR EACH ROW EXECUTE FUNCTION shine_blocks_line_integrity_bi();
|
||||||
|
DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai ON blocks_store;
|
||||||
|
CREATE TRIGGER trg_blocks_connection_state_ai AFTER INSERT ON blocks_store FOR EACH ROW EXECUTE FUNCTION shine_blocks_connection_state_ai();
|
||||||
|
DROP TRIGGER IF EXISTS trg_blocks_message_stats_like_ai ON blocks_store;
|
||||||
|
CREATE TRIGGER trg_blocks_message_stats_like_ai AFTER INSERT ON blocks_store FOR EACH ROW EXECUTE FUNCTION shine_blocks_message_stats_like_ai();
|
||||||
|
DROP TRIGGER IF EXISTS trg_blocks_message_stats_reply_ai ON blocks_store;
|
||||||
|
CREATE TRIGGER trg_blocks_message_stats_reply_ai AFTER INSERT ON blocks_store FOR EACH ROW EXECUTE FUNCTION shine_blocks_message_stats_reply_ai();
|
||||||
|
DROP TRIGGER IF EXISTS trg_blocks_edit_apply_ai ON blocks_store;
|
||||||
|
CREATE TRIGGER trg_blocks_edit_apply_ai AFTER INSERT ON blocks_store FOR EACH ROW EXECUTE FUNCTION shine_blocks_edit_apply_ai();
|
||||||
|
|
||||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||||
VALUES(1,18,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
VALUES(1,29,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||||
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
+53
-8
@@ -42,15 +42,28 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing
|
|||||||
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetMyBlockchain_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStart_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAddBlock_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationFinishChain_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationRotatePda_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStatus_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAbort_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationContinue_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request;
|
||||||
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestGetFreeAvatarQuota_Handler;
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler;
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request;
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request;
|
|
||||||
|
|
||||||
// --- NEW: SearchUsers ---
|
// --- NEW: SearchUsers ---
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler;
|
||||||
@@ -75,18 +88,24 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend
|
|||||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageLikes_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetUserCounters_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.Net_SetChannelReadState_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler;
|
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler;
|
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler;
|
||||||
@@ -95,10 +114,12 @@ import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfi
|
|||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
|
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.notifications.Net_SetNotificationState_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_SetNotificationState_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||||
@@ -157,8 +178,6 @@ public final class JsonHandlerRegistry {
|
|||||||
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
|
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
|
||||||
Map.entry("GetUser", new Net_GetUser_Handler()),
|
Map.entry("GetUser", new Net_GetUser_Handler()),
|
||||||
Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
|
Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
|
||||||
Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()),
|
|
||||||
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
|
|
||||||
|
|
||||||
// --- auth ---
|
// --- auth ---
|
||||||
Map.entry("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()),
|
Map.entry("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()),
|
||||||
@@ -189,6 +208,16 @@ public final class JsonHandlerRegistry {
|
|||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||||
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
||||||
|
Map.entry("GetMyBlockchain", new Net_GetMyBlockchain_Handler()),
|
||||||
|
|
||||||
|
// --- key rotation ---
|
||||||
|
Map.entry("KeyRotationStart", new Net_KeyRotationStart_Handler()),
|
||||||
|
Map.entry("KeyRotationStatus", new Net_KeyRotationStatus_Handler()),
|
||||||
|
Map.entry("KeyRotationAddBlock", new Net_KeyRotationAddBlock_Handler()),
|
||||||
|
Map.entry("KeyRotationFinishChain", new Net_KeyRotationFinishChain_Handler()),
|
||||||
|
Map.entry("KeyRotationRotatePda", new Net_KeyRotationRotatePda_Handler()),
|
||||||
|
Map.entry("KeyRotationContinue", new Net_KeyRotationContinue_Handler()),
|
||||||
|
Map.entry("KeyRotationAbort", new Net_KeyRotationAbort_Handler()),
|
||||||
|
|
||||||
// --- userParams ---
|
// --- userParams ---
|
||||||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||||||
@@ -206,15 +235,19 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||||
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
|
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
|
||||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||||
|
Map.entry("GetMessageLikes", new Net_GetMessageLikes_Handler()),
|
||||||
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
||||||
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
|
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
|
||||||
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
|
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
|
||||||
|
Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()),
|
||||||
|
Map.entry("SetChannelReadState", new Net_SetChannelReadState_Handler()),
|
||||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||||
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()),
|
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()),
|
||||||
Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()),
|
Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()),
|
||||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||||
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
||||||
|
Map.entry("SetNotificationState", new Net_SetNotificationState_Handler()),
|
||||||
|
|
||||||
// --- direct messages / push ---
|
// --- direct messages / push ---
|
||||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||||
@@ -247,8 +280,6 @@ public final class JsonHandlerRegistry {
|
|||||||
private static final Map<String, Class<? extends Net_Request>> REQUEST_TYPES = Map.ofEntries(
|
private static final Map<String, Class<? extends Net_Request>> REQUEST_TYPES = Map.ofEntries(
|
||||||
Map.entry("GetUser", Net_GetUser_Request.class),
|
Map.entry("GetUser", Net_GetUser_Request.class),
|
||||||
Map.entry("SearchUsers", Net_SearchUsers_Request.class),
|
Map.entry("SearchUsers", Net_SearchUsers_Request.class),
|
||||||
Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class),
|
|
||||||
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
|
|
||||||
|
|
||||||
// --- auth ---
|
// --- auth ---
|
||||||
Map.entry("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class),
|
Map.entry("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class),
|
||||||
@@ -279,6 +310,16 @@ public final class JsonHandlerRegistry {
|
|||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
||||||
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
|
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
|
||||||
|
Map.entry("GetMyBlockchain", Net_GetMyBlockchain_Request.class),
|
||||||
|
|
||||||
|
// --- key rotation ---
|
||||||
|
Map.entry("KeyRotationStart", Net_KeyRotationStart_Request.class),
|
||||||
|
Map.entry("KeyRotationStatus", Net_KeyRotationStatus_Request.class),
|
||||||
|
Map.entry("KeyRotationAddBlock", Net_KeyRotationAddBlock_Request.class),
|
||||||
|
Map.entry("KeyRotationFinishChain", Net_KeyRotationFinishChain_Request.class),
|
||||||
|
Map.entry("KeyRotationRotatePda", Net_KeyRotationRotatePda_Request.class),
|
||||||
|
Map.entry("KeyRotationContinue", Net_KeyRotationContinue_Request.class),
|
||||||
|
Map.entry("KeyRotationAbort", Net_KeyRotationAbort_Request.class),
|
||||||
|
|
||||||
// --- userParams ---
|
// --- userParams ---
|
||||||
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
||||||
@@ -296,15 +337,19 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||||
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
|
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
|
||||||
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
||||||
|
Map.entry("GetMessageLikes", Net_GetMessageLikes_Request.class),
|
||||||
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
|
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
|
||||||
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
|
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
|
||||||
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
|
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
|
||||||
|
Map.entry("GetUserCounters", Net_GetUserCounters_Request.class),
|
||||||
|
Map.entry("SetChannelReadState", Net_SetChannelReadState_Request.class),
|
||||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||||
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
|
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
|
||||||
Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class),
|
Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class),
|
||||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||||
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
||||||
|
Map.entry("SetNotificationState", Net_SetNotificationState_Request.class),
|
||||||
|
|
||||||
// --- direct messages / push ---
|
// --- direct messages / push ---
|
||||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||||
|
|||||||
+118
-164
@@ -54,8 +54,7 @@ public final class SolanaUserPdaImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Чтение server PDA по логину сервера. Используется сервером при старте,
|
* Чтение server PDA 1.2 по логину сервера. В 1.2 разрешён ровно один endpoint.
|
||||||
* чтобы получить актуальный server_address и список sync_servers.
|
|
||||||
*/
|
*/
|
||||||
public static ParsedServerProfile fetchServerProfileByLogin(String loginRaw) throws Exception {
|
public static ParsedServerProfile fetchServerProfileByLogin(String loginRaw) throws Exception {
|
||||||
String login = normalizeLogin(loginRaw);
|
String login = normalizeLogin(loginRaw);
|
||||||
@@ -155,9 +154,16 @@ public final class SolanaUserPdaImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ParsedSolanaUser parseUserPda(byte[] raw) {
|
private static ParsedSolanaUser parseUserPda(byte[] raw) {
|
||||||
if (raw == null || raw.length < 128) return null;
|
if (raw == null || raw.length < 9) return null;
|
||||||
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
|
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
|
||||||
|
int major = u8(raw, 5);
|
||||||
|
int minor = u8(raw, 6);
|
||||||
|
if (major != 1 || minor != 2) return null;
|
||||||
|
return parseUserPdaV12(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ParsedSolanaUser parseUserPdaV12(byte[] raw) {
|
||||||
|
if (raw == null || raw.length < 128) return null;
|
||||||
int recordLen = u16le(raw, 7);
|
int recordLen = u16le(raw, 7);
|
||||||
if (recordLen < 73 || recordLen > raw.length) return null;
|
if (recordLen < 73 || recordLen > raw.length) return null;
|
||||||
|
|
||||||
@@ -166,224 +172,166 @@ public final class SolanaUserPdaImportService {
|
|||||||
c += 8; // updated_at_ms
|
c += 8; // updated_at_ms
|
||||||
c += 4; // record_number
|
c += 4; // record_number
|
||||||
c += 32; // prev_record_hash
|
c += 32; // prev_record_hash
|
||||||
|
if (c >= recordLen) return null;
|
||||||
|
|
||||||
int loginLen = u8(raw, c++);
|
int loginLen = u8(raw, c++);
|
||||||
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
||||||
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
||||||
c += loginLen;
|
c += loginLen;
|
||||||
|
if (c >= recordLen) return null;
|
||||||
|
|
||||||
int blocksCount = u8(raw, c++);
|
int blocksCount = u8(raw, c++);
|
||||||
String blockchainName = null;
|
|
||||||
byte[] blockchainKey32 = null;
|
byte[] blockchainKey32 = null;
|
||||||
byte[] clientKey32 = null;
|
byte[] clientKey32 = null;
|
||||||
long paidLimitBytes = 0L;
|
long paidLimitBytes = 0L;
|
||||||
List<ParsedSessionRecord> sessions = new ArrayList<>();
|
int forkCount = 0;
|
||||||
List<String> accessServers = new ArrayList<>();
|
List<String> accessServers = new ArrayList<>();
|
||||||
|
|
||||||
for (int i = 0; i < blocksCount; i++) {
|
for (int i = 0; i < blocksCount; i++) {
|
||||||
|
if (c + 2 > recordLen) return null;
|
||||||
int blockType = u8(raw, c++);
|
int blockType = u8(raw, c++);
|
||||||
int blockVer = u8(raw, c++);
|
int blockVer = u8(raw, c++);
|
||||||
if (blockVer != 0) return null;
|
if (blockVer != 0) return null;
|
||||||
|
|
||||||
if (blockType == 0) {
|
if (blockType == 1) {
|
||||||
c += 32; // recovery_key
|
if (c + 32 > recordLen) return null;
|
||||||
} else if (blockType == 1) {
|
c += 32; // root_key
|
||||||
c += 32;
|
continue;
|
||||||
} else if (blockType == 2) {
|
}
|
||||||
|
if (blockType == 2) {
|
||||||
|
if (c + 32 > recordLen) return null;
|
||||||
clientKey32 = slice(raw, c, 32);
|
clientKey32 = slice(raw, c, 32);
|
||||||
c += 32;
|
c += 32;
|
||||||
} else if (blockType == 3) {
|
continue;
|
||||||
int count = u8(raw, c++);
|
|
||||||
for (int j = 0; j < count; j++) {
|
|
||||||
c += 1; // blockchain_type
|
|
||||||
int bchLen = u8(raw, c++);
|
|
||||||
blockchainName = new String(raw, c, bchLen, StandardCharsets.UTF_8);
|
|
||||||
c += bchLen;
|
|
||||||
blockchainKey32 = slice(raw, c, 32);
|
|
||||||
c += 32;
|
|
||||||
paidLimitBytes = u64le(raw, c);
|
|
||||||
c += 8;
|
|
||||||
c += 8; // used_bytes
|
|
||||||
c += 4; // last_block_number
|
|
||||||
c += 32; // last_block_hash
|
|
||||||
c += 64; // last_block_signature
|
|
||||||
int arweavePresent = u8(raw, c++);
|
|
||||||
if (arweavePresent == 1) {
|
|
||||||
int arLen = u8(raw, c++);
|
|
||||||
c += arLen;
|
|
||||||
} else if (arweavePresent != 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (blockType == 30) {
|
|
||||||
int isServer = u8(raw, c++);
|
|
||||||
if (isServer == 1) {
|
|
||||||
c += 1; // address_format_type
|
|
||||||
c += 1; // address_format_version
|
|
||||||
int addrLen = u8(raw, c++);
|
|
||||||
c += addrLen;
|
|
||||||
int syncCount = u8(raw, c++);
|
|
||||||
for (int j = 0; j < syncCount; j++) {
|
|
||||||
int n = u8(raw, c++);
|
|
||||||
c += n;
|
|
||||||
}
|
|
||||||
} else if (isServer != 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else if (blockType == 40) {
|
|
||||||
int accessCount = u8(raw, c++);
|
|
||||||
for (int j = 0; j < accessCount; j++) {
|
|
||||||
int n = u8(raw, c++);
|
|
||||||
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
|
||||||
c += n;
|
|
||||||
String normalizedAccessServerLogin = normalizeLogin(accessServerLogin);
|
|
||||||
if (normalizedAccessServerLogin == null || accessServers.contains(normalizedAccessServerLogin)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
|
|
||||||
accessServers.add(normalizedAccessServerLogin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (blockType == 50) {
|
|
||||||
int sessionsMode = u8(raw, c++);
|
|
||||||
if (sessionsMode != 1 && sessionsMode != 10) return null;
|
|
||||||
int sessionsCount = u8(raw, c++);
|
|
||||||
if (sessionsCount > 64) return null;
|
|
||||||
for (int j = 0; j < sessionsCount; j++) {
|
|
||||||
int sessionType = u8(raw, c++);
|
|
||||||
int sessionVersion = u8(raw, c++);
|
|
||||||
int n = u8(raw, c++);
|
|
||||||
String sessionName = new String(raw, c, n, StandardCharsets.UTF_8);
|
|
||||||
c += n;
|
|
||||||
byte[] sessionPubKey32 = slice(raw, c, 32);
|
|
||||||
c += 32;
|
|
||||||
sessions.add(new ParsedSessionRecord(
|
|
||||||
sessionType,
|
|
||||||
sessionVersion,
|
|
||||||
sessionName,
|
|
||||||
sessionPubKey32
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} else if (blockType == 70) {
|
|
||||||
c += 1;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c > recordLen) return null;
|
if (c + 2 > recordLen) return null;
|
||||||
|
int payloadLen = u16le(raw, c);
|
||||||
|
c += 2;
|
||||||
|
int payloadEnd = c + payloadLen;
|
||||||
|
if (payloadEnd < c || payloadEnd > recordLen) return null;
|
||||||
|
|
||||||
|
if (blockType == 3) {
|
||||||
|
if (c + 2 > payloadEnd) return null;
|
||||||
|
forkCount = u16le(raw, c);
|
||||||
|
c += 2;
|
||||||
|
if (forkCount <= 0) return null;
|
||||||
|
for (int j = 0; j < forkCount; j++) {
|
||||||
|
if (c + 44 > payloadEnd) return null;
|
||||||
|
blockchainKey32 = slice(raw, c, 32);
|
||||||
|
c += 32;
|
||||||
|
c += 8; // fork created_at_ms
|
||||||
|
paidLimitBytes = u32le(raw, c);
|
||||||
|
c += 4;
|
||||||
|
}
|
||||||
|
if (c != payloadEnd) return null;
|
||||||
|
} else if (blockType == 30) {
|
||||||
|
// Server profile is not needed for auth parsing; validate/skip its payload.
|
||||||
|
c = payloadEnd;
|
||||||
|
} else if (blockType == 40) {
|
||||||
|
if (c >= payloadEnd) return null;
|
||||||
|
int accessCount = u8(raw, c++);
|
||||||
|
if (accessCount > 1) return null;
|
||||||
|
for (int j = 0; j < accessCount; j++) {
|
||||||
|
if (c >= payloadEnd) return null;
|
||||||
|
int n = u8(raw, c++);
|
||||||
|
if (c + n > payloadEnd) return null;
|
||||||
|
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||||
|
c += n;
|
||||||
|
String normalized = normalizeLogin(accessServerLogin);
|
||||||
|
if (normalized != null && !accessServers.contains(normalized)
|
||||||
|
&& accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
|
||||||
|
accessServers.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c != payloadEnd) return null;
|
||||||
|
} else {
|
||||||
|
// Unknown variable PDA 1.2 block: forward-compatible skip by payload_len.
|
||||||
|
c = payloadEnd;
|
||||||
|
}
|
||||||
|
c = payloadEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blockchainName == null || blockchainKey32 == null || clientKey32 == null) return null;
|
if (blockchainKey32 == null || clientKey32 == null || forkCount <= 0 || forkCount > 999) return null;
|
||||||
|
String blockchainName = login + "-" + String.format(Locale.ROOT, "%03d", forkCount);
|
||||||
return new ParsedSolanaUser(
|
return new ParsedSolanaUser(
|
||||||
login,
|
login,
|
||||||
blockchainName,
|
blockchainName,
|
||||||
Base64.getEncoder().encodeToString(blockchainKey32),
|
Base64.getEncoder().encodeToString(blockchainKey32),
|
||||||
Base64.getEncoder().encodeToString(clientKey32),
|
Base64.getEncoder().encodeToString(clientKey32),
|
||||||
paidLimitBytes,
|
paidLimitBytes,
|
||||||
sessions,
|
List.of(),
|
||||||
accessServers
|
accessServers
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ParsedServerProfile parseServerProfile(byte[] raw) {
|
|
||||||
if (raw == null || raw.length < 128) return null;
|
|
||||||
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
|
|
||||||
|
|
||||||
|
private static ParsedServerProfile parseServerProfile(byte[] raw) {
|
||||||
|
if (raw == null || raw.length < 9) return null;
|
||||||
|
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
|
||||||
|
int major = u8(raw, 5);
|
||||||
|
int minor = u8(raw, 6);
|
||||||
|
if (major != 1 || minor != 2) return null;
|
||||||
|
return parseServerProfileV12(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ParsedServerProfile parseServerProfileV12(byte[] raw) {
|
||||||
|
if (raw == null || raw.length < 128) return null;
|
||||||
int recordLen = u16le(raw, 7);
|
int recordLen = u16le(raw, 7);
|
||||||
if (recordLen < 73 || recordLen > raw.length) return null;
|
if (recordLen < 73 || recordLen > raw.length) return null;
|
||||||
|
|
||||||
int c = 9;
|
int c = 9 + 8 + 8 + 4 + 32;
|
||||||
c += 8; // created_at_ms
|
if (c >= recordLen) return null;
|
||||||
c += 8; // updated_at_ms
|
|
||||||
c += 4; // record_number
|
|
||||||
c += 32; // prev_record_hash
|
|
||||||
|
|
||||||
int loginLen = u8(raw, c++);
|
int loginLen = u8(raw, c++);
|
||||||
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
||||||
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
||||||
c += loginLen;
|
c += loginLen;
|
||||||
|
if (c >= recordLen) return null;
|
||||||
int blocksCount = u8(raw, c++);
|
int blocksCount = u8(raw, c++);
|
||||||
|
|
||||||
boolean isServer = false;
|
boolean isServer = false;
|
||||||
String serverAddress = "";
|
String serverAddress = "";
|
||||||
List<String> syncServers = new ArrayList<>();
|
|
||||||
|
|
||||||
for (int i = 0; i < blocksCount; i++) {
|
for (int i = 0; i < blocksCount; i++) {
|
||||||
|
if (c + 2 > recordLen) return null;
|
||||||
int blockType = u8(raw, c++);
|
int blockType = u8(raw, c++);
|
||||||
int blockVer = u8(raw, c++);
|
int blockVer = u8(raw, c++);
|
||||||
if (blockVer != 0) return null;
|
if (blockVer != 0) return null;
|
||||||
|
if (blockType == 1 || blockType == 2) {
|
||||||
if (blockType == 0 || blockType == 1 || blockType == 2) {
|
if (c + 32 > recordLen) return null;
|
||||||
c += 32;
|
c += 32;
|
||||||
} else if (blockType == 3) {
|
continue;
|
||||||
int count = u8(raw, c++);
|
}
|
||||||
for (int j = 0; j < count; j++) {
|
if (c + 2 > recordLen) return null;
|
||||||
c += 1; // blockchain_type
|
int payloadLen = u16le(raw, c);
|
||||||
int bchLen = u8(raw, c++);
|
c += 2;
|
||||||
c += bchLen;
|
int payloadEnd = c + payloadLen;
|
||||||
c += 32; // blockchain pubkey
|
if (payloadEnd < c || payloadEnd > recordLen) return null;
|
||||||
c += 8; // paid_limit_bytes
|
|
||||||
c += 8; // used_bytes
|
if (blockType == 30) {
|
||||||
c += 4; // last_block_number
|
if (c >= payloadEnd) return null;
|
||||||
c += 32; // last_block_hash
|
int addressCount = u8(raw, c++);
|
||||||
c += 64; // last_block_signature
|
if (addressCount != 1) return null;
|
||||||
int arweavePresent = u8(raw, c++);
|
for (int j = 0; j < addressCount; j++) {
|
||||||
if (arweavePresent == 1) {
|
if (c + 3 > payloadEnd) return null;
|
||||||
int arLen = u8(raw, c++);
|
|
||||||
c += arLen;
|
|
||||||
} else if (arweavePresent != 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (blockType == 30) {
|
|
||||||
int isServerValue = u8(raw, c++);
|
|
||||||
if (isServerValue == 1) {
|
|
||||||
isServer = true;
|
|
||||||
c += 1; // address_format_type
|
c += 1; // address_format_type
|
||||||
c += 1; // address_format_version
|
c += 1; // address_format_version
|
||||||
int addrLen = u8(raw, c++);
|
int n = u8(raw, c++);
|
||||||
serverAddress = new String(raw, c, addrLen, StandardCharsets.UTF_8);
|
if (c + n > payloadEnd) return null;
|
||||||
c += addrLen;
|
String address = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||||
int syncCount = u8(raw, c++);
|
c += n;
|
||||||
for (int j = 0; j < syncCount; j++) {
|
if (!isServer) {
|
||||||
int n = u8(raw, c++);
|
isServer = true;
|
||||||
String syncLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
serverAddress = address;
|
||||||
c += n;
|
|
||||||
syncServers.add(normalizeLogin(syncLogin));
|
|
||||||
}
|
}
|
||||||
} else if (isServerValue != 0) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
} else if (blockType == 40) {
|
if (c != payloadEnd) return null;
|
||||||
int accessCount = u8(raw, c++);
|
|
||||||
for (int j = 0; j < accessCount; j++) {
|
|
||||||
int n = u8(raw, c++);
|
|
||||||
c += n;
|
|
||||||
}
|
|
||||||
} else if (blockType == 50) {
|
|
||||||
int sessionsMode = u8(raw, c++);
|
|
||||||
if (sessionsMode != 1 && sessionsMode != 10) return null;
|
|
||||||
int sessionsCount = u8(raw, c++);
|
|
||||||
if (sessionsCount > 64) return null;
|
|
||||||
for (int j = 0; j < sessionsCount; j++) {
|
|
||||||
c += 1; // session_type
|
|
||||||
c += 1; // session_version
|
|
||||||
int n = u8(raw, c++);
|
|
||||||
c += n;
|
|
||||||
c += 32;
|
|
||||||
}
|
|
||||||
} else if (blockType == 70) {
|
|
||||||
c += 1;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
c = payloadEnd;
|
||||||
if (c > recordLen) return null;
|
|
||||||
}
|
}
|
||||||
|
return new ParsedServerProfile(login, isServer, serverAddress, List.of());
|
||||||
return new ParsedServerProfile(login, isServer, serverAddress, syncServers);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static String normalizeLogin(String login) {
|
private static String normalizeLogin(String login) {
|
||||||
if (login == null) return null;
|
if (login == null) return null;
|
||||||
String s = login.trim();
|
String s = login.trim();
|
||||||
@@ -395,6 +343,12 @@ public final class SolanaUserPdaImportService {
|
|||||||
private static int u16le(byte[] b, int o) {
|
private static int u16le(byte[] b, int o) {
|
||||||
return (b[o] & 0xFF) | ((b[o + 1] & 0xFF) << 8);
|
return (b[o] & 0xFF) | ((b[o + 1] & 0xFF) << 8);
|
||||||
}
|
}
|
||||||
|
private static long u32le(byte[] b, int o) {
|
||||||
|
return ((long) b[o] & 0xFFL)
|
||||||
|
| (((long) b[o + 1] & 0xFFL) << 8)
|
||||||
|
| (((long) b[o + 2] & 0xFFL) << 16)
|
||||||
|
| (((long) b[o + 3] & 0xFFL) << 24);
|
||||||
|
}
|
||||||
private static long u64le(byte[] b, int o) {
|
private static long u64le(byte[] b, int o) {
|
||||||
long out = 0L;
|
long out = 0L;
|
||||||
for (int i = 0; i < 8; i++) out |= ((long) (b[o + i] & 0xFF)) << (8 * i);
|
for (int i = 0; i < 8; i++) out |= ((long) (b[o + i] & 0xFF)) << (8 * i);
|
||||||
|
|||||||
+226
-39
@@ -7,6 +7,7 @@ import blockchain.body.BodyHasLine;
|
|||||||
import blockchain.body.BodyHasTarget;
|
import blockchain.body.BodyHasTarget;
|
||||||
import blockchain.body.ConnectionBody;
|
import blockchain.body.ConnectionBody;
|
||||||
import blockchain.body.CreateChannelBody;
|
import blockchain.body.CreateChannelBody;
|
||||||
|
import blockchain.body.HeaderBody;
|
||||||
import blockchain.body.StatusActionBody;
|
import blockchain.body.StatusActionBody;
|
||||||
import blockchain.body.TextReplyBody;
|
import blockchain.body.TextReplyBody;
|
||||||
import blockchain.body.TextLineBody;
|
import blockchain.body.TextLineBody;
|
||||||
@@ -26,6 +27,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R
|
|||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser;
|
import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import server.sync.AddBlockSyncService;
|
import server.sync.AddBlockSyncService;
|
||||||
import shine.db.channels.ChannelNameRules;
|
import shine.db.channels.ChannelNameRules;
|
||||||
@@ -42,6 +44,7 @@ import shine.db.entities.UserParamEntry;
|
|||||||
import utils.blockchain.BlockchainNameUtil;
|
import utils.blockchain.BlockchainNameUtil;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
@@ -96,11 +99,25 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||||
lock.lock();
|
lock.lock();
|
||||||
try {
|
try {
|
||||||
|
try {
|
||||||
|
if (isKeyRotationActive(blockchainName)) {
|
||||||
|
BlockchainStateEntry currentState = stateDAO.getByBlockchainName(blockchainName);
|
||||||
|
int lastNum = currentState != null ? currentState.getLastBlockNumber() : -1;
|
||||||
|
String lastHash = currentState != null ? toHex(currentState.getLastBlockHash()) : "";
|
||||||
|
return error(req, 423, "key_rotation_in_progress", lastNum, lastHash);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("AddBlock: не удалось проверить key rotation status для {}", blockchainName, e);
|
||||||
|
return error(req, WireCodes.Status.INTERNAL_ERROR, "key_rotation_check_failed", -1, "");
|
||||||
|
}
|
||||||
|
|
||||||
AddBlockResult r = addBlock(
|
AddBlockResult r = addBlock(
|
||||||
blockchainName,
|
blockchainName,
|
||||||
req.getBlockNumber(), // старое поле, пока оставляем
|
req.getBlockNumber(), // старое поле, пока оставляем
|
||||||
req.getPrevBlockHash(), // старое поле, пока оставляем
|
req.getPrevBlockHash(), // старое поле, пока оставляем
|
||||||
req.getBlockBytesB64()
|
req.getBlockBytesB64(),
|
||||||
|
true,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
// ✅ УСПЕХ: как раньше
|
// ✅ УСПЕХ: как раньше
|
||||||
@@ -148,6 +165,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isKeyRotationActive(String blockchainName) throws java.sql.SQLException {
|
||||||
|
String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName);
|
||||||
|
if (login == null || login.isBlank()) return false;
|
||||||
|
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT rotation_status
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
WHERE normalized_login = LOWER(BTRIM(?))
|
||||||
|
LIMIT 1
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() && !"NONE".equals(rs.getString(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String humanMessage(String code) {
|
private static String humanMessage(String code) {
|
||||||
if (code == null) return "Ошибка добавления блока";
|
if (code == null) return "Ошибка добавления блока";
|
||||||
return switch (code) {
|
return switch (code) {
|
||||||
@@ -160,6 +194,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
case "limit_exceeded" -> "Превышен лимит размера блокчейна";
|
case "limit_exceeded" -> "Превышен лимит размера блокчейна";
|
||||||
case "limit_check_failed" -> "Ошибка проверки лимита размера";
|
case "limit_check_failed" -> "Ошибка проверки лимита размера";
|
||||||
case "bad_block_format" -> "Некорректный формат блока";
|
case "bad_block_format" -> "Некорректный формат блока";
|
||||||
|
case "bad_app_tag" -> "Некорректный тестовый тег ANS-104";
|
||||||
|
case "bad_channel_tag" -> "Некорректный тег канала ANS-104";
|
||||||
case "bad_block_body" -> "Некорректное тело блока";
|
case "bad_block_body" -> "Некорректное тело блока";
|
||||||
case "bad_channel_name" -> "Некорректное название канала";
|
case "bad_channel_name" -> "Некорректное название канала";
|
||||||
case "bad_block_number" -> "Некорректный номер блока";
|
case "bad_block_number" -> "Некорректный номер блока";
|
||||||
@@ -178,6 +214,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
|
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
|
||||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||||
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||||
|
case "key_rotation_in_progress" -> "Сейчас выполняется смена ключей; обычные новые блоки временно запрещены";
|
||||||
|
case "key_rotation_check_failed" -> "Не удалось проверить состояние смены ключей";
|
||||||
default -> "Ошибка: " + code;
|
default -> "Ошибка: " + code;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -186,7 +224,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
String blockchainName,
|
String blockchainName,
|
||||||
int globalNumberFromReq,
|
int globalNumberFromReq,
|
||||||
String prevGlobalHashHexFromReq,
|
String prevGlobalHashHexFromReq,
|
||||||
String blockBytesB64
|
String blockBytesB64,
|
||||||
|
boolean replicateAfterWrite,
|
||||||
|
boolean publishToArweave
|
||||||
) {
|
) {
|
||||||
if (blockchainName == null || blockchainName.isBlank()) {
|
if (blockchainName == null || blockchainName.isBlank()) {
|
||||||
log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq);
|
log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq);
|
||||||
@@ -268,6 +308,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex);
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!block.getDataItem().hasTag("App", "test5590")) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_app_tag", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
|
||||||
// body.check()
|
// body.check()
|
||||||
try {
|
try {
|
||||||
block.body.check();
|
block.body.check();
|
||||||
@@ -277,6 +321,18 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex);
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Canonical chain bootstrap: block #0 is always HEADER.
|
||||||
|
if (serverLastNum == -1) {
|
||||||
|
if (!(block.body instanceof HeaderBody headerBody)) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "header_required_as_block_zero", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
if (!login.equals(headerBody.login)) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "header_login_mismatch", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
} else if (block.body instanceof HeaderBody) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "header_must_be_block_zero", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
|
||||||
// Репосты временно отключены до будущей реализации.
|
// Репосты временно отключены до будущей реализации.
|
||||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||||
if ((block.type & 0xFFFF) == 1
|
if ((block.type & 0xFFFF) == 1
|
||||||
@@ -397,6 +453,22 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
|
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Channel DataItems are indexed by a signed canonical channel slug tag: c_test5590=<slug>.
|
||||||
|
try {
|
||||||
|
String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry);
|
||||||
|
String actualChannelSlug = block.getDataItem().tagValue("c_test5590");
|
||||||
|
if (expectedChannelSlug != null) {
|
||||||
|
if (!expectedChannelSlug.equals(actualChannelSlug)) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
} else if (actualChannelSlug != null) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("AddBlock: cannot validate channel tag blockchainName={} block={}", blockchainName, block.blockNumber, e);
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
|
||||||
if ((block.type & 0xFFFF) == 1
|
if ((block.type & 0xFFFF) == 1
|
||||||
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
try {
|
try {
|
||||||
@@ -411,6 +483,20 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((block.type & 0xFFFF) == 1
|
||||||
|
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||||
|
try {
|
||||||
|
String editError = validateEditReplyTarget(blockchainName, block);
|
||||||
|
if (editError != null) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, editError, serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("AddBlock: edit_reply_target_check_failed (login={}, blockchainName={}, blockNumber={})",
|
||||||
|
login, blockchainName, block.blockNumber, e);
|
||||||
|
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ((block.type & 0xFFFF) == 5) {
|
if ((block.type & 0xFFFF) == 5) {
|
||||||
try {
|
try {
|
||||||
String statusError = validateStatusActionTarget(block);
|
String statusError = validateStatusActionTarget(block);
|
||||||
@@ -454,7 +540,12 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex);
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) подпись по hash32(preimage)
|
if (serverLastNum == -1 && block.body instanceof HeaderBody headerBody
|
||||||
|
&& !Arrays.equals(headerBody.initialBlockchainKey32, pubKey32)) {
|
||||||
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "header_initial_key_mismatch", serverLastNum, serverLastHashHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) ANS-104 Ed25519 подпись и owner
|
||||||
boolean sigOk;
|
boolean sigOk;
|
||||||
try {
|
try {
|
||||||
sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32);
|
sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32);
|
||||||
@@ -482,7 +573,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
prevLineHash32 = bl.prevLineBlockHash32();
|
prevLineHash32 = bl.prevLineBlockHash32();
|
||||||
thisLineNumber = bl.lineSeq();
|
thisLineNumber = bl.lineSeq();
|
||||||
|
|
||||||
// Нормализация: -1 не пишем в БД (для совместимости со старым TextBody)
|
// Нормализация: пустой line anchor (-1) не пишем в DB projection
|
||||||
if (prevLineNumber != null && prevLineNumber == -1) {
|
if (prevLineNumber != null && prevLineNumber == -1) {
|
||||||
lineCode = null;
|
lineCode = null;
|
||||||
prevLineNumber = null;
|
prevLineNumber = null;
|
||||||
@@ -512,11 +603,11 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8) сформировать запись и записать (DB + state + файл)
|
// 8) сформировать запись и атомарно записать в PostgreSQL вместе с новым state
|
||||||
try {
|
try {
|
||||||
BlockEntry be = new BlockEntry();
|
BlockEntry be = new BlockEntry();
|
||||||
be.setLogin(login);
|
be.setLogin(login);
|
||||||
be.setBchName(blockchainName);
|
be.setForkNumber(java.util.Objects.requireNonNull(BlockchainNameUtil.forkNumberFromBlockchainName(blockchainName), "invalid blockchainName fork"));
|
||||||
|
|
||||||
be.setBlockNumber(block.blockNumber);
|
be.setBlockNumber(block.blockNumber);
|
||||||
be.setMsgType(block.type & 0xFFFF);
|
be.setMsgType(block.type & 0xFFFF);
|
||||||
@@ -525,6 +616,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
be.setBlockBytes(block.toBytes());
|
be.setBlockBytes(block.toBytes());
|
||||||
be.setBlockHash(block.getHash32());
|
be.setBlockHash(block.getHash32());
|
||||||
be.setBlockSignature(block.getSignature64());
|
be.setBlockSignature(block.getSignature64());
|
||||||
|
be.setDataItemId(block.getDataItemId32());
|
||||||
|
be.setArweavePublishPending(publishToArweave);
|
||||||
|
|
||||||
// line columns (optional)
|
// line columns (optional)
|
||||||
be.setLineCode(lineCode);
|
be.setLineCode(lineCode);
|
||||||
@@ -532,25 +625,26 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
be.setPrevLineHash(prevLineHash32);
|
be.setPrevLineHash(prevLineHash32);
|
||||||
be.setThisLineNumber(thisLineNumber);
|
be.setThisLineNumber(thisLineNumber);
|
||||||
|
|
||||||
// target columns (optional)
|
// target columns (optional). Signed identity is login + blockNumber + blockHash.
|
||||||
|
// toForkNumber is historical metadata only and never participates in target identity.
|
||||||
if (block.body instanceof BodyHasTarget t) {
|
if (block.body instanceof BodyHasTarget t) {
|
||||||
String targetBchName = t.toBchName();
|
|
||||||
int type = block.type & 0xFFFF;
|
int type = block.type & 0xFFFF;
|
||||||
int sub = block.subType & 0xFFFF;
|
int sub = block.subType & 0xFFFF;
|
||||||
boolean isTextEdit = type == 1
|
boolean isTextEdit = type == 1
|
||||||
&& (sub == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || sub == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
&& (sub == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || sub == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
||||||
if (isTextEdit && (targetBchName == null || targetBchName.isBlank())) {
|
|
||||||
targetBchName = blockchainName;
|
String targetLogin = t.toLogin();
|
||||||
|
Integer targetForkNumber = t.toForkNumber();
|
||||||
|
if (isTextEdit) {
|
||||||
|
// EDIT target is compact and always belongs to this physical fork.
|
||||||
|
targetLogin = login;
|
||||||
|
targetForkNumber = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
be.setToLogin(t.toLogin());
|
be.setToLogin(targetLogin);
|
||||||
be.setToBchName(targetBchName);
|
be.setToForkNumber(targetForkNumber);
|
||||||
be.setToBlockNumber(t.toBlockGlobalNumber());
|
be.setToBlockNumber(t.toBlockGlobalNumber());
|
||||||
be.setToBlockHash(t.toBlockHashBytes());
|
be.setToBlockHash(t.toBlockHashBytes());
|
||||||
|
|
||||||
if (isTextEdit && (be.getToLogin() == null || be.getToLogin().isBlank()) && targetBchName != null && !targetBchName.isBlank()) {
|
|
||||||
be.setToLogin(BlockchainNameUtil.loginFromBlockchainName(targetBchName));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// edit helper (optional): если TEXT_EDIT_* — это "редактирование блока цели"
|
// edit helper (optional): если TEXT_EDIT_* — это "редактирование блока цели"
|
||||||
@@ -586,9 +680,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
|
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
|
||||||
dbWriter.appendBlockAndState(
|
List<String> counterChangedLogins = dbWriter.appendBlockAndState(
|
||||||
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
|
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
|
||||||
|
|
||||||
|
for (String changedLogin : counterChangedLogins) {
|
||||||
|
UserCountersSupport.pushChanged(changedLogin);
|
||||||
|
}
|
||||||
|
|
||||||
if (chat200CreateSeed != null) {
|
if (chat200CreateSeed != null) {
|
||||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||||
}
|
}
|
||||||
@@ -607,15 +705,80 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}",
|
log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}",
|
||||||
login, blockchainName, block.blockNumber, newHashHex);
|
login, blockchainName, block.blockNumber, newHashHex);
|
||||||
|
|
||||||
addBlockSyncService.replicateAsync(blockchainName, block.blockNumber);
|
if (replicateAfterWrite) {
|
||||||
|
addBlockSyncService.replicateAsync(blockchainName, block.blockNumber);
|
||||||
|
}
|
||||||
|
|
||||||
return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex);
|
return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Архивный импорт использует ту же проверку и запись, что обычный AddBlock,
|
||||||
|
* но не запускает повторную межсерверную репликацию уже импортированного блока.
|
||||||
|
*/
|
||||||
|
public ArweaveImportResult addBlockFromArweave(String blockchainName, byte[] rawBlockBytes) {
|
||||||
|
if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) {
|
||||||
|
return new ArweaveImportResult(false, "chain_resync_in_progress", -1, "");
|
||||||
|
}
|
||||||
|
if (rawBlockBytes == null || rawBlockBytes.length == 0) {
|
||||||
|
return new ArweaveImportResult(false, "empty_block", -1, "");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
BchBlockEntry probe = new BchBlockEntry(rawBlockBytes);
|
||||||
|
if (blocksDAO.existsByDataItemId(probe.getDataItemId32())) {
|
||||||
|
return new ArweaveImportResult(true, "already_present", probe.blockNumber, toHex(probe.getHash32()));
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Parsed again below to return the canonical bad_block_format result.
|
||||||
|
}
|
||||||
|
final BchBlockEntry parsed;
|
||||||
|
try {
|
||||||
|
parsed = new BchBlockEntry(rawBlockBytes);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new ArweaveImportResult(false, "bad_block_format", -1, "");
|
||||||
|
}
|
||||||
|
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
AddBlockResult r = addBlock(
|
||||||
|
blockchainName,
|
||||||
|
parsed.blockNumber,
|
||||||
|
toHex(parsed.prevHash32),
|
||||||
|
Base64Ws.encode(rawBlockBytes),
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
return new ArweaveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex);
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ArweaveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {}
|
||||||
|
|
||||||
/* ===================================================================== */
|
/* ===================================================================== */
|
||||||
/* ====================== Helpers ====================================== */
|
/* ====================== Helpers ====================================== */
|
||||||
/* ===================================================================== */
|
/* ===================================================================== */
|
||||||
|
|
||||||
|
private String expectedChannelSlug(String blockchainName, BchBlockEntry block, ChannelNameStateEntry justCreated) throws Exception {
|
||||||
|
if (block.body instanceof CreateChannelBody) {
|
||||||
|
return justCreated == null ? null : justCreated.getSlug();
|
||||||
|
}
|
||||||
|
int type = block.type & 0xffff;
|
||||||
|
int sub = block.subType & 0xffff;
|
||||||
|
boolean channelBlock = type == 1 && (
|
||||||
|
sub == (MsgSubType.TEXT_POST & 0xffff)
|
||||||
|
|| sub == (MsgSubType.TEXT_REPOST & 0xffff)
|
||||||
|
|| sub == (MsgSubType.TEXT_CHANNEL_META & 0xffff)
|
||||||
|
|| sub == (MsgSubType.TEXT_ENTRYPOINT & 0xffff)
|
||||||
|
|| sub == (MsgSubType.TEXT_EXERCISE & 0xffff)
|
||||||
|
|| sub == (MsgSubType.TEXT_SERVICE & 0xffff)
|
||||||
|
|| sub == (MsgSubType.TEXT_COURSE & 0xffff)
|
||||||
|
|| sub == (MsgSubType.TEXT_EDIT_POST & 0xffff));
|
||||||
|
if (!channelBlock || !(block.body instanceof BodyHasLine line) || line.lineCode() <= 0) return null;
|
||||||
|
return channelNameStateDAO.getSlugByOwnerAndRoot(blockchainName, line.lineCode());
|
||||||
|
}
|
||||||
|
|
||||||
private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) {
|
private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) {
|
||||||
if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||||
&& subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null;
|
&& subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null;
|
||||||
@@ -676,41 +839,58 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String validateEditPostTarget(String ownerBch, BchBlockEntry block) throws Exception {
|
private String validateEditPostTarget(String ownerBch, BchBlockEntry block) throws Exception {
|
||||||
if (!(block.body instanceof TextLineBody editBody)) return null;
|
if (!(block.body instanceof TextLineBody editBody)) return "bad_block_body";
|
||||||
Integer targetBlockNumber = editBody.toBlockGlobalNumber();
|
Integer targetBlockNumber = editBody.toBlockGlobalNumber();
|
||||||
byte[] targetHash = editBody.toBlockHashBytes();
|
byte[] targetHash = editBody.toBlockHashBytes();
|
||||||
if (targetBlockNumber == null || targetHash == null || targetHash.length != 32) return "bad_block_body";
|
if (targetBlockNumber == null || targetHash == null || targetHash.length != 32) return "bad_block_body";
|
||||||
|
|
||||||
BlockEntry target = blocksDAO.getByNumber(ownerBch, targetBlockNumber);
|
BlockEntry target = blocksDAO.getByNumber(ownerBch, targetBlockNumber);
|
||||||
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
|
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
|
||||||
return null;
|
return "edit_target_not_found";
|
||||||
}
|
}
|
||||||
if (target.getMsgType() != 1) return null;
|
if (target.getMsgType() != 1 || target.getMsgSubType() != (MsgSubType.TEXT_POST & 0xFFFF)) {
|
||||||
if (target.getMsgSubType() == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)) {
|
return "edit_post_target_must_be_text_post";
|
||||||
return "entrypoint_edit_forbidden";
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateEditReplyTarget(String ownerBch, BchBlockEntry block) throws Exception {
|
||||||
|
if (!(block.body instanceof TextReplyBody editBody)) return "bad_block_body";
|
||||||
|
Integer targetBlockNumber = editBody.toBlockGlobalNumber();
|
||||||
|
byte[] targetHash = editBody.toBlockHashBytes();
|
||||||
|
if (targetBlockNumber == null || targetHash == null || targetHash.length != 32) return "bad_block_body";
|
||||||
|
|
||||||
|
BlockEntry target = blocksDAO.getByNumber(ownerBch, targetBlockNumber);
|
||||||
|
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
|
||||||
|
return "edit_target_not_found";
|
||||||
|
}
|
||||||
|
if (target.getMsgType() != 1 || target.getMsgSubType() != (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||||
|
return "edit_reply_target_must_be_text_reply";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String validateStatusActionTarget(BchBlockEntry block) throws Exception {
|
private String validateStatusActionTarget(BchBlockEntry block) throws Exception {
|
||||||
if (!(block.body instanceof StatusActionBody statusBody)) return "bad_block_body";
|
if (!(block.body instanceof StatusActionBody statusBody)) return "bad_block_body";
|
||||||
String targetBch = statusBody.toBchName();
|
String targetLogin = statusBody.toLogin();
|
||||||
|
Integer targetFork = statusBody.toForkNumber();
|
||||||
Integer targetBlockNumber = statusBody.toBlockGlobalNumber();
|
Integer targetBlockNumber = statusBody.toBlockGlobalNumber();
|
||||||
byte[] targetHash = statusBody.toBlockHashBytes();
|
byte[] targetHash = statusBody.toBlockHashBytes();
|
||||||
if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
|
if (targetLogin == null || targetLogin.isBlank() || targetFork == null
|
||||||
|
|| targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
|
||||||
return "bad_block_body";
|
return "bad_block_body";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// forkNumber is historical only. It is used here solely as a physical lookup hint.
|
||||||
|
if (targetFork <= 0) return null;
|
||||||
|
String targetBch = BlockchainNameUtil.blockchainName(targetLogin, targetFork);
|
||||||
BlockEntry target = blocksDAO.getByNumber(targetBch, targetBlockNumber);
|
BlockEntry target = blocksDAO.getByNumber(targetBch, targetBlockNumber);
|
||||||
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
|
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
int statusSubType = block.subType & 0xFFFF;
|
int statusSubType = block.subType & 0xFFFF;
|
||||||
if (statusSubType == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) {
|
if (statusSubType == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) {
|
||||||
if (target.getMsgType() != 5) {
|
return target.getMsgType() == 5 ? null : "status_confirmed_target_must_be_status_action";
|
||||||
return "status_confirmed_target_must_be_status_action";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
if (target.getMsgType() != 1) return "status_action_target_not_allowed";
|
if (target.getMsgType() != 1) return "status_action_target_not_allowed";
|
||||||
|
|
||||||
@@ -916,16 +1096,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection notifications are intentionally modeled as a generic kind.
|
if (msgType == 3 && block.body instanceof ConnectionBody) {
|
||||||
// Current UI surfaces FRIEND and CLOSE_FRIEND here; other reserved relation types stay silent.
|
boolean personalConnection = msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||||
if (msgType == 3
|
|| msgSubType == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||||
&& (msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
|| msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||||
|| msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF))
|
|| msgSubType == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||||
&& block.body instanceof ConnectionBody) {
|
|| msgSubType == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
|| msgSubType == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||||
entry.setNotificationKind("connection");
|
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||||
entry.setSourceText("");
|
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||||
return entry;
|
boolean channelEvent = msgSubType == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||||
|
|| msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF);
|
||||||
|
if (personalConnection || channelEvent) {
|
||||||
|
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||||
|
entry.setNotificationKind(channelEvent ? "event" : "connection");
|
||||||
|
entry.setSourceText("");
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+49
-116
@@ -4,6 +4,8 @@ import blockchain.BchBlockEntry;
|
|||||||
import shine.db.dao.BlockchainStateDAO;
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
import shine.db.dao.BlocksDAO;
|
import shine.db.dao.BlocksDAO;
|
||||||
import shine.db.dao.ChannelNameStateDAO;
|
import shine.db.dao.ChannelNameStateDAO;
|
||||||
|
import shine.db.dao.ChannelReadStateDAO;
|
||||||
|
import shine.db.MsgSubType;
|
||||||
import shine.db.dao.UserParamsDAO;
|
import shine.db.dao.UserParamsDAO;
|
||||||
import shine.db.dao.UserNotificationsStateDAO;
|
import shine.db.dao.UserNotificationsStateDAO;
|
||||||
import shine.db.entities.BlockchainStateEntry;
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
@@ -11,40 +13,24 @@ import shine.db.entities.BlockEntry;
|
|||||||
import shine.db.entities.ChannelNameStateEntry;
|
import shine.db.entities.ChannelNameStateEntry;
|
||||||
import shine.db.entities.UserParamEntry;
|
import shine.db.entities.UserParamEntry;
|
||||||
import shine.db.entities.UserNotificationEntry;
|
import shine.db.entities.UserNotificationEntry;
|
||||||
import utils.files.FileStoreUtil;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.HexFormat;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/** Stores the complete ANS-104 DataItem and all derived SHiNE state atomically in PostgreSQL.
|
||||||
* BlockchainWriter — запись блока в БД и формирование файловой версии цепочки.
|
* Local .bch/tmp/marker files are intentionally not used.
|
||||||
*
|
|
||||||
* Текущая схема:
|
|
||||||
* 1) собираем <blockchainName>.tmp_bch как готовый кандидат на замену основного файла;
|
|
||||||
* 2) пишем маленький sidecar <blockchainName>.write_check с blockNumber/blockHash;
|
|
||||||
* 3) создаём пустой marker <blockchainName>.write_pending;
|
|
||||||
* 4) выполняем одну SQL-транзакцию;
|
|
||||||
* 5) после commit атомарно заменяем основной .bch из tmp;
|
|
||||||
* 6) убираем временные файлы.
|
|
||||||
*
|
|
||||||
* Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись:
|
|
||||||
* - если marker отсутствует, а tmp/check остались, это мусор;
|
|
||||||
* - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор.
|
|
||||||
*/
|
*/
|
||||||
public final class BlockchainWriter {
|
public final class BlockchainWriter {
|
||||||
|
|
||||||
private static final HexFormat HEX = HexFormat.of();
|
|
||||||
|
|
||||||
private final BlocksDAO blocksDAO;
|
private final BlocksDAO blocksDAO;
|
||||||
private final BlockchainStateDAO stateDAO;
|
private final BlockchainStateDAO stateDAO;
|
||||||
private final ChannelNameStateDAO channelNameStateDAO;
|
private final ChannelNameStateDAO channelNameStateDAO;
|
||||||
private final UserParamsDAO userParamsDAO;
|
private final UserParamsDAO userParamsDAO;
|
||||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
|
||||||
|
|
||||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||||
BlockchainStateDAO stateDAO,
|
BlockchainStateDAO stateDAO,
|
||||||
@@ -58,7 +44,11 @@ public final class BlockchainWriter {
|
|||||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void appendBlockAndState(String blockchainName,
|
/**
|
||||||
|
* Stores the complete ANS-104 DataItem and all SQL projections in one DB transaction.
|
||||||
|
* There is intentionally no .bch/tmp/marker file layer anymore.
|
||||||
|
*/
|
||||||
|
public List<String> appendBlockAndState(String blockchainName,
|
||||||
BchBlockEntry block,
|
BchBlockEntry block,
|
||||||
BlockchainStateEntry st,
|
BlockchainStateEntry st,
|
||||||
BlockEntry be,
|
BlockEntry be,
|
||||||
@@ -68,121 +58,64 @@ public final class BlockchainWriter {
|
|||||||
UserNotificationEntry notificationEntry) throws SQLException {
|
UserNotificationEntry notificationEntry) throws SQLException {
|
||||||
|
|
||||||
long nowMs = System.currentTimeMillis();
|
long nowMs = System.currentTimeMillis();
|
||||||
byte[] blockBytes = block.toBytes();
|
byte[] rawDataItem = block.toBytes();
|
||||||
byte[] candidateBytes = buildCandidateBlockchainBytes(blockchainName, blockBytes);
|
List<String> counterChangedLogins = new ArrayList<>();
|
||||||
String blockHashHex = HEX.formatHex(block.getHash32());
|
|
||||||
|
|
||||||
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
|
|
||||||
|
|
||||||
boolean committed = false;
|
|
||||||
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
|
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
|
||||||
c.setAutoCommit(false);
|
c.setAutoCommit(false);
|
||||||
try {
|
try {
|
||||||
// 1) insert block
|
|
||||||
blocksDAO.insert(c, be);
|
blocksDAO.insert(c, be);
|
||||||
|
|
||||||
// 2) update state
|
|
||||||
st.setLastBlockNumber(block.blockNumber);
|
st.setLastBlockNumber(block.blockNumber);
|
||||||
st.setLastBlockHash(block.getHash32());
|
st.setLastBlockHash(block.getHash32());
|
||||||
st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length);
|
st.setFileSizeBytes(st.getFileSizeBytes() + rawDataItem.length);
|
||||||
st.setUpdatedAtMs(nowMs);
|
st.setUpdatedAtMs(nowMs);
|
||||||
|
|
||||||
stateDAO.upsert(c, st);
|
stateDAO.upsert(c, st);
|
||||||
|
|
||||||
// 2.1) Если блок — USER_PARAM, синхронизируем снимок в users_params в той же транзакции.
|
if (userParamEntry != null) userParamsDAO.upsertIfNewer(c, userParamEntry);
|
||||||
if (userParamEntry != null) {
|
if (channelNameStateEntry != null) channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||||
userParamsDAO.upsertIfNewer(c, userParamEntry);
|
if (channelMetaUpdateEntry != null) channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||||
|
if (notificationEntry != null) userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||||
|
|
||||||
|
int msgType = block.type & 0xFFFF;
|
||||||
|
int msgSubType = block.subType & 0xFFFF;
|
||||||
|
Integer lineCode = be.getLineCode();
|
||||||
|
if (isPublicChannelPublication(msgType, msgSubType) && lineCode != null && lineCode > 0) {
|
||||||
|
String channelName = channelReadStateDAO.resolvePublicChannelNameByRoot(c, blockchainName, lineCode);
|
||||||
|
if (channelName != null && !channelName.isBlank()) {
|
||||||
|
counterChangedLogins.addAll(channelReadStateDAO.incrementUnreadForSubscribers(
|
||||||
|
c, blockchainName, channelName, MsgSubType.CONNECTION_FOLLOW, nowMs));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channelNameStateEntry != null) {
|
if (msgType == 3
|
||||||
channelNameStateDAO.insert(c, channelNameStateEntry);
|
&& msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|
||||||
}
|
&& be.getToBchName() != null && !be.getToBchName().isBlank()
|
||||||
|
&& be.getToBlockNumber() != null && be.getToBlockNumber() > 0) {
|
||||||
if (channelMetaUpdateEntry != null) {
|
if (channelReadStateDAO.deleteForUnfollowTarget(
|
||||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
c, be.getLogin(), be.getToBchName(), be.getToBlockNumber())) {
|
||||||
}
|
counterChangedLogins.add(be.getLogin());
|
||||||
|
}
|
||||||
// Notification projection is part of the same SQL transaction as the block.
|
|
||||||
// If notification indexing fails, the block/state write is rolled back as well.
|
|
||||||
if (notificationEntry != null) {
|
|
||||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
committed = true;
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
try {
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
c.rollback();
|
if (e instanceof SQLException se) throw se;
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!committed) {
|
|
||||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e instanceof SQLException se) {
|
|
||||||
throw se;
|
|
||||||
}
|
|
||||||
throw new SQLException("appendBlockAndState failed", e);
|
throw new SQLException("appendBlockAndState failed", e);
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||||
c.setAutoCommit(true);
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return counterChangedLogins;
|
||||||
// 3) После commit — атомарно подменяем основной файл.
|
|
||||||
try {
|
|
||||||
fs.atomicReplaceBlockchainFile(blockchainName);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
// marker/check/tmp оставляем для startup-recovery
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) После успешной подмены — чистим временные артефакты.
|
|
||||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
private static boolean isPublicChannelPublication(int msgType, int msgSubType) {
|
||||||
byte[] base;
|
if (msgType != 1) return false;
|
||||||
try {
|
return msgSubType == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
if (Files.exists(fs.resolveBlockchainPath(blockchainName))) {
|
|| msgSubType == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
base = fs.readBlockchain(blockchainName);
|
|| msgSubType == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
} else {
|
|| msgSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
base = new byte[0];
|
|| msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
}
|
|| msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||||
} catch (RuntimeException e) {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] out = new byte[base.length + blockBytes.length];
|
|
||||||
System.arraycopy(base, 0, out, 0, base.length);
|
|
||||||
System.arraycopy(blockBytes, 0, out, base.length, blockBytes.length);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void prepareWriteArtifacts(String blockchainName, int blockNumber, String blockHashHex, byte[] candidateBytes) {
|
|
||||||
fs.writeBlockchainTmp(blockchainName, candidateBytes);
|
|
||||||
fs.writeBlockchainWriteCheck(blockchainName, blockNumber, blockHashHex);
|
|
||||||
fs.writeBlockchainWritePendingMarker(blockchainName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void cleanupWriteArtifactsBestEffort(String blockchainName) {
|
|
||||||
deleteQuietly(() -> fs.deleteBlockchainWritePendingMarkerIfExists(blockchainName));
|
|
||||||
deleteQuietly(() -> fs.deleteBlockchainWriteCheckIfExists(blockchainName));
|
|
||||||
deleteQuietly(() -> fs.deleteBlockchainTmpFileIfExists(blockchainName));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void deleteQuietly(DeleteAction action) {
|
|
||||||
try {
|
|
||||||
action.run();
|
|
||||||
} catch (RuntimeException ignored) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
private interface DeleteAction {
|
|
||||||
void run();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain;
|
||||||
|
|
||||||
|
import blockchain.BchBlockEntry;
|
||||||
|
import blockchain.body.BodyHasTarget;
|
||||||
|
import server.logic.ws_protocol.Base64Ws;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.SolanaUserPdaCurrentDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Authenticated, paginated current-chain view used by "Мой блокчейн" and key rotation. */
|
||||||
|
public final class Net_GetMyBlockchain_Handler implements JsonMessageHandler {
|
||||||
|
private final BlocksDAO blocks = BlocksDAO.getInstance();
|
||||||
|
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
|
||||||
|
private final SolanaUserPdaCurrentDAO users = SolanaUserPdaCurrentDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_GetMyBlockchain_Request req = (Net_GetMyBlockchain_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
SolanaUserPdaCurrentEntry user = users.getByLogin(login);
|
||||||
|
if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "Текущий блокчейн пользователя не найден");
|
||||||
|
}
|
||||||
|
String bch = user.getBlockchainName();
|
||||||
|
BlockchainStateEntry state = states.getByBlockchainName(bch);
|
||||||
|
int tip = state == null ? -1 : state.getLastBlockNumber();
|
||||||
|
String tipHash = state == null ? null : hex(state.getLastBlockHash());
|
||||||
|
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(100, req.getLimit()));
|
||||||
|
int before = req.getBeforeBlock() == null || req.getBeforeBlock() < 0 ? tip : Math.min(tip, req.getBeforeBlock());
|
||||||
|
boolean includeBytes = Boolean.TRUE.equals(req.getIncludeBlockBytes());
|
||||||
|
|
||||||
|
List<BlockEntry> rows = before < 0
|
||||||
|
? List.of()
|
||||||
|
: blocks.listRangeByNumber(bch, Math.max(0, before - limit + 1), before);
|
||||||
|
Collections.reverse(rows);
|
||||||
|
List<Net_GetMyBlockchain_Response.BlockItem> items = new ArrayList<>(rows.size());
|
||||||
|
for (BlockEntry row : rows) {
|
||||||
|
BchBlockEntry parsed = new BchBlockEntry(row.getBlockBytes());
|
||||||
|
Net_GetMyBlockchain_Response.BlockItem item = new Net_GetMyBlockchain_Response.BlockItem();
|
||||||
|
item.setBlockNumber(row.getBlockNumber());
|
||||||
|
item.setBlockHash(hex(row.getBlockHash()));
|
||||||
|
item.setPrevBlockHash(hex(parsed.prevHash32));
|
||||||
|
item.setTimestampMs(parsed.timestamp * 1000L);
|
||||||
|
item.setMsgType(Short.toUnsignedInt(parsed.type));
|
||||||
|
item.setMsgSubType(Short.toUnsignedInt(parsed.subType));
|
||||||
|
item.setMsgVersion(Short.toUnsignedInt(parsed.version));
|
||||||
|
if (parsed.body instanceof BodyHasTarget target) {
|
||||||
|
item.setToLogin(target.toLogin());
|
||||||
|
item.setToForkNumber(target.toForkNumber());
|
||||||
|
item.setToBlockNumber(target.toBlockGlobalNumber());
|
||||||
|
item.setToBlockHash(hex(target.toBlockHashBytes()));
|
||||||
|
}
|
||||||
|
if (includeBytes) item.setBlockBytesB64(Base64Ws.encode(row.getBlockBytes()));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_GetMyBlockchain_Response resp = new Net_GetMyBlockchain_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setLogin(login);
|
||||||
|
resp.setBlockchainName(bch);
|
||||||
|
resp.setTipBlockNumber(tip);
|
||||||
|
resp.setTipBlockHash(tipHash);
|
||||||
|
resp.setBlocks(items);
|
||||||
|
int lowest = rows.isEmpty() ? -1 : rows.get(rows.size() - 1).getBlockNumber();
|
||||||
|
resp.setNextBeforeBlock(lowest > 0 ? lowest - 1 : null);
|
||||||
|
return resp;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "GET_MY_BLOCKCHAIN_FAILED", "Не удалось прочитать блокчейн пользователя");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String hex(byte[] bytes) {
|
||||||
|
if (bytes == null) return null;
|
||||||
|
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||||
|
for (byte b : bytes) sb.append(String.format("%02x", b & 0xff));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/** Paginated read of the authenticated user's current materialized blockchain. */
|
||||||
|
public final class Net_GetMyBlockchain_Request extends Net_Request {
|
||||||
|
/** Inclusive highest block number to return. Null/<0 means current tip. */
|
||||||
|
private Integer beforeBlock;
|
||||||
|
/** 1..100, default 50. */
|
||||||
|
private Integer limit;
|
||||||
|
/** Include complete serialized ANS-104 DataItem for rotation/UI inspection. */
|
||||||
|
private Boolean includeBlockBytes;
|
||||||
|
|
||||||
|
public Integer getBeforeBlock() { return beforeBlock; }
|
||||||
|
public void setBeforeBlock(Integer beforeBlock) { this.beforeBlock = beforeBlock; }
|
||||||
|
public Integer getLimit() { return limit; }
|
||||||
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
public Boolean getIncludeBlockBytes() { return includeBlockBytes; }
|
||||||
|
public void setIncludeBlockBytes(Boolean includeBlockBytes) { this.includeBlockBytes = includeBlockBytes; }
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class Net_GetMyBlockchain_Response extends Net_Response {
|
||||||
|
private String login;
|
||||||
|
private String blockchainName;
|
||||||
|
private int tipBlockNumber;
|
||||||
|
private String tipBlockHash;
|
||||||
|
private Integer nextBeforeBlock;
|
||||||
|
private List<BlockItem> blocks = new ArrayList<>();
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public String getBlockchainName() { return blockchainName; }
|
||||||
|
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||||
|
public int getTipBlockNumber() { return tipBlockNumber; }
|
||||||
|
public void setTipBlockNumber(int tipBlockNumber) { this.tipBlockNumber = tipBlockNumber; }
|
||||||
|
public String getTipBlockHash() { return tipBlockHash; }
|
||||||
|
public void setTipBlockHash(String tipBlockHash) { this.tipBlockHash = tipBlockHash; }
|
||||||
|
public Integer getNextBeforeBlock() { return nextBeforeBlock; }
|
||||||
|
public void setNextBeforeBlock(Integer nextBeforeBlock) { this.nextBeforeBlock = nextBeforeBlock; }
|
||||||
|
public List<BlockItem> getBlocks() { return blocks; }
|
||||||
|
public void setBlocks(List<BlockItem> blocks) { this.blocks = blocks; }
|
||||||
|
|
||||||
|
public static final class BlockItem {
|
||||||
|
private int blockNumber;
|
||||||
|
private String blockHash;
|
||||||
|
private String prevBlockHash;
|
||||||
|
private long timestampMs;
|
||||||
|
private int msgType;
|
||||||
|
private int msgSubType;
|
||||||
|
private int msgVersion;
|
||||||
|
private String toLogin;
|
||||||
|
private Integer toForkNumber;
|
||||||
|
private Integer toBlockNumber;
|
||||||
|
private String toBlockHash;
|
||||||
|
private String blockBytesB64;
|
||||||
|
|
||||||
|
public int getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
public String getBlockHash() { return blockHash; }
|
||||||
|
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||||
|
public String getPrevBlockHash() { return prevBlockHash; }
|
||||||
|
public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; }
|
||||||
|
public long getTimestampMs() { return timestampMs; }
|
||||||
|
public void setTimestampMs(long timestampMs) { this.timestampMs = timestampMs; }
|
||||||
|
public int getMsgType() { return msgType; }
|
||||||
|
public void setMsgType(int msgType) { this.msgType = msgType; }
|
||||||
|
public int getMsgSubType() { return msgSubType; }
|
||||||
|
public void setMsgSubType(int msgSubType) { this.msgSubType = msgSubType; }
|
||||||
|
public int getMsgVersion() { return msgVersion; }
|
||||||
|
public void setMsgVersion(int msgVersion) { this.msgVersion = msgVersion; }
|
||||||
|
public String getToLogin() { return toLogin; }
|
||||||
|
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||||
|
public Integer getToForkNumber() { return toForkNumber; }
|
||||||
|
public void setToForkNumber(Integer toForkNumber) { this.toForkNumber = toForkNumber; }
|
||||||
|
public Integer getToBlockNumber() { return toBlockNumber; }
|
||||||
|
public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; }
|
||||||
|
public String getToBlockHash() { return toBlockHash; }
|
||||||
|
public void setToBlockHash(String toBlockHash) { this.toBlockHash = toBlockHash; }
|
||||||
|
public String getBlockBytesB64() { return blockBytesB64; }
|
||||||
|
public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-58
@@ -3,7 +3,6 @@ package server.logic.ws_protocol.JSON.handlers.channels;
|
|||||||
import blockchain.BchBlockEntry;
|
import blockchain.BchBlockEntry;
|
||||||
import blockchain.body.BodyRecord;
|
import blockchain.body.BodyRecord;
|
||||||
import blockchain.body.CreateChannelBody;
|
import blockchain.body.CreateChannelBody;
|
||||||
import blockchain.body.TextBody;
|
|
||||||
import blockchain.body.TextLineBody;
|
import blockchain.body.TextLineBody;
|
||||||
import blockchain.body.TextReplyBody;
|
import blockchain.body.TextReplyBody;
|
||||||
import shine.db.channels.ChannelNameRules;
|
import shine.db.channels.ChannelNameRules;
|
||||||
@@ -144,38 +143,12 @@ final class ChannelsReadSupport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static String userSettingsChannelKey(String ownerBch, String channelName) {
|
|
||||||
String bch = ownerBch == null ? "" : ownerBch.trim();
|
|
||||||
String name = channelName == null ? "" : channelName.trim();
|
|
||||||
return bch + "/" + name;
|
|
||||||
}
|
|
||||||
|
|
||||||
static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException {
|
static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException {
|
||||||
if (viewerLogin == null || viewerLogin.isBlank()) return 0;
|
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank()
|
||||||
String key = userSettingsChannelKey(ownerBch, channelName);
|
|| channelName == null || channelName.isBlank()) return 0;
|
||||||
String sql = """
|
long unread = shine.db.dao.ChannelReadStateDAO.getInstance()
|
||||||
SELECT value_num
|
.getUnreadCount(c, viewerLogin, ownerBch, channelName);
|
||||||
FROM user_settings
|
return unread > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, unread);
|
||||||
WHERE LOWER(login) = LOWER(?)
|
|
||||||
AND setting_type = ?
|
|
||||||
AND setting_key = ?
|
|
||||||
LIMIT 1
|
|
||||||
""";
|
|
||||||
long lastSeen = messagesCount;
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, viewerLogin);
|
|
||||||
ps.setInt(2, 1);
|
|
||||||
ps.setString(3, key);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
|
||||||
if (rs.next()) {
|
|
||||||
long value = rs.getLong("value_num");
|
|
||||||
if (!rs.wasNull()) lastSeen = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lastSeen < 0) lastSeen = 0;
|
|
||||||
if (lastSeen > messagesCount) return 0;
|
|
||||||
return Math.max(0, messagesCount - (int) lastSeen);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||||
@@ -214,7 +187,7 @@ final class ChannelsReadSupport {
|
|||||||
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
||||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
AND to_block_number=? AND to_block_hash=?
|
||||||
ORDER BY block_number DESC
|
ORDER BY block_number DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""";
|
||||||
@@ -222,9 +195,8 @@ final class ChannelsReadSupport {
|
|||||||
ps.setString(1, ownerBch);
|
ps.setString(1, ownerBch);
|
||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
|
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
|
||||||
ps.setString(4, ownerBch);
|
ps.setInt(4, originalBlockNumber);
|
||||||
ps.setInt(5, originalBlockNumber);
|
ps.setBytes(5, originalHash);
|
||||||
ps.setBytes(6, originalHash);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (!rs.next()) return null;
|
if (!rs.next()) return null;
|
||||||
PostBlock pb = new PostBlock();
|
PostBlock pb = new PostBlock();
|
||||||
@@ -251,8 +223,6 @@ final class ChannelsReadSupport {
|
|||||||
ti.text = trb.message;
|
ti.text = trb.message;
|
||||||
} else if (e.body instanceof blockchain.body.StatusActionBody sab) {
|
} else if (e.body instanceof blockchain.body.StatusActionBody sab) {
|
||||||
ti.text = sab.message;
|
ti.text = sab.message;
|
||||||
} else if (e.body instanceof TextBody tb) {
|
|
||||||
ti.text = tb.message;
|
|
||||||
}
|
}
|
||||||
return ti;
|
return ti;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
@@ -262,12 +232,13 @@ final class ChannelsReadSupport {
|
|||||||
|
|
||||||
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
|
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
|
||||||
String order = asc ? "ASC" : "DESC";
|
String order = asc ? "ASC" : "DESC";
|
||||||
|
boolean bounded = limit > 0;
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
|
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
||||||
ORDER BY block_number
|
ORDER BY block_number
|
||||||
""" + order + " LIMIT ?";
|
""" + order + (bounded ? " LIMIT ?" : "");
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, ownerBch);
|
ps.setString(1, ownerBch);
|
||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
@@ -278,7 +249,7 @@ final class ChannelsReadSupport {
|
|||||||
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
||||||
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
||||||
ps.setInt(9, lineCode);
|
ps.setInt(9, lineCode);
|
||||||
ps.setInt(10, limit);
|
if (bounded) ps.setInt(10, limit);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
List<PostBlock> out = new ArrayList<>();
|
List<PostBlock> out = new ArrayList<>();
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
@@ -315,7 +286,6 @@ final class ChannelsReadSupport {
|
|||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
||||||
AND to_block_number=? AND to_block_hash=?
|
AND to_block_number=? AND to_block_hash=?
|
||||||
AND (to_bch_name=? OR to_bch_name IS NULL OR to_bch_name='')
|
|
||||||
ORDER BY block_number ASC
|
ORDER BY block_number ASC
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
@@ -324,7 +294,6 @@ final class ChannelsReadSupport {
|
|||||||
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
|
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
|
||||||
ps.setInt(4, originalBlock);
|
ps.setInt(4, originalBlock);
|
||||||
ps.setBytes(5, originalHash);
|
ps.setBytes(5, originalHash);
|
||||||
ps.setString(6, ownerBch);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
List<PostBlock> out = new ArrayList<>();
|
List<PostBlock> out = new ArrayList<>();
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
@@ -342,13 +311,13 @@ final class ChannelsReadSupport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
|
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
|
||||||
String sql = "SELECT likes_count,primary_likes_count,shining_likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
String sql = "SELECT likes_count,primary_likes_count,shining_likes_count,replies_count FROM message_stats WHERE LOWER(to_login)=LOWER(?) AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
||||||
int likesCount = 0;
|
int likesCount = 0;
|
||||||
int primaryLikesCount = 0;
|
int primaryLikesCount = 0;
|
||||||
int shiningLikesCount = 0;
|
int shiningLikesCount = 0;
|
||||||
int repliesCount = 0;
|
int repliesCount = 0;
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, bch);
|
ps.setString(1, loginFromChainName(bch));
|
||||||
ps.setInt(2, blockNumber);
|
ps.setInt(2, blockNumber);
|
||||||
ps.setBytes(3, blockHash);
|
ps.setBytes(3, blockHash);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
@@ -365,7 +334,7 @@ final class ChannelsReadSupport {
|
|||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE msg_type = ?
|
WHERE msg_type = ?
|
||||||
AND msg_sub_type = ?
|
AND msg_sub_type = ?
|
||||||
AND to_bch_name = ?
|
AND LOWER(to_login) = LOWER(?)
|
||||||
AND to_block_number = ?
|
AND to_block_number = ?
|
||||||
AND to_block_hash = ?
|
AND to_block_hash = ?
|
||||||
""";
|
""";
|
||||||
@@ -373,7 +342,7 @@ final class ChannelsReadSupport {
|
|||||||
try (PreparedStatement ps = c.prepareStatement(ratingsSql)) {
|
try (PreparedStatement ps = c.prepareStatement(ratingsSql)) {
|
||||||
ps.setInt(1, MSG_TYPE_TEXT);
|
ps.setInt(1, MSG_TYPE_TEXT);
|
||||||
ps.setInt(2, MsgSubType.TEXT_RATING);
|
ps.setInt(2, MsgSubType.TEXT_RATING);
|
||||||
ps.setString(3, bch);
|
ps.setString(3, loginFromChainName(bch));
|
||||||
ps.setInt(4, blockNumber);
|
ps.setInt(4, blockNumber);
|
||||||
ps.setBytes(5, blockHash);
|
ps.setBytes(5, blockHash);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
@@ -626,26 +595,25 @@ final class ChannelsReadSupport {
|
|||||||
if (login == null || login.isBlank() || toBch == null || toBch.isBlank() || toBlockHash == null || toBlockHash.length != 32) {
|
if (login == null || login.isBlank() || toBch == null || toBch.isBlank() || toBlockHash == null || toBlockHash.length != 32) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
String targetLogin = loginFromChainName(toBch);
|
||||||
|
if (targetLogin == null || targetLogin.isBlank()) return false;
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT msg_sub_type
|
SELECT last_sub_type
|
||||||
FROM blocks
|
FROM reactions_state
|
||||||
WHERE LOWER(login) = LOWER(?)
|
WHERE LOWER(from_login) = LOWER(?)
|
||||||
AND msg_type = ?
|
AND reaction_type = 1
|
||||||
AND to_bch_name = ?
|
AND LOWER(to_login) = LOWER(?)
|
||||||
AND to_block_number = ?
|
AND to_block_number = ?
|
||||||
AND to_block_hash = ?
|
AND to_block_hash = ?
|
||||||
ORDER BY block_number DESC
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, login);
|
ps.setString(1, login);
|
||||||
ps.setInt(2, MSG_TYPE_REACTION);
|
ps.setString(2, targetLogin);
|
||||||
ps.setString(3, toBch);
|
ps.setInt(3, toBlockNumber);
|
||||||
ps.setInt(4, toBlockNumber);
|
ps.setBytes(4, toBlockHash);
|
||||||
ps.setBytes(5, toBlockHash);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (!rs.next()) return false;
|
return rs.next() && rs.getInt("last_sub_type") == MsgSubType.REACTION_LIKE;
|
||||||
return rs.getInt("msg_sub_type") == MsgSubType.REACTION_LIKE;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -736,4 +704,8 @@ final class ChannelsReadSupport {
|
|||||||
String ownerBlockchainName;
|
String ownerBlockchainName;
|
||||||
int channelRootBlockNumber;
|
int channelRootBlockNumber;
|
||||||
}
|
}
|
||||||
|
static String loginFromChainName(String bch) {
|
||||||
|
if (bch == null) return null; int d=bch.lastIndexOf('-'); return d>0?bch.substring(0,d):bch;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-4
@@ -34,9 +34,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
int limit = req.getLimit() == null ? 30 : req.getLimit();
|
// limit is optional. null/0 means "return the whole channel".
|
||||||
if (limit <= 0 || limit > 1000) {
|
// Positive values are kept for backwards-compatible callers that still want a bounded response.
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
|
int limit = req.getLimit() == null ? 0 : req.getLimit();
|
||||||
|
if (limit < 0) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_limit", "Некорректный limit");
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
||||||
@@ -113,7 +115,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
if (!asc) {
|
if (!asc) {
|
||||||
java.util.Collections.reverse(posts);
|
java.util.Collections.reverse(posts);
|
||||||
}
|
}
|
||||||
if (posts.size() > limit) {
|
if (limit > 0 && posts.size() > limit) {
|
||||||
posts = new ArrayList<>(posts.subList(0, limit));
|
posts = new ArrayList<>(posts.subList(0, limit));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.DbController;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_GetMessageLikes_Request req = (Net_GetMessageLikes_Request) baseRequest;
|
||||||
|
Net_GetMessageLikes_Request.MessageSelector message = req.getMessage();
|
||||||
|
if (message == null || message.getBlockchainName() == null || message.getBlockchainName().isBlank()
|
||||||
|
|| message.getBlockNumber() == null || message.getBlockNumber() < 0
|
||||||
|
|| message.getBlockHash() == null || message.getBlockHash().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message");
|
||||||
|
}
|
||||||
|
|
||||||
|
final byte[] blockHash;
|
||||||
|
try {
|
||||||
|
blockHash = ChannelsReadSupport.hexToBytes(message.getBlockHash());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
|
||||||
|
}
|
||||||
|
if (blockHash == null || blockHash.length == 0) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
String sql = """
|
||||||
|
SELECT rs.from_login,
|
||||||
|
COALESCE(ups.first_name, '') AS first_name,
|
||||||
|
COALESCE(ups.last_name, '') AS last_name,
|
||||||
|
COALESCE(ups.ava_ar, '') AS avatar_ar,
|
||||||
|
COALESCE(ups.account_role, 'unknown') AS account_role,
|
||||||
|
COALESCE(ups.shine_status, 'unknown') AS shine_status
|
||||||
|
FROM reactions_state rs
|
||||||
|
LEFT JOIN user_profile_state ups ON LOWER(ups.login) = LOWER(rs.from_login)
|
||||||
|
WHERE rs.reaction_type = 1
|
||||||
|
AND rs.last_sub_type = 1
|
||||||
|
AND LOWER(rs.to_login) = LOWER(?)
|
||||||
|
AND rs.to_block_number = ?
|
||||||
|
AND rs.to_block_hash = ?
|
||||||
|
ORDER BY LOWER(rs.from_login)
|
||||||
|
""";
|
||||||
|
|
||||||
|
List<Net_GetMessageLikes_Response.UserItem> shining = new ArrayList<>();
|
||||||
|
List<Net_GetMessageLikes_Response.UserItem> official = new ArrayList<>();
|
||||||
|
List<Net_GetMessageLikes_Response.UserItem> others = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, loginFromChainName(message.getBlockchainName().trim()));
|
||||||
|
ps.setInt(2, message.getBlockNumber());
|
||||||
|
ps.setBytes(3, blockHash);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem();
|
||||||
|
user.setLogin(rs.getString("from_login"));
|
||||||
|
user.setFirstName(rs.getString("first_name"));
|
||||||
|
user.setLastName(rs.getString("last_name"));
|
||||||
|
user.setAvatarAr(rs.getString("avatar_ar"));
|
||||||
|
|
||||||
|
boolean isOfficial = "primary".equalsIgnoreCase(rs.getString("account_role"));
|
||||||
|
boolean isShining = "shining".equalsIgnoreCase(rs.getString("shine_status"));
|
||||||
|
if (isOfficial && isShining) shining.add(user);
|
||||||
|
else if (isOfficial) official.add(user);
|
||||||
|
else others.add(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_GetMessageLikes_Response resp = new Net_GetMessageLikes_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setShining(shining);
|
||||||
|
resp.setOfficial(official);
|
||||||
|
resp.setOthers(others);
|
||||||
|
resp.setTotal(shining.size() + official.size() + others.size());
|
||||||
|
resp.setTruncated(false);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("GetMessageLikes failed", e);
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private static String loginFromChainName(String bch) {
|
||||||
|
if (bch == null) return null; int d=bch.lastIndexOf('-'); return d>0?bch.substring(0,d):bch;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+50
-13
@@ -56,8 +56,17 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
List<Net_GetMessageThread_Response.MessageNode> ancestors = new ArrayList<>();
|
List<Net_GetMessageThread_Response.MessageNode> ancestors = new ArrayList<>();
|
||||||
PostRow cur = focusRow;
|
PostRow cur = focusRow;
|
||||||
for (int i = 0; i < depthUp; i++) {
|
for (int i = 0; i < depthUp; i++) {
|
||||||
if (cur.toBlockNumber == null || cur.toBchName == null) break;
|
if (cur.toBlockNumber == null) break;
|
||||||
PostRow parent = findByNumber(c, cur.toBchName, cur.toBlockNumber);
|
PostRow parent;
|
||||||
|
if (cur.msgSubType == MsgSubType.TEXT_RATING
|
||||||
|
&& cur.toLogin != null && !cur.toLogin.isBlank()
|
||||||
|
&& cur.toBlockHash != null && cur.toBlockHash.length == 32) {
|
||||||
|
parent = findByLogicalTarget(c, cur.toLogin, cur.toBlockNumber, cur.toBlockHash);
|
||||||
|
} else {
|
||||||
|
// REPLY and other legacy thread links intentionally keep their existing physical-target behavior.
|
||||||
|
if (cur.toBchName == null) break;
|
||||||
|
parent = findByNumber(c, cur.toBchName, cur.toBlockNumber);
|
||||||
|
}
|
||||||
if (parent == null) break;
|
if (parent == null) break;
|
||||||
ancestors.add(0, toNode(c, parent, viewerLogin));
|
ancestors.add(0, toNode(c, parent, viewerLogin));
|
||||||
cur = parent;
|
cur = parent;
|
||||||
@@ -102,18 +111,26 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<PostRow> findRepliesAndRatings(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
|
private List<PostRow> findRepliesAndRatings(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
|
||||||
|
String targetLogin = ChannelsReadSupport.loginFromChainName(toBchName);
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
SELECT login,bch_name,block_number,block_hash,block_bytes,to_login,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE msg_type=1 AND msg_sub_type IN (?, ?)
|
WHERE msg_type=1
|
||||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
AND to_block_number=? AND to_block_hash=?
|
||||||
|
AND (
|
||||||
|
(msg_sub_type=? AND to_bch_name=?)
|
||||||
|
OR (msg_sub_type=? AND LOWER(to_login)=LOWER(?))
|
||||||
|
)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setInt(1, MsgSubType.TEXT_REPLY);
|
ps.setInt(1, toBlockNumber);
|
||||||
ps.setInt(2, MsgSubType.TEXT_RATING);
|
ps.setBytes(2, toBlockHash);
|
||||||
ps.setString(3, toBchName);
|
// REPLY keeps its legacy physical-target lookup intentionally.
|
||||||
ps.setInt(4, toBlockNumber);
|
ps.setInt(3, MsgSubType.TEXT_REPLY);
|
||||||
ps.setBytes(5, toBlockHash);
|
ps.setString(4, toBchName);
|
||||||
|
// RATING follows the Stage 2 logical target identity.
|
||||||
|
ps.setInt(5, MsgSubType.TEXT_RATING);
|
||||||
|
ps.setString(6, targetLogin);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
List<PostRow> out = new ArrayList<>();
|
List<PostRow> out = new ArrayList<>();
|
||||||
while (rs.next()) out.add(mapRow(rs));
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
@@ -129,9 +146,27 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private PostRow findByLogicalTarget(Connection c, String login, int blockNumber, byte[] blockHash) throws Exception {
|
||||||
|
String sql = """
|
||||||
|
SELECT login,bch_name,block_number,block_hash,block_bytes,to_login,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
||||||
|
FROM blocks
|
||||||
|
WHERE LOWER(login)=LOWER(?) AND block_number=? AND block_hash=?
|
||||||
|
ORDER BY fork_number DESC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
ps.setInt(2, blockNumber);
|
||||||
|
ps.setBytes(3, blockHash);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? mapRow(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private PostRow findByNumber(Connection c, String bchName, int blockNumber) throws Exception {
|
private PostRow findByNumber(Connection c, String bchName, int blockNumber) throws Exception {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
SELECT login,bch_name,block_number,block_hash,block_bytes,to_login,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND block_number=?
|
WHERE bch_name=? AND block_number=?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -147,7 +182,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
private PostRow findByNumberAndHash(Connection c, String bchName, int blockNumber, byte[] blockHash) throws Exception {
|
private PostRow findByNumberAndHash(Connection c, String bchName, int blockNumber, byte[] blockHash) throws Exception {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
SELECT login,bch_name,block_number,block_hash,block_bytes,to_login,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND block_number=? AND block_hash=?
|
WHERE bch_name=? AND block_number=? AND block_hash=?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -179,6 +214,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
row.blockNumber = rs.getInt("block_number");
|
row.blockNumber = rs.getInt("block_number");
|
||||||
row.blockHash = rs.getBytes("block_hash");
|
row.blockHash = rs.getBytes("block_hash");
|
||||||
row.blockBytes = rs.getBytes("block_bytes");
|
row.blockBytes = rs.getBytes("block_bytes");
|
||||||
|
row.toLogin = rs.getString("to_login");
|
||||||
row.toBchName = rs.getString("to_bch_name");
|
row.toBchName = rs.getString("to_bch_name");
|
||||||
row.toBlockNumber = (Integer) rs.getObject("to_block_number");
|
row.toBlockNumber = (Integer) rs.getObject("to_block_number");
|
||||||
row.toBlockHash = rs.getBytes("to_block_hash");
|
row.toBlockHash = rs.getBytes("to_block_hash");
|
||||||
@@ -262,7 +298,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
private List<PostRow> findEdits(Connection c, String bch, int targetBlock, byte[] targetHash, int subType) throws Exception {
|
private List<PostRow> findEdits(Connection c, String bch, int targetBlock, byte[] targetHash, int subType) throws Exception {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
SELECT login,bch_name,block_number,block_hash,block_bytes,to_login,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=1 AND msg_sub_type=?
|
WHERE bch_name=? AND msg_type=1 AND msg_sub_type=?
|
||||||
AND to_block_number=? AND to_block_hash=?
|
AND to_block_number=? AND to_block_hash=?
|
||||||
@@ -289,6 +325,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
int blockNumber;
|
int blockNumber;
|
||||||
byte[] blockHash;
|
byte[] blockHash;
|
||||||
byte[] blockBytes;
|
byte[] blockBytes;
|
||||||
|
String toLogin;
|
||||||
String toBchName;
|
String toBchName;
|
||||||
Integer toBlockNumber;
|
Integer toBlockNumber;
|
||||||
byte[] toBlockHash;
|
byte[] toBlockHash;
|
||||||
|
|||||||
+15
-12
@@ -105,7 +105,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
|||||||
String order = asc ? "ASC" : "DESC";
|
String order = asc ? "ASC" : "DESC";
|
||||||
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
|
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
|
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type,
|
||||||
|
to_login, to_bch_name, to_block_number, to_block_hash
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE login = ? AND msg_type = ?
|
WHERE login = ? AND msg_type = ?
|
||||||
ORDER BY block_number
|
ORDER BY block_number
|
||||||
@@ -132,9 +133,9 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
|||||||
item.setLikedByMe(false);
|
item.setLikedByMe(false);
|
||||||
item.setRepliesCount(0);
|
item.setRepliesCount(0);
|
||||||
item.setRatingsCount(0);
|
item.setRatingsCount(0);
|
||||||
item.setTargetBlockchainName(statusBody.toBchName());
|
item.setTargetBlockchainName(rs.getString("to_bch_name"));
|
||||||
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber());
|
item.setTargetBlockNumber((Integer) rs.getObject("to_block_number"));
|
||||||
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes()));
|
item.setTargetBlockHash(ChannelsReadSupport.toHex(rs.getBytes("to_block_hash")));
|
||||||
|
|
||||||
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
|
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
|
||||||
c,
|
c,
|
||||||
@@ -148,7 +149,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
|||||||
item.setVersionsTotal(versions.size());
|
item.setVersionsTotal(versions.size());
|
||||||
item.setText(versions.get(versions.size() - 1).getText());
|
item.setText(versions.get(versions.size() - 1).getText());
|
||||||
|
|
||||||
fillTargetDetails(c, item, statusBody.toBchName(), statusBody.toBlockGlobalNumber(), statusBody.toBlockHashBytes());
|
fillTargetDetails(c, item, rs.getString("to_login"),
|
||||||
|
(Integer) rs.getObject("to_block_number"), rs.getBytes("to_block_hash"));
|
||||||
out.add(item);
|
out.add(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,24 +210,25 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
private void fillTargetDetails(Connection c,
|
private void fillTargetDetails(Connection c,
|
||||||
Net_GetChannelMessages_Response.MessageItem item,
|
Net_GetChannelMessages_Response.MessageItem item,
|
||||||
String targetBch,
|
String targetLogin,
|
||||||
Integer targetBlockNumber,
|
Integer targetBlockNumber,
|
||||||
byte[] targetHash) throws Exception {
|
byte[] targetHash) throws Exception {
|
||||||
if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
|
if (targetLogin == null || targetLogin.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
|
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type,
|
||||||
|
to_login, to_bch_name, to_block_number, to_block_hash
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name = ? AND block_number = ?
|
WHERE LOWER(login) = LOWER(?) AND block_number = ? AND block_hash = ?
|
||||||
|
ORDER BY fork_number DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""")) {
|
""")) {
|
||||||
ps.setString(1, targetBch);
|
ps.setString(1, targetLogin);
|
||||||
ps.setInt(2, targetBlockNumber);
|
ps.setInt(2, targetBlockNumber);
|
||||||
|
ps.setBytes(3, targetHash);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (!rs.next()) return;
|
if (!rs.next()) return;
|
||||||
byte[] actualHash = rs.getBytes("block_hash");
|
|
||||||
if (actualHash == null || !java.util.Arrays.equals(actualHash, targetHash)) return;
|
|
||||||
item.setTargetMsgSubType(rs.getInt("msg_sub_type"));
|
item.setTargetMsgSubType(rs.getInt("msg_sub_type"));
|
||||||
item.setTargetAuthorLogin(rs.getString("login"));
|
item.setTargetAuthorLogin(rs.getString("login"));
|
||||||
item.setTargetAuthorBlockchainName(rs.getString("bch_name"));
|
item.setTargetAuthorBlockchainName(rs.getString("bch_name"));
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.DbController;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
|
||||||
|
public class Net_GetUserCounters_Handler implements JsonMessageHandler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_GetUserCounters_Handler.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_GetUserCounters_Request req = (Net_GetUserCounters_Request) baseRequest;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||||
|
}
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
UserCountersSupport.Snapshot s = UserCountersSupport.calculate(c, login);
|
||||||
|
Net_GetUserCounters_Response r = new Net_GetUserCounters_Response();
|
||||||
|
r.setOp(req.getOp());
|
||||||
|
r.setRequestId(req.getRequestId());
|
||||||
|
r.setStatus(WireCodes.Status.OK);
|
||||||
|
r.setLogin(login);
|
||||||
|
r.setDmUnreadCount(s.dmUnreadCount());
|
||||||
|
r.setChannelsUnreadCount(s.channelsUnreadCount());
|
||||||
|
r.setNotificationsUnreadCount(s.notificationsUnreadCount());
|
||||||
|
r.setNotificationRepliesUnreadCount(s.repliesUnreadCount());
|
||||||
|
r.setNotificationConnectionsUnreadCount(s.connectionsUnreadCount());
|
||||||
|
r.setNotificationEventsUnreadCount(s.eventsUnreadCount());
|
||||||
|
return r;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("GetUserCounters failed for {}", login, e);
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Не удалось получить счётчики");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-4
@@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
|||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import shine.db.MsgSubType;
|
import shine.db.MsgSubType;
|
||||||
import shine.db.DbController;
|
import shine.db.DbController;
|
||||||
|
import shine.db.dao.ChannelReadStateDAO;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
@@ -81,8 +82,15 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
|||||||
channelRef.setChannelRoot(rootRef);
|
channelRef.setChannelRoot(rootRef);
|
||||||
|
|
||||||
row.setChannel(channelRef);
|
row.setChannel(channelRef);
|
||||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
int messagesCount = ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber);
|
||||||
row.setUnreadCount(ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
|
row.setMessagesCount(messagesCount);
|
||||||
|
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
|
||||||
|
ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null
|
||||||
|
? null
|
||||||
|
: ChannelReadStateDAO.getInstance().get(c, viewerLogin, key.ownerBch, meta.channelName);
|
||||||
|
row.setReadStateInitialized(readState != null);
|
||||||
|
row.setReadCount(readState == null ? 0L : Math.min(messagesCount, Math.max(0L, readState.readCount())));
|
||||||
|
row.setUnreadCount(readState == null ? 0 : (int) Math.min(Integer.MAX_VALUE, readState.unreadCount()));
|
||||||
|
|
||||||
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||||
if (lastPost != null) {
|
if (lastPost != null) {
|
||||||
@@ -137,10 +145,13 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
|||||||
private List<ChannelKey> loadFollowedChannels(Connection c, String canonicalLogin, boolean onlyUserRoots) throws Exception {
|
private List<ChannelKey> loadFollowedChannels(Connection c, String canonicalLogin, boolean onlyUserRoots) throws Exception {
|
||||||
List<ChannelKey> out = new ArrayList<>();
|
List<ChannelKey> out = new ArrayList<>();
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT cs.to_login, cs.to_bch_name, COALESCE(cs.to_block_number,0) AS root_number, cs.to_block_hash
|
SELECT cs.to_login,
|
||||||
|
COALESCE(cn.owner_bch_name,(SELECT bs.blockchain_name FROM blockchain_state bs WHERE LOWER(bs.login)=LOWER(cs.to_login) ORDER BY bs.fork_number DESC LIMIT 1)) AS to_bch_name,
|
||||||
|
COALESCE(cs.to_block_number,0) AS root_number, cs.to_block_hash
|
||||||
FROM connections_state cs
|
FROM connections_state cs
|
||||||
|
LEFT JOIN channel_names_state cn ON LOWER(cn.owner_login)=LOWER(cs.to_login) AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
|
||||||
WHERE cs.login=? AND cs.rel_type=?
|
WHERE cs.login=? AND cs.rel_type=?
|
||||||
ORDER BY cs.to_login, cs.to_bch_name, root_number
|
ORDER BY cs.to_login, root_number
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, canonicalLogin);
|
ps.setString(1, canonicalLogin);
|
||||||
|
|||||||
+12
-7
@@ -48,13 +48,14 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
Integer expectedRoot = req.getChannel() != null ? req.getChannel().getChannelRootBlockNumber() : null;
|
Integer expectedRoot = req.getChannel() != null ? req.getChannel().getChannelRootBlockNumber() : null;
|
||||||
String expectedOwnerBch = req.getChannel() != null ? req.getChannel().getOwnerBlockchainName() : null;
|
String expectedOwnerBch = req.getChannel() != null ? req.getChannel().getOwnerBlockchainName() : null;
|
||||||
|
String expectedOwnerLogin = loginFromChainName(expectedOwnerBch);
|
||||||
boolean strictChannelMatch = expectedRoot != null && expectedRoot >= 0
|
boolean strictChannelMatch = expectedRoot != null && expectedRoot >= 0
|
||||||
&& expectedOwnerBch != null && !expectedOwnerBch.isBlank();
|
&& expectedOwnerLogin != null && !expectedOwnerLogin.isBlank();
|
||||||
|
|
||||||
String existsSql = """
|
String existsSql = """
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name = ?
|
WHERE LOWER(login) = LOWER(?)
|
||||||
AND block_number = ?
|
AND block_number = ?
|
||||||
AND block_hash = ?
|
AND block_hash = ?
|
||||||
AND msg_type = ?
|
AND msg_type = ?
|
||||||
@@ -65,7 +66,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
String insertSql = """
|
String insertSql = """
|
||||||
INSERT INTO message_views_state (
|
INSERT INTO message_views_state (
|
||||||
viewer_login, to_bch_name, to_block_number, to_block_hash, first_seen_at_ms
|
viewer_login, to_login, to_block_number, to_block_hash, first_seen_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
""";
|
""";
|
||||||
@@ -87,13 +88,15 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
|
|||||||
if (bch.isBlank() || no == null || no < 0) continue;
|
if (bch.isBlank() || no == null || no < 0) continue;
|
||||||
if (!hashHex.matches("^[0-9a-f]{64}$")) continue;
|
if (!hashHex.matches("^[0-9a-f]{64}$")) continue;
|
||||||
|
|
||||||
if (strictChannelMatch && !bch.equals(expectedOwnerBch)) continue;
|
String targetLogin = loginFromChainName(bch);
|
||||||
|
if (targetLogin == null || targetLogin.isBlank()) continue;
|
||||||
|
if (strictChannelMatch && !targetLogin.equalsIgnoreCase(expectedOwnerLogin)) continue;
|
||||||
|
|
||||||
String key = bch + "|" + no + "|" + hashHex;
|
String key = targetLogin.toLowerCase(java.util.Locale.ROOT) + "|" + no + "|" + hashHex;
|
||||||
if (!dedup.add(key)) continue;
|
if (!dedup.add(key)) continue;
|
||||||
|
|
||||||
existsPs.clearParameters();
|
existsPs.clearParameters();
|
||||||
existsPs.setString(1, bch);
|
existsPs.setString(1, targetLogin);
|
||||||
existsPs.setInt(2, no);
|
existsPs.setInt(2, no);
|
||||||
existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex));
|
existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex));
|
||||||
existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT);
|
existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT);
|
||||||
@@ -117,7 +120,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
insertPs.clearParameters();
|
insertPs.clearParameters();
|
||||||
insertPs.setString(1, viewerLogin);
|
insertPs.setString(1, viewerLogin);
|
||||||
insertPs.setString(2, bch);
|
insertPs.setString(2, targetLogin);
|
||||||
insertPs.setInt(3, no);
|
insertPs.setInt(3, no);
|
||||||
insertPs.setBytes(4, ChannelsReadSupport.hexToBytes(hashHex));
|
insertPs.setBytes(4, ChannelsReadSupport.hexToBytes(hashHex));
|
||||||
insertPs.setLong(5, nowMs);
|
insertPs.setLong(5, nowMs);
|
||||||
@@ -137,4 +140,6 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Internal server error");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Internal server error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private static String loginFromChainName(String bch) { if(bch==null)return null; int d=bch.lastIndexOf('-'); return d>0?bch.substring(0,d):bch; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.Base64Ws;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.MsgSubType;
|
||||||
|
import shine.db.dao.ChannelReadStateDAO;
|
||||||
|
import utils.config.ShineSignatureConstants;
|
||||||
|
import utils.crypto.Ed25519Util;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.sql.Connection;
|
||||||
|
|
||||||
|
/** Accepts the signed client read watermark for one followed public channel. */
|
||||||
|
public final class Net_SetChannelReadState_Handler implements JsonMessageHandler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_SetChannelReadState_Handler.class);
|
||||||
|
private static final long MAX_FUTURE_SKEW_MS = 5 * 60_000L;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_SetChannelReadState_Request req = (Net_SetChannelReadState_Request) baseRequest;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||||
|
"NOT_AUTHENTICATED", "Требуется авторизация");
|
||||||
|
}
|
||||||
|
if (req.getLogin() == null || req.getLogin().isBlank()
|
||||||
|
|| req.getOwner_bch_name() == null || req.getOwner_bch_name().isBlank()
|
||||||
|
|| req.getChannel_name() == null || req.getChannel_name().isBlank()
|
||||||
|
|| req.getRead_count() == null || req.getRead_count() < 0
|
||||||
|
|| req.getTime_ms() == null || req.getTime_ms() <= 0
|
||||||
|
|| req.getClient_key() == null || req.getClient_key().isBlank()
|
||||||
|
|| req.getSignature() == null || req.getSignature().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST,
|
||||||
|
"BAD_FIELDS", "Некорректные поля ChannelReadState");
|
||||||
|
}
|
||||||
|
|
||||||
|
String authenticatedLogin = String.valueOf(ctx.getCurrentUser().getLogin()).trim();
|
||||||
|
String login = req.getLogin().trim();
|
||||||
|
String ownerBch = req.getOwner_bch_name().trim();
|
||||||
|
String channelName = req.getChannel_name().trim();
|
||||||
|
long readCount = req.getRead_count();
|
||||||
|
long timeMs = req.getTime_ms();
|
||||||
|
String clientKeyB64 = req.getClient_key().trim();
|
||||||
|
String signatureB64 = req.getSignature().trim();
|
||||||
|
|
||||||
|
if (!authenticatedLogin.equalsIgnoreCase(login)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||||
|
"LOGIN_MISMATCH", "Состояние чтения принадлежит другому пользователю");
|
||||||
|
}
|
||||||
|
long nowMs = System.currentTimeMillis();
|
||||||
|
if (timeMs > nowMs + MAX_FUTURE_SKEW_MS) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||||
|
"BAD_TIME", "Некорректное время подписи");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
byte[] pubKey32;
|
||||||
|
byte[] sig64;
|
||||||
|
try {
|
||||||
|
pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key");
|
||||||
|
sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST,
|
||||||
|
"BAD_BASE64", "client_key/signature должны быть Base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
String userClientKey = String.valueOf(ctx.getCurrentUser().getClientKey() == null
|
||||||
|
? "" : ctx.getCurrentUser().getClientKey()).trim();
|
||||||
|
if (userClientKey.isBlank() || !userClientKey.equals(clientKeyB64)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 403,
|
||||||
|
"DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю");
|
||||||
|
}
|
||||||
|
|
||||||
|
String signText = ShineSignatureConstants.CHANNEL_READ_STATE_PREFIX
|
||||||
|
+ escapePart(login) + '|'
|
||||||
|
+ escapePart(ownerBch) + '|'
|
||||||
|
+ escapePart(channelName) + '|'
|
||||||
|
+ timeMs + '|'
|
||||||
|
+ readCount;
|
||||||
|
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||||
|
"INVALID_SIGNATURE", "Подпись состояния чтения канала не прошла проверку");
|
||||||
|
}
|
||||||
|
|
||||||
|
ChannelReadStateDAO dao = ChannelReadStateDAO.getInstance();
|
||||||
|
ChannelReadStateDAO.UpsertResult result;
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
if (!dao.hasActiveSubscription(c, authenticatedLogin, ownerBch, channelName,
|
||||||
|
MsgSubType.CONNECTION_FOLLOW)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409,
|
||||||
|
"CHANNEL_NOT_FOLLOWED", "Пользователь не подписан на этот канал");
|
||||||
|
}
|
||||||
|
String ownerLogin = dao.resolveOwnerLogin(c, ownerBch, channelName);
|
||||||
|
Integer channelRoot = dao.resolvePublicChannelRootByName(c, ownerBch, channelName);
|
||||||
|
if (ownerLogin == null || ownerLogin.isBlank() || channelRoot == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 404,
|
||||||
|
"CHANNEL_NOT_FOUND", "Канал не найден");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never persist a watermark beyond the number of messages that really exists.
|
||||||
|
// Without this guard, one buggy/malicious oversized read_count would be monotonic
|
||||||
|
// and could make all future unread counters impossible to clear correctly.
|
||||||
|
long actualMessagesCount = ChannelsReadSupport.countPosts(c, ownerBch, channelRoot);
|
||||||
|
long effectiveReadCount = Math.min(readCount, actualMessagesCount);
|
||||||
|
|
||||||
|
result = dao.upsertSignedReadIfNewer(c,
|
||||||
|
authenticatedLogin, ownerLogin, ownerBch, channelName,
|
||||||
|
effectiveReadCount, timeMs, clientKeyB64, signatureB64, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_SetChannelReadState_Response resp = new Net_SetChannelReadState_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setLogin(authenticatedLogin);
|
||||||
|
resp.setOwner_bch_name(ownerBch);
|
||||||
|
resp.setChannel_name(channelName);
|
||||||
|
resp.setRead_count(result.readCount());
|
||||||
|
resp.setUnread_count(result.unreadCount());
|
||||||
|
resp.setTime_ms(timeMs);
|
||||||
|
resp.setApplied(result.applied());
|
||||||
|
if (result.applied()) UserCountersSupport.pushChanged(authenticatedLogin);
|
||||||
|
return resp;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("SetChannelReadState failed", e);
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR,
|
||||||
|
"INTERNAL_ERROR", NetExceptionResponseFactory.detailedMessage(
|
||||||
|
"Внутренняя ошибка SetChannelReadState", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escapePart(String value) {
|
||||||
|
String s = String.valueOf(value == null ? "" : value);
|
||||||
|
return s.replace("\\", "\\\\").replace("|", "\\|");
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.dao.UserNotificationSeenStateDAO;
|
||||||
|
import shine.db.dao.ChannelReadStateDAO;
|
||||||
|
import shine.db.dao.UserNotificationsStateDAO;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary calculator behind the stable GetUserCounters/UserCountersChanged contract.
|
||||||
|
* It intentionally uses current DB state today; later it can read one materialized row/cache
|
||||||
|
* without changing API or UI.
|
||||||
|
*/
|
||||||
|
public final class UserCountersSupport {
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private UserCountersSupport() {}
|
||||||
|
|
||||||
|
public record Snapshot(long dmUnreadCount, long channelsUnreadCount, long repliesUnreadCount,
|
||||||
|
long connectionsUnreadCount, long eventsUnreadCount) {
|
||||||
|
public long notificationsUnreadCount() {
|
||||||
|
return repliesUnreadCount + connectionsUnreadCount + eventsUnreadCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Snapshot calculate(Connection c, String login) throws Exception {
|
||||||
|
long dm = countDmUnread(c, login);
|
||||||
|
long channels = countChannelsUnread(c, login);
|
||||||
|
UserNotificationSeenStateDAO seen = UserNotificationSeenStateDAO.getInstance();
|
||||||
|
UserNotificationsStateDAO notifications = UserNotificationsStateDAO.getInstance();
|
||||||
|
long repliesSeen = seen.getSeenAt(c, login, "replies");
|
||||||
|
long connectionsSeen = seen.getSeenAt(c, login, "connections");
|
||||||
|
long eventsSeen = seen.getSeenAt(c, login, "events");
|
||||||
|
long replies = notifications.countUnseen(c, login, "reply", repliesSeen);
|
||||||
|
long connections = notifications.countUnseen(c, login, "connection", connectionsSeen);
|
||||||
|
long events = notifications.countUnseen(c, login, "event", eventsSeen);
|
||||||
|
return new Snapshot(dm, channels, replies, connections, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Snapshot calculate(String login) throws Exception {
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
return calculate(c, login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void pushChanged(String login) {
|
||||||
|
if (login == null || login.isBlank()) return;
|
||||||
|
try {
|
||||||
|
Snapshot s = calculate(login);
|
||||||
|
ObjectNode payload = toPayload(login, s);
|
||||||
|
String eventId = "user-counters-" + System.currentTimeMillis();
|
||||||
|
for (ConnectionContext ctx : ActiveConnectionsRegistry.getInstance().getByLogin(login)) {
|
||||||
|
WsEventSender.sendEvent(ctx, "UserCountersChanged", eventId, payload);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Counter push is best-effort. The client can always recover with GetUserCounters.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ObjectNode toPayload(String login, Snapshot s) {
|
||||||
|
ObjectNode p = MAPPER.createObjectNode();
|
||||||
|
p.put("login", login);
|
||||||
|
p.put("dmUnreadCount", s.dmUnreadCount());
|
||||||
|
p.put("channelsUnreadCount", s.channelsUnreadCount());
|
||||||
|
p.put("notificationsUnreadCount", s.notificationsUnreadCount());
|
||||||
|
ObjectNode n = p.putObject("notifications");
|
||||||
|
n.put("replies", s.repliesUnreadCount());
|
||||||
|
n.put("connections", s.connectionsUnreadCount());
|
||||||
|
n.put("events", s.eventsUnreadCount());
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long countDmUnread(Connection c, String login) throws Exception {
|
||||||
|
String sql = "SELECT COALESCE(SUM(unread_count),0) FROM dm_dialog_state WHERE LOWER(owner_login)=LOWER(?)";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long countChannelsUnread(Connection c, String login) throws Exception {
|
||||||
|
return ChannelReadStateDAO.getInstance().sumUnreadCount(c, login);
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_GetMessageLikes_Request extends Net_Request {
|
||||||
|
private MessageSelector message;
|
||||||
|
private Integer limit;
|
||||||
|
|
||||||
|
public MessageSelector getMessage() { return message; }
|
||||||
|
public void setMessage(MessageSelector message) { this.message = message; }
|
||||||
|
|
||||||
|
public Integer getLimit() { return limit; }
|
||||||
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
|
||||||
|
public static class MessageSelector {
|
||||||
|
private String blockchainName;
|
||||||
|
private Integer blockNumber;
|
||||||
|
private String blockHash;
|
||||||
|
|
||||||
|
public String getBlockchainName() { return blockchainName; }
|
||||||
|
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||||
|
|
||||||
|
public Integer getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(Integer blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
|
||||||
|
public String getBlockHash() { return blockHash; }
|
||||||
|
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_GetMessageLikes_Response extends Net_Response {
|
||||||
|
private List<UserItem> shining = new ArrayList<>();
|
||||||
|
private List<UserItem> official = new ArrayList<>();
|
||||||
|
private List<UserItem> others = new ArrayList<>();
|
||||||
|
private int total;
|
||||||
|
private boolean truncated;
|
||||||
|
|
||||||
|
public List<UserItem> getShining() { return shining; }
|
||||||
|
public void setShining(List<UserItem> shining) { this.shining = shining; }
|
||||||
|
public List<UserItem> getOfficial() { return official; }
|
||||||
|
public void setOfficial(List<UserItem> official) { this.official = official; }
|
||||||
|
public List<UserItem> getOthers() { return others; }
|
||||||
|
public void setOthers(List<UserItem> others) { this.others = others; }
|
||||||
|
public int getTotal() { return total; }
|
||||||
|
public void setTotal(int total) { this.total = total; }
|
||||||
|
public boolean isTruncated() { return truncated; }
|
||||||
|
public void setTruncated(boolean truncated) { this.truncated = truncated; }
|
||||||
|
|
||||||
|
public static class UserItem {
|
||||||
|
private String login;
|
||||||
|
private String firstName;
|
||||||
|
private String lastName;
|
||||||
|
private String avatarAr;
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public String getFirstName() { return firstName; }
|
||||||
|
public void setFirstName(String firstName) { this.firstName = firstName; }
|
||||||
|
public String getLastName() { return lastName; }
|
||||||
|
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||||
|
public String getAvatarAr() { return avatarAr; }
|
||||||
|
public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/** Lightweight authenticated request for bottom-toolbar counters. */
|
||||||
|
public class Net_GetUserCounters_Request extends Net_Request {
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
/** Stable UI contract. Server-side calculation may be replaced by materialized counters later. */
|
||||||
|
public class Net_GetUserCounters_Response extends Net_Response {
|
||||||
|
private String login;
|
||||||
|
private long dmUnreadCount;
|
||||||
|
private long channelsUnreadCount;
|
||||||
|
private long notificationsUnreadCount;
|
||||||
|
private long notificationRepliesUnreadCount;
|
||||||
|
private long notificationConnectionsUnreadCount;
|
||||||
|
private long notificationEventsUnreadCount;
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public long getDmUnreadCount() { return dmUnreadCount; }
|
||||||
|
public void setDmUnreadCount(long value) { this.dmUnreadCount = value; }
|
||||||
|
public long getChannelsUnreadCount() { return channelsUnreadCount; }
|
||||||
|
public void setChannelsUnreadCount(long value) { this.channelsUnreadCount = value; }
|
||||||
|
public long getNotificationsUnreadCount() { return notificationsUnreadCount; }
|
||||||
|
public void setNotificationsUnreadCount(long value) { this.notificationsUnreadCount = value; }
|
||||||
|
public long getNotificationRepliesUnreadCount() { return notificationRepliesUnreadCount; }
|
||||||
|
public void setNotificationRepliesUnreadCount(long value) { this.notificationRepliesUnreadCount = value; }
|
||||||
|
public long getNotificationConnectionsUnreadCount() { return notificationConnectionsUnreadCount; }
|
||||||
|
public void setNotificationConnectionsUnreadCount(long value) { this.notificationConnectionsUnreadCount = value; }
|
||||||
|
public long getNotificationEventsUnreadCount() { return notificationEventsUnreadCount; }
|
||||||
|
public void setNotificationEventsUnreadCount(long value) { this.notificationEventsUnreadCount = value; }
|
||||||
|
}
|
||||||
+8
@@ -26,7 +26,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
public static class ChannelSummary {
|
public static class ChannelSummary {
|
||||||
private ChannelRef channel;
|
private ChannelRef channel;
|
||||||
private int messagesCount;
|
private int messagesCount;
|
||||||
|
private long readCount;
|
||||||
private int unreadCount;
|
private int unreadCount;
|
||||||
|
private boolean readStateInitialized;
|
||||||
private LastMessage lastMessage;
|
private LastMessage lastMessage;
|
||||||
|
|
||||||
public ChannelRef getChannel() { return channel; }
|
public ChannelRef getChannel() { return channel; }
|
||||||
@@ -35,9 +37,15 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
public int getMessagesCount() { return messagesCount; }
|
public int getMessagesCount() { return messagesCount; }
|
||||||
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
|
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
|
||||||
|
|
||||||
|
public long getReadCount() { return readCount; }
|
||||||
|
public void setReadCount(long readCount) { this.readCount = readCount; }
|
||||||
|
|
||||||
public int getUnreadCount() { return unreadCount; }
|
public int getUnreadCount() { return unreadCount; }
|
||||||
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
||||||
|
|
||||||
|
public boolean isReadStateInitialized() { return readStateInitialized; }
|
||||||
|
public void setReadStateInitialized(boolean readStateInitialized) { this.readStateInitialized = readStateInitialized; }
|
||||||
|
|
||||||
public LastMessage getLastMessage() { return lastMessage; }
|
public LastMessage getLastMessage() { return lastMessage; }
|
||||||
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
|
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_SetChannelReadState_Request extends Net_Request {
|
||||||
|
private String login;
|
||||||
|
private String owner_bch_name;
|
||||||
|
private String channel_name;
|
||||||
|
private Long read_count;
|
||||||
|
private Long time_ms;
|
||||||
|
private String client_key;
|
||||||
|
private String signature;
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
|
||||||
|
public String getOwner_bch_name() { return owner_bch_name; }
|
||||||
|
public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; }
|
||||||
|
|
||||||
|
public String getChannel_name() { return channel_name; }
|
||||||
|
public void setChannel_name(String channel_name) { this.channel_name = channel_name; }
|
||||||
|
|
||||||
|
public Long getRead_count() { return read_count; }
|
||||||
|
public void setRead_count(Long read_count) { this.read_count = read_count; }
|
||||||
|
|
||||||
|
public Long getTime_ms() { return time_ms; }
|
||||||
|
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||||
|
|
||||||
|
public String getClient_key() { return client_key; }
|
||||||
|
public void setClient_key(String client_key) { this.client_key = client_key; }
|
||||||
|
|
||||||
|
public String getSignature() { return signature; }
|
||||||
|
public void setSignature(String signature) { this.signature = signature; }
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
public class Net_SetChannelReadState_Response extends Net_Response {
|
||||||
|
private String login;
|
||||||
|
private String owner_bch_name;
|
||||||
|
private String channel_name;
|
||||||
|
private Long read_count;
|
||||||
|
private Long unread_count;
|
||||||
|
private Long time_ms;
|
||||||
|
private Boolean applied;
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
|
||||||
|
public String getOwner_bch_name() { return owner_bch_name; }
|
||||||
|
public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; }
|
||||||
|
|
||||||
|
public String getChannel_name() { return channel_name; }
|
||||||
|
public void setChannel_name(String channel_name) { this.channel_name = channel_name; }
|
||||||
|
|
||||||
|
public Long getRead_count() { return read_count; }
|
||||||
|
public void setRead_count(Long read_count) { this.read_count = read_count; }
|
||||||
|
|
||||||
|
public Long getUnread_count() { return unread_count; }
|
||||||
|
public void setUnread_count(Long unread_count) { this.unread_count = unread_count; }
|
||||||
|
|
||||||
|
public Long getTime_ms() { return time_ms; }
|
||||||
|
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||||
|
|
||||||
|
public Boolean getApplied() { return applied; }
|
||||||
|
public void setApplied(Boolean applied) { this.applied = applied; }
|
||||||
|
}
|
||||||
+22
-8
@@ -44,7 +44,11 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
// Idempotent insert for close-friend relation.
|
// Idempotent insert for close-friend relation.
|
||||||
// Rely on PostgreSQL ON CONFLICT DO NOTHING.
|
// Rely on PostgreSQL ON CONFLICT DO NOTHING.
|
||||||
insertCloseFriendIgnoreDuplicate(c, from, canonicalTo, targetBch);
|
byte[] targetHeaderHash = findHeaderHash(c, targetBch);
|
||||||
|
if (targetHeaderHash == null || targetHeaderHash.length != 32) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "HEADER_NOT_FOUND", "У целевого blockchain нет HEADER block 0");
|
||||||
|
}
|
||||||
|
insertCloseFriendIgnoreDuplicate(c, from, canonicalTo, targetBch, targetHeaderHash);
|
||||||
|
|
||||||
Net_AddCloseFriend_Response resp = new Net_AddCloseFriend_Response();
|
Net_AddCloseFriend_Response resp = new Net_AddCloseFriend_Response();
|
||||||
resp.setOp(req.getOp());
|
resp.setOp(req.getOp());
|
||||||
@@ -78,25 +82,35 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private byte[] findHeaderHash(Connection c, String blockchainName) throws Exception {
|
||||||
|
String sql = "SELECT block_hash FROM blocks WHERE bch_name = ? AND block_number = 0 LIMIT 1";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, blockchainName);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? rs.getBytes("block_hash") : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void insertCloseFriendIgnoreDuplicate(Connection c,
|
private void insertCloseFriendIgnoreDuplicate(Connection c,
|
||||||
String login,
|
String login,
|
||||||
String toLogin,
|
String toLogin,
|
||||||
String toBchName) throws Exception {
|
String toBchName,
|
||||||
|
byte[] targetHeaderHash) throws Exception {
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO connections_state (
|
INSERT INTO connections_state (
|
||||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
login, rel_type, to_login, to_block_number, to_block_hash
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) DO NOTHING
|
ON CONFLICT (login, rel_type, to_login, to_block_number, to_block_hash) DO NOTHING
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, login);
|
ps.setString(1, login);
|
||||||
ps.setInt(2, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
ps.setInt(2, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||||
ps.setString(3, toLogin);
|
ps.setString(3, toLogin);
|
||||||
ps.setString(4, toBchName);
|
ps.setInt(4, 0);
|
||||||
ps.setInt(5, 0);
|
ps.setBytes(5, targetHeaderHash);
|
||||||
ps.setBytes(6, new byte[32]);
|
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user