SHA256
Compare commits
68
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f645686d87 | ||
|
|
6fcaa5d3d4 | ||
|
|
5d19e17a0b | ||
|
|
52a9f1ac1d | ||
|
|
3dec5e3225 | ||
|
|
90ded1612a | ||
|
|
6f499c68e2 | ||
|
|
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-настроек обязательно обновлять:
|
||||||
|
|||||||
@@ -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;
|
} else if (blockType === 30) {
|
||||||
const arPresent = readU8(bytes, cursorRef);
|
const addressCount = readU8(bytes, cursorRef);
|
||||||
if (arPresent === 1) readStrU8(bytes, cursorRef);
|
if (addressCount !== 1) throw new Error('PDA 1.2 допускает ровно один адрес сервера');
|
||||||
}
|
readU8(bytes, cursorRef); // address_format_type
|
||||||
continue;
|
readU8(bytes, cursorRef); // address_format_version
|
||||||
}
|
|
||||||
if (blockType === 30) {
|
|
||||||
isServer = readU8(bytes, cursorRef) === 1;
|
|
||||||
if (isServer) {
|
|
||||||
cursorRef.value += 1; // address_format_type
|
|
||||||
cursorRef.value += 1; // address_format_version
|
|
||||||
serverAddress = readStrU8(bytes, cursorRef);
|
serverAddress = readStrU8(bytes, cursorRef);
|
||||||
const syncCount = readU8(bytes, cursorRef);
|
} else if (blockType === 40) {
|
||||||
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
|
public Ans104DataItem getDataItem() { return dataItem; }
|
||||||
this.signature64 = new byte[SIGNATURE_LEN];
|
public byte[] getFrameBytes() { return Arrays.copyOf(frameBytes, frameBytes.length); }
|
||||||
bb.get(this.signature64);
|
/** Kept as an alias for code/docs that called the old field preimage. */
|
||||||
|
public byte[] getPreimageBytes() { return getFrameBytes(); }
|
||||||
// preimage = первые blockSize байт (включая frameCode)
|
public byte[] getSignature64() { return dataItem.signature64(); }
|
||||||
this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize);
|
public byte[] getOwner32() { return dataItem.owner32(); }
|
||||||
|
public byte[] getDataItemId32() { return dataItem.id32(); }
|
||||||
// hash32 = sha256(preimage)
|
public byte[] getHash32() { return Arrays.copyOf(hash32, hash32.length); }
|
||||||
this.hash32 = BchCryptoVerifier.sha256(preimage);
|
/** Full serialized ANS-104 DataItem. */
|
||||||
|
public byte[] toBytes() { return dataItem.rawBytes(); }
|
||||||
// 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 BchBlockEntry(byte[] prevHash32,
|
|
||||||
int blockNumber,
|
|
||||||
long timestamp,
|
|
||||||
short type,
|
|
||||||
short subType,
|
|
||||||
short version,
|
|
||||||
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)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+17
-16
@@ -1,29 +1,30 @@
|
|||||||
package blockchain.body;
|
package blockchain.body;
|
||||||
|
|
||||||
import utils.blockchain.BlockchainNameUtil;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля).
|
* BodyHasTarget — дополнительный интерфейс для body, которые ссылаются на логическую цель.
|
||||||
*
|
*
|
||||||
* Новое правило:
|
* Актуальное правило target:
|
||||||
* - toLogin НЕ храним в байтах блока.
|
* - в подписываемых байтах хранится login цели;
|
||||||
* - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN".
|
* - номер fork/blockchainName в target не хранится;
|
||||||
|
* - идентичность цели задаётся как login + blockNumber + blockHash.
|
||||||
*
|
*
|
||||||
* Все методы могут возвращать null.
|
* Это позволяет одной и той же логической записи сохранять ссылки после fork,
|
||||||
|
* если её номер и SHA-256 hash остались прежними.
|
||||||
*/
|
*/
|
||||||
public interface BodyHasTarget {
|
public interface BodyHasTarget {
|
||||||
|
|
||||||
/** login цели (nullable). Вычисляется из toBchName(). */
|
/** login цели. Актуальные runtime-body обязаны переопределять этот метод. */
|
||||||
default String toLogin() {
|
default String toLogin() { return null; }
|
||||||
String bch = toBchName();
|
|
||||||
if (bch == null) return null;
|
|
||||||
return BlockchainNameUtil.loginFromBlockchainName(bch);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** blockchainName цели (nullable). */
|
/**
|
||||||
String toBchName();
|
* Legacy source-level accessor. В новом подписанном target blockchainName не хранится.
|
||||||
|
* Оставлен временно только чтобы старые вспомогательные классы компилировались;
|
||||||
|
* runtime-логика не должна использовать его для новых блоков.
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
|
default String toBchName() { return null; }
|
||||||
|
|
||||||
/** globalNumber цели (nullable). */
|
/** globalNumber цели (nullable только если конкретный subtype не содержит target). */
|
||||||
Integer toBlockGlobalNumber();
|
Integer toBlockGlobalNumber();
|
||||||
|
|
||||||
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
|
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
|
||||||
|
|||||||
+60
-157
@@ -1,7 +1,6 @@
|
|||||||
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;
|
||||||
@@ -12,96 +11,38 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* ConnectionBody — type=3, ver=1 (в заголовке блока).
|
* ConnectionBody — type=3, ver=1 (в заголовке блока).
|
||||||
*
|
*
|
||||||
* subType (в заголовке блока) как MsgSubType:
|
* bodyBytes (BigEndian):
|
||||||
* 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] lineCode
|
||||||
* [4] prevLineNumber
|
* [4] prevLineNumber
|
||||||
* [32] prevLineHash32
|
* [32] prevLineHash32
|
||||||
* [4] thisLineNumber
|
* [4] thisLineNumber
|
||||||
*
|
* [1] toLoginLen (uint8)
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [N] toLogin UTF-8
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber (int32)
|
* [4] toBlockGlobalNumber (int32)
|
||||||
* [32] toBlockHash32 (raw 32 bytes)
|
* [32] toBlockHash32 (raw 32 bytes)
|
||||||
*
|
*
|
||||||
* toLogin вычисляется автоматически из toBlockchainName:
|
* Номер fork/blockchainName в target не хранится.
|
||||||
* 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;
|
||||||
|
|
||||||
// payload
|
public final String toLogin;
|
||||||
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.subType = subType;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
|
||||||
@@ -111,34 +52,25 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
if (!isValidSubType(this.subType)) {
|
if (!isValidSubType(this.subType)) {
|
||||||
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
|
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
|
||||||
}
|
}
|
||||||
|
if (bodyBytes.length < 4 + 4 + 32 + 4 + 1 + 1 + 4 + 32) {
|
||||||
// минимум:
|
|
||||||
// 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");
|
throw new IllegalArgumentException("ConnectionBody too short");
|
||||||
}
|
}
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
this.lineCode = bb.getInt();
|
this.lineCode = bb.getInt();
|
||||||
|
|
||||||
this.prevLineNumber = bb.getInt();
|
this.prevLineNumber = bb.getInt();
|
||||||
|
|
||||||
this.prevLineHash32 = new byte[32];
|
this.prevLineHash32 = new byte[32];
|
||||||
bb.get(this.prevLineHash32);
|
bb.get(this.prevLineHash32);
|
||||||
|
|
||||||
this.thisLineNumber = bb.getInt();
|
this.thisLineNumber = bb.getInt();
|
||||||
|
|
||||||
int bchLen = Byte.toUnsignedInt(bb.get());
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||||
if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
|
if (bb.remaining() < loginLen + 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);
|
|
||||||
|
|
||||||
|
byte[] loginBytes = new byte[loginLen];
|
||||||
|
bb.get(loginBytes);
|
||||||
|
this.toLogin = new String(loginBytes, 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);
|
||||||
|
|
||||||
@@ -150,39 +82,68 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
byte[] prevLineHash32,
|
byte[] prevLineHash32,
|
||||||
int thisLineNumber,
|
int thisLineNumber,
|
||||||
short subType,
|
short subType,
|
||||||
String toBlockchainName,
|
String toLogin,
|
||||||
int toBlockGlobalNumber,
|
int toBlockGlobalNumber,
|
||||||
byte[] toBlockHash32) {
|
byte[] toBlockHash32) {
|
||||||
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
||||||
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
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 (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
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.subType = subType;
|
this.subType = subType;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
|
this.toLogin = toLogin;
|
||||||
this.toBlockchainName = toBlockchainName;
|
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ConnectionBody check() {
|
||||||
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
|
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
||||||
|
|
||||||
|
if (prevLineNumber == -1) {
|
||||||
|
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] toBytes() {
|
||||||
|
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (loginBytes.length == 0 || loginBytes.length > 255)
|
||||||
|
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
|
||||||
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
|
throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
|
int cap = 4 + 4 + 32 + 4 + 1 + loginBytes.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) loginBytes.length);
|
||||||
|
bb.put(loginBytes);
|
||||||
|
bb.putInt(toBlockGlobalNumber);
|
||||||
|
bb.put(toBlockHash32);
|
||||||
|
return bb.array();
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean isValidSubType(short st) {
|
private static boolean isValidSubType(short st) {
|
||||||
int v = st & 0xFFFF;
|
int v = st & 0xFFFF;
|
||||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||||
@@ -211,76 +172,18 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 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 (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public byte[] toBytes() {
|
|
||||||
byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
|
||||||
if (bchBytes.length == 0 || bchBytes.length > 255)
|
|
||||||
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isAllZero32(byte[] b) {
|
private static boolean isAllZero32(byte[] b) {
|
||||||
if (b == null || b.length != 32) return true;
|
if (b == null || b.length != 32) return true;
|
||||||
for (int i = 0; i < 32; i++) if (b[i] != 0) return false;
|
for (byte value : b) if (value != 0) return false;
|
||||||
return true;
|
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 prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
|
||||||
@Override public int lineSeq() { return thisLineNumber; }
|
@Override public int lineSeq() { return thisLineNumber; }
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ===================== */
|
@Override public String toLogin() { return toLogin; }
|
||||||
@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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-35
@@ -15,12 +15,13 @@ import java.util.Objects;
|
|||||||
* 1 = LIKE
|
* 1 = LIKE
|
||||||
* 2 = UNLIKE
|
* 2 = UNLIKE
|
||||||
*
|
*
|
||||||
* bodyBytes (BigEndian), новый формат:
|
* bodyBytes (BigEndian):
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [1] toLoginLen (uint8)
|
||||||
* [N] toBlockchainName UTF-8
|
* [N] toLogin UTF-8
|
||||||
* [4] toBlockGlobalNumber (int32)
|
* [4] toBlockGlobalNumber (int32)
|
||||||
* [32] toBlockHash32 (raw 32 bytes)
|
* [32] toBlockHash32 (raw 32 bytes)
|
||||||
*
|
*
|
||||||
|
* Номер fork/blockchainName в target не хранится.
|
||||||
* ЛИНИИ НЕТ.
|
* ЛИНИИ НЕТ.
|
||||||
*/
|
*/
|
||||||
public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
||||||
@@ -30,10 +31,10 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
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 toBlockchainName;
|
public final String toLogin;
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
|
|
||||||
@@ -49,40 +50,37 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
|||||||
if (!isSupportedSubType(this.subType)) {
|
if (!isSupportedSubType(this.subType)) {
|
||||||
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
|
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");
|
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);
|
||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||||
if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
|
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
byte[] loginBytes = new byte[loginLen];
|
||||||
bb.get(nameBytes);
|
bb.get(loginBytes);
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
this.toLogin = new String(loginBytes, 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);
|
||||||
|
|
||||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
public ReactionBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
|
||||||
this.subType = MsgSubType.REACTION_LIKE;
|
this.subType = MsgSubType.REACTION_LIKE;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
|
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
}
|
}
|
||||||
@@ -91,37 +89,30 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
|||||||
public ReactionBody check() {
|
public ReactionBody check() {
|
||||||
if (!isSupportedSubType(subType))
|
if (!isSupportedSubType(subType))
|
||||||
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
||||||
|
if (toLogin == null || toLogin.isBlank())
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
throw new IllegalArgumentException("toLogin is blank");
|
||||||
throw new IllegalArgumentException("toBlockchainName is blank");
|
|
||||||
if (toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber < 0)
|
||||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (nameBytes.length == 0 || nameBytes.length > 255)
|
if (loginBytes.length == 0 || loginBytes.length > 255)
|
||||||
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
|
||||||
|
|
||||||
int cap = 1 + nameBytes.length + 4 + 32;
|
ByteBuffer bb = ByteBuffer.allocate(1 + loginBytes.length + 4 + 32).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.put((byte) loginBytes.length);
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
bb.put(loginBytes);
|
||||||
bb.put((byte) nameBytes.length);
|
|
||||||
bb.put(nameBytes);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ====================== */
|
@Override public String toLogin() { return toLogin; }
|
||||||
|
|
||||||
@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; }
|
||||||
|
|
||||||
|
|||||||
+24
-35
@@ -13,11 +13,11 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* StatusActionBody — type=5, ver=1.
|
* StatusActionBody — type=5, ver=1.
|
||||||
*
|
*
|
||||||
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
|
* Все STATUS_ACTION имеют target на конкретный логический блок и опциональный текст-пояснение.
|
||||||
*
|
*
|
||||||
* Формат bodyBytes (BigEndian):
|
* Формат bodyBytes (BigEndian):
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [1] toLoginLen (uint8)
|
||||||
* [N] toBlockchainName UTF-8
|
* [N] toLogin UTF-8
|
||||||
* [4] toBlockGlobalNumber
|
* [4] toBlockGlobalNumber
|
||||||
* [32] toBlockHash32
|
* [32] toBlockHash32
|
||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
@@ -31,7 +31,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
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 toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
public final String message;
|
public final String message;
|
||||||
@@ -51,33 +51,32 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
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");
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
|
||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
|
if (loginLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toLoginLen is 0");
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
ensureMin(bb, loginLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
byte[] loginBytes = new byte[loginLen];
|
||||||
bb.get(nameBytes);
|
bb.get(loginBytes);
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
this.toLogin = new String(loginBytes, 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);
|
||||||
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
|
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
|
||||||
|
|
||||||
ensureNoTail(bb, "StatusActionBody");
|
ensureNoTail(bb, "StatusActionBody");
|
||||||
}
|
}
|
||||||
|
|
||||||
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
public StatusActionBody(short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == 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");
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
this.message = message;
|
this.message = message;
|
||||||
@@ -85,16 +84,10 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public StatusActionBody check() {
|
public StatusActionBody check() {
|
||||||
if (!isSupportedSubType(subType)) {
|
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
||||||
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("STATUS_ACTION toLogin is blank");
|
||||||
}
|
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
|
|
||||||
}
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
|
||||||
}
|
|
||||||
if (message == null) throw new IllegalArgumentException("message is null");
|
if (message == null) throw new IllegalArgumentException("message is null");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -104,15 +97,14 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
if (loginUtf8.length == 0 || loginUtf8.length > 255)
|
||||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255");
|
throw new IllegalArgumentException("STATUS_ACTION toLogin utf8 len must be 1..255");
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||||
.order(ByteOrder.BIG_ENDIAN);
|
.order(ByteOrder.BIG_ENDIAN);
|
||||||
bb.put((byte) nameUtf8.length);
|
bb.put((byte) loginUtf8.length);
|
||||||
bb.put(nameUtf8);
|
bb.put(loginUtf8);
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
bb.putShort((short) msgUtf8.length);
|
bb.putShort((short) msgUtf8.length);
|
||||||
@@ -120,7 +112,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
@Override public String toLogin() { return toLogin; }
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
@@ -141,14 +133,11 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) return "";
|
if (len == 0) return "";
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
byte[] bytes = new byte[len];
|
||||||
bb.get(bytes);
|
bb.get(bytes);
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
} catch (CharacterCodingException e) {
|
} catch (CharacterCodingException e) {
|
||||||
|
|||||||
+36
-62
@@ -33,23 +33,13 @@ import java.util.Objects;
|
|||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
* [N] text UTF-8
|
* [N] text UTF-8
|
||||||
*
|
*
|
||||||
* EDIT_POST:
|
* EDIT_POST / REPOST:
|
||||||
* [4] lineCode
|
* [4] lineCode
|
||||||
* [4] prevLineNumber
|
* [4] prevLineNumber
|
||||||
* [32] prevLineHash32
|
* [32] prevLineHash32
|
||||||
* [4] thisLineNumber
|
* [4] thisLineNumber
|
||||||
* [4] toBlockGlobalNumber (int32)
|
* [1] toLoginLen (uint8)
|
||||||
* [32] toBlockHash32
|
* [N] toLogin UTF-8
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [N] text UTF-8
|
|
||||||
*
|
|
||||||
* REPOST:
|
|
||||||
* [4] lineCode
|
|
||||||
* [4] prevLineNumber
|
|
||||||
* [32] prevLineHash32
|
|
||||||
* [4] thisLineNumber
|
|
||||||
* [1] toBlockchainNameLen (uint8)
|
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber (int32)
|
* [4] toBlockGlobalNumber (int32)
|
||||||
* [32] toBlockHash32
|
* [32] toBlockHash32
|
||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
@@ -72,7 +62,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
public final int thisLineNumber;
|
public final int thisLineNumber;
|
||||||
|
|
||||||
// target (для EDIT_POST / REPOST)
|
// target (для EDIT_POST / REPOST)
|
||||||
public final String toBlockchainName; // nullable для POST/EDIT_POST
|
public final String toLogin; // nullable для сообщений без target
|
||||||
public final Integer toBlockGlobalNumber; // nullable для POST
|
public final Integer toBlockGlobalNumber; // nullable для POST
|
||||||
public final byte[] toBlockHash32; // nullable для POST
|
public final byte[] toBlockHash32; // nullable для POST
|
||||||
|
|
||||||
@@ -116,29 +106,19 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
|
|
||||||
this.thisLineNumber = bb.getInt();
|
this.thisLineNumber = bb.getInt();
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
// нужен target
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TEXT target missing");
|
||||||
ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target");
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
int tgtNum = bb.getInt();
|
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||||
byte[] tgtHash = new byte[32];
|
ensureMin(bb, loginLen + 4 + 32 + 2, "TEXT target payload too short");
|
||||||
bb.get(tgtHash);
|
byte[] loginBytes = new byte[loginLen];
|
||||||
|
bb.get(loginBytes);
|
||||||
this.toBlockchainName = null;
|
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
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);
|
||||||
} else {
|
} else {
|
||||||
this.toBlockchainName = null;
|
this.toLogin = null;
|
||||||
this.toBlockGlobalNumber = null;
|
this.toBlockGlobalNumber = null;
|
||||||
this.toBlockHash32 = null;
|
this.toBlockHash32 = null;
|
||||||
}
|
}
|
||||||
@@ -158,7 +138,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
short subType,
|
short subType,
|
||||||
Integer toBlockGlobalNumber,
|
Integer toBlockGlobalNumber,
|
||||||
byte[] toBlockHash32,
|
byte[] toBlockHash32,
|
||||||
String toBlockchainName,
|
String toLogin,
|
||||||
String message) {
|
String message) {
|
||||||
|
|
||||||
Objects.requireNonNull(message, "message == null");
|
Objects.requireNonNull(message, "message == null");
|
||||||
@@ -189,27 +169,29 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
this.thisLineNumber = thisLineNumber;
|
this.thisLineNumber = thisLineNumber;
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.toBlockchainName = null;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
} else {
|
} else {
|
||||||
this.toBlockchainName = null;
|
this.toLogin = null;
|
||||||
this.toBlockGlobalNumber = null;
|
this.toBlockGlobalNumber = null;
|
||||||
this.toBlockHash32 = null;
|
this.toBlockHash32 = null;
|
||||||
}
|
}
|
||||||
@@ -240,13 +222,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
||||||
if (toBlockchainName != null)
|
if (toLogin == null || toLogin.isBlank())
|
||||||
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName");
|
throw new IllegalArgumentException("EDIT_POST toLogin is blank");
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
if (message == null || message.isBlank())
|
if (message == null || message.isBlank())
|
||||||
throw new IllegalArgumentException("REPOST message is blank");
|
throw new IllegalArgumentException("REPOST message is blank");
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
if (toLogin == null || toLogin.isBlank())
|
||||||
throw new IllegalArgumentException("REPOST toBlockchainName is blank");
|
throw new IllegalArgumentException("REPOST toLogin is blank");
|
||||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||||
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
|
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
@@ -259,7 +241,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
} else if (message == null) {
|
} else if (message == null) {
|
||||||
throw new IllegalArgumentException("Text message is null");
|
throw new IllegalArgumentException("Text message is null");
|
||||||
}
|
}
|
||||||
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
if (toLogin != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||||
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
|
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,19 +266,14 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||||
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
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 {
|
} else {
|
||||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName");
|
if (toLogin == null) throw new IllegalArgumentException("target missing toLogin");
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
byte[] nameUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||||
throw new IllegalArgumentException("REPOST toBlockchainName utf8 len must be 1..255");
|
throw new IllegalArgumentException("target toLogin utf8 len must be 1..255");
|
||||||
}
|
}
|
||||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPOST missing toBlockGlobalNumber");
|
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("target missing toBlockGlobalNumber");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32");
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("target toBlockHash32 != 32");
|
||||||
cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length;
|
cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,13 +284,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||||
bb.putInt(thisLineNumber);
|
bb.putInt(thisLineNumber);
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
bb.putInt(toBlockGlobalNumber);
|
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
bb.put(toBlockHash32);
|
bb.put((byte) loginUtf8.length);
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
bb.put(loginUtf8);
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
|
||||||
bb.put((byte) nameUtf8.length);
|
|
||||||
bb.put(nameUtf8);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
}
|
}
|
||||||
@@ -331,7 +305,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
@Override public int lineSeq() { return thisLineNumber; }
|
@Override public int lineSeq() { return thisLineNumber; }
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ===================== */
|
/* ====================== BodyHasTarget ===================== */
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
@Override public String toLogin() { return toLogin; }
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
|
|||||||
+25
-38
@@ -11,14 +11,11 @@ import java.util.Arrays;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TextRatingBody — type=1, ver=1.
|
* TextRatingBody — type=1, ver=1, subType=30.
|
||||||
*
|
|
||||||
* subType:
|
|
||||||
* - RATING (30)
|
|
||||||
*
|
*
|
||||||
* Формат bodyBytes (BigEndian):
|
* Формат bodyBytes (BigEndian):
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [1] toLoginLen (uint8)
|
||||||
* [N] toBlockchainName UTF-8
|
* [N] toLogin UTF-8
|
||||||
* [4] toBlockGlobalNumber
|
* [4] toBlockGlobalNumber
|
||||||
* [32] toBlockHash32
|
* [32] toBlockHash32
|
||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
@@ -32,7 +29,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
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 toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
public final String message;
|
public final String message;
|
||||||
@@ -52,33 +49,32 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
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");
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
|
||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
|
if (loginLen <= 0) throw new IllegalArgumentException("RATING toLoginLen is 0");
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
|
ensureMin(bb, loginLen + 4 + 32 + 2, "RATING payload too short");
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
byte[] loginBytes = new byte[loginLen];
|
||||||
bb.get(nameBytes);
|
bb.get(loginBytes);
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
this.toLogin = new String(loginBytes, 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);
|
||||||
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
|
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
|
||||||
|
|
||||||
ensureNoTail(bb, "TextRatingBody");
|
ensureNoTail(bb, "TextRatingBody");
|
||||||
}
|
}
|
||||||
|
|
||||||
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
public TextRatingBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == 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 (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
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");
|
||||||
|
|
||||||
this.subType = MsgSubType.TEXT_RATING;
|
this.subType = MsgSubType.TEXT_RATING;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
this.message = message;
|
this.message = message;
|
||||||
@@ -86,16 +82,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TextRatingBody check() {
|
public TextRatingBody check() {
|
||||||
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF))
|
||||||
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
|
throw new IllegalArgumentException("Bad RATING subType");
|
||||||
}
|
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("RATING toLogin is blank");
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("RATING toBlockchainName is blank");
|
|
||||||
}
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
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");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -106,15 +97,14 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
if (loginUtf8.length == 0 || loginUtf8.length > 255)
|
||||||
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
|
throw new IllegalArgumentException("RATING toLogin utf8 len must be 1..255");
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||||
.order(ByteOrder.BIG_ENDIAN);
|
.order(ByteOrder.BIG_ENDIAN);
|
||||||
bb.put((byte) nameUtf8.length);
|
bb.put((byte) loginUtf8.length);
|
||||||
bb.put(nameUtf8);
|
bb.put(loginUtf8);
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
bb.putShort((short) msgUtf8.length);
|
bb.putShort((short) msgUtf8.length);
|
||||||
@@ -122,7 +112,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
@Override public String toLogin() { return toLogin; }
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
@@ -130,14 +120,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
|
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
byte[] bytes = new byte[len];
|
||||||
bb.get(bytes);
|
bb.get(bytes);
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||||
|
|||||||
+33
-105
@@ -17,85 +17,57 @@ import java.util.Objects;
|
|||||||
* - REPLY (20)
|
* - REPLY (20)
|
||||||
* - EDIT_REPLY (21)
|
* - EDIT_REPLY (21)
|
||||||
*
|
*
|
||||||
* Форматы bodyBytes (BigEndian):
|
* Оба subtype используют одинаковый target-формат:
|
||||||
*
|
* [1] toLoginLen (uint8)
|
||||||
* REPLY:
|
* [N] toLogin UTF-8
|
||||||
* [1] toBlockchainNameLen (uint8)
|
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber
|
* [4] toBlockGlobalNumber
|
||||||
* [32] toBlockHash32
|
* [32] toBlockHash32
|
||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
* [M] text UTF-8
|
* [M] text UTF-8
|
||||||
*
|
*
|
||||||
* EDIT_REPLY:
|
* Для EDIT_REPLY text может быть пустым (логическое удаление).
|
||||||
* [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;
|
||||||
|
|
||||||
// target
|
public final String toLogin;
|
||||||
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 ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||||
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
|
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||||
}
|
}
|
||||||
|
|
||||||
int st = this.subType & 0xFFFF;
|
int st = this.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("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
|
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);
|
||||||
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TextReplyBody too short");
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
// минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2]
|
if (loginLen <= 0) throw new IllegalArgumentException("TextReplyBody toLoginLen is 0");
|
||||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");
|
ensureMin(bb, loginLen + 4 + 32 + 2, "TextReplyBody payload 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);
|
|
||||||
|
|
||||||
|
byte[] loginBytes = new byte[loginLen];
|
||||||
|
bb.get(loginBytes);
|
||||||
|
this.toLogin = new String(loginBytes, 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);
|
||||||
|
|
||||||
} else {
|
|
||||||
// EDIT_REPLY: target без имени
|
|
||||||
ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");
|
|
||||||
|
|
||||||
this.toBlockchainName = null;
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
|
||||||
|
|
||||||
this.toBlockHash32 = new byte[32];
|
|
||||||
bb.get(this.toBlockHash32);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
||||||
ensureNoTail(bb, "TextReplyBody");
|
ensureNoTail(bb, "TextReplyBody");
|
||||||
}
|
}
|
||||||
@@ -103,11 +75,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
public TextReplyBody(short subType,
|
public TextReplyBody(short subType,
|
||||||
int toBlockGlobalNumber,
|
int toBlockGlobalNumber,
|
||||||
byte[] toBlockHash32,
|
byte[] toBlockHash32,
|
||||||
String toBlockchainName,
|
String toLogin,
|
||||||
String message) {
|
String message) {
|
||||||
|
|
||||||
Objects.requireNonNull(message, "message == null");
|
Objects.requireNonNull(message, "message == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
Objects.requireNonNull(toLogin, "toLogin == 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)) {
|
||||||
@@ -116,25 +88,15 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
|
||||||
throw new IllegalArgumentException("message is blank");
|
throw new IllegalArgumentException("message is blank");
|
||||||
}
|
}
|
||||||
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
|
||||||
this.toBlockchainName = toBlockchainName;
|
|
||||||
} else {
|
|
||||||
// EDIT_REPLY: имя не хранить
|
|
||||||
this.toBlockchainName = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
|
|
||||||
this.message = message;
|
this.message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,23 +105,14 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
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 TextReplyBody subType: " + st);
|
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
||||||
|
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
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())
|
if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank");
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
} else if (message == null) {
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
throw new IllegalArgumentException("EDIT_REPLY message is null");
|
||||||
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
|
|
||||||
} else {
|
|
||||||
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
|
|
||||||
if (toBlockchainName != null)
|
|
||||||
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,47 +120,27 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
|
||||||
throw new IllegalArgumentException("Text payload is empty");
|
throw new IllegalArgumentException("Text payload is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
if (loginUtf8.length == 0 || loginUtf8.length > 255)
|
||||||
|
throw new IllegalArgumentException("TextReplyBody toLogin utf8 len must be 1..255");
|
||||||
|
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255)
|
.order(ByteOrder.BIG_ENDIAN);
|
||||||
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");
|
bb.put((byte) loginUtf8.length);
|
||||||
|
bb.put(loginUtf8);
|
||||||
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.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
bb.putShort((short) msgUtf8.length);
|
bb.putShort((short) msgUtf8.length);
|
||||||
bb.put(msgUtf8);
|
bb.put(msgUtf8);
|
||||||
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
// EDIT_REPLY
|
@Override public String toLogin() { return toLogin; }
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ====================== */
|
|
||||||
|
|
||||||
@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; }
|
||||||
|
|
||||||
@@ -215,8 +148,6 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== helpers ====================== */
|
|
||||||
|
|
||||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
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) {
|
||||||
@@ -224,14 +155,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
throw new IllegalArgumentException(fieldName + " is empty");
|
throw new IllegalArgumentException(fieldName + " is empty");
|
||||||
}
|
}
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
byte[] bytes = new byte[len];
|
||||||
bb.get(bytes);
|
bb.get(bytes);
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package blockchain;
|
||||||
|
|
||||||
|
import blockchain.body.ConnectionBody;
|
||||||
|
import blockchain.body.ReactionBody;
|
||||||
|
import blockchain.body.StatusActionBody;
|
||||||
|
import blockchain.body.TextLineBody;
|
||||||
|
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);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reactionRoundTripStoresLoginNotBlockchainName() {
|
||||||
|
ReactionBody source = new ReactionBody("alice", 42, HASH);
|
||||||
|
ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check();
|
||||||
|
|
||||||
|
assertEquals("alice", parsed.toLogin());
|
||||||
|
assertEquals(42, parsed.toBlockGlobalNumber());
|
||||||
|
assertArrayEquals(HASH, parsed.toBlockHashBytes());
|
||||||
|
assertFalse(new String(source.toBytes()).contains("alice-001"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyAndEditReplyUseSameLoginTargetShape() {
|
||||||
|
TextReplyBody reply = new TextReplyBody(MsgSubType.TEXT_REPLY, 11, HASH, "alice", "reply");
|
||||||
|
TextReplyBody parsedReply = new TextReplyBody(MsgSubType.TEXT_REPLY, TextReplyBody.VER, reply.toBytes()).check();
|
||||||
|
assertEquals("alice", parsedReply.toLogin());
|
||||||
|
|
||||||
|
TextReplyBody edit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, 11, HASH, "alice", "");
|
||||||
|
TextReplyBody parsedEdit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, TextReplyBody.VER, edit.toBytes()).check();
|
||||||
|
assertEquals("alice", parsedEdit.toLogin());
|
||||||
|
assertEquals("", parsedEdit.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editPostAndConnectionCarryLoginTarget() {
|
||||||
|
TextLineBody editPost = new TextLineBody(
|
||||||
|
0, -1, new byte[32], -1,
|
||||||
|
MsgSubType.TEXT_EDIT_POST,
|
||||||
|
5, HASH, "alice", "edited"
|
||||||
|
);
|
||||||
|
TextLineBody parsedEditPost = new TextLineBody(MsgSubType.TEXT_EDIT_POST, TextLineBody.VER, editPost.toBytes()).check();
|
||||||
|
assertEquals("alice", parsedEditPost.toLogin());
|
||||||
|
|
||||||
|
ConnectionBody connection = new ConnectionBody(
|
||||||
|
0, -1, new byte[32], -1,
|
||||||
|
MsgSubType.CONNECTION_FOLLOW,
|
||||||
|
"alice", 0, HASH
|
||||||
|
);
|
||||||
|
ConnectionBody parsedConnection = new ConnectionBody(MsgSubType.CONNECTION_FOLLOW, ConnectionBody.VER, connection.toBytes()).check();
|
||||||
|
assertEquals("alice", parsedConnection.toLogin());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusActionCarriesLoginTarget() {
|
||||||
|
StatusActionBody source = new StatusActionBody(MsgSubType.STATUS_STARTED, "alice", 77, HASH, "");
|
||||||
|
StatusActionBody parsed = new StatusActionBody(MsgSubType.STATUS_STARTED, StatusActionBody.VER, source.toBytes()).check();
|
||||||
|
assertEquals("alice", parsed.toLogin());
|
||||||
|
assertEquals(77, parsed.toBlockGlobalNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
+3
@@ -17,6 +17,9 @@ 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:";
|
||||||
|
|
||||||
|
/** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */
|
||||||
|
public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:";
|
||||||
|
|
||||||
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
||||||
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# utils.crypto
|
# utils.crypto
|
||||||
|
|
||||||
Пакет отвечает за криптографию — подписи и хэши блоков.
|
Пакет отвечает за криптографию — подписи и хэши блоков.
|
||||||
Используется при создании и проверке целостности `.bch`-блоков.
|
Используется при создании и проверке криптографических сущностей SHiNE; пользовательские blockchain-файлы `.bch` больше не используются.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ public final class DatabaseInitializer {
|
|||||||
public static final int SCHEMA_VERSION_17 = 17;
|
public static final int SCHEMA_VERSION_17 = 17;
|
||||||
public static final int SCHEMA_VERSION_18 = 18;
|
public static final int SCHEMA_VERSION_18 = 18;
|
||||||
public static final int SCHEMA_VERSION_19 = 19;
|
public static final int SCHEMA_VERSION_19 = 19;
|
||||||
|
public static final int SCHEMA_VERSION_20 = 20;
|
||||||
|
public static final int SCHEMA_VERSION_21 = 21;
|
||||||
|
public static final int SCHEMA_VERSION_22 = 22;
|
||||||
|
public static final int SCHEMA_VERSION_23 = 23;
|
||||||
|
public static final int SCHEMA_VERSION_24 = 24;
|
||||||
|
public static final int SCHEMA_VERSION_25 = 25;
|
||||||
|
public static final int SCHEMA_VERSION_26 = 26;
|
||||||
|
public static final int SCHEMA_VERSION_27 = 27;
|
||||||
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_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||||
@@ -55,6 +63,14 @@ public final class DatabaseInitializer {
|
|||||||
public static final String POSTGRES_MIGRATION_V17_RESOURCE = "postgres/migration_v17.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_V18_RESOURCE = "postgres/migration_v18.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
|
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V26_RESOURCE = "postgres/migration_v26.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V27_RESOURCE = "postgres/migration_v27.sql";
|
||||||
|
|
||||||
private DatabaseInitializer() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -200,6 +216,38 @@ public final class DatabaseInitializer {
|
|||||||
runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE);
|
runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE);
|
||||||
currentVersion = SCHEMA_VERSION_19;
|
currentVersion = SCHEMA_VERSION_19;
|
||||||
}
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_20) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_20;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_21) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_21;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_22) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V22_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_22;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_23) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V23_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_23;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_24) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_24;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_25) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_25;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_26) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V26_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_26;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_27) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V27_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_27;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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++) {
|
||||||
|
|||||||
@@ -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
-2
@@ -23,7 +23,7 @@ import java.sql.SQLException;
|
|||||||
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
|
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
|
||||||
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
|
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
|
||||||
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
|
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
|
||||||
* - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию:
|
* - пользовательские blockchain-файлы больше не существуют; cleanup полностью DB-only.
|
||||||
* SQL-транзакция и файловая система не коммитятся атомарно вместе;
|
* SQL-транзакция и файловая система не коммитятся атомарно вместе;
|
||||||
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
|
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
|
||||||
* отдельным recovery/resync-слоем после успешного commit.
|
* отдельным recovery/resync-слоем после успешного commit.
|
||||||
@@ -71,7 +71,7 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
* - если blockchain_state уже отсутствует, login берём из blockchainName.
|
* - если blockchain_state уже отсутствует, login берём из blockchainName.
|
||||||
*
|
*
|
||||||
* Отдельно важно:
|
* Отдельно важно:
|
||||||
* - здесь НЕТ удаления .bch/.tmp_bch;
|
* - filesystem blockchain storage отсутствует;
|
||||||
* - здесь НЕТ повторной загрузки цепочки;
|
* - здесь НЕТ повторной загрузки цепочки;
|
||||||
* - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow.
|
* - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow.
|
||||||
*/
|
*/
|
||||||
@@ -127,6 +127,100 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* После смены fork обновляет только runtime-кэш физического blockchainName у логических target-ссылок.
|
||||||
|
* Подписанная идентичность target уже задаётся login + blockNumber + blockHash; поэтому смена cache value
|
||||||
|
* не меняет смысл ссылки и не требует переподписывать чужие блоки.
|
||||||
|
*/
|
||||||
|
public void refreshLogicalTargetBlockchainCache(String login, String oldBlockchainName, String newBlockchainName) throws SQLException {
|
||||||
|
if (login == null || login.isBlank() || newBlockchainName == null || newBlockchainName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("login/newBlockchainName are required");
|
||||||
|
}
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
updateTargetCacheByLogin(c, "blocks", login, newBlockchainName);
|
||||||
|
updateTargetCacheByLogin(c, "connections_state", login, newBlockchainName);
|
||||||
|
updateTargetCacheByLogin(c, "reactions_state", login, newBlockchainName);
|
||||||
|
updateTargetCacheByLogin(c, "message_stats", login, newBlockchainName);
|
||||||
|
if (oldBlockchainName != null && !oldBlockchainName.isBlank()) {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("UPDATE message_views_state SET to_bch_name=? WHERE to_bch_name=?")) {
|
||||||
|
ps.setString(1, newBlockchainName);
|
||||||
|
ps.setString(2, oldBlockchainName);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("UPDATE channel_read_state SET owner_bch_name=? WHERE owner_bch_name=?")) {
|
||||||
|
ps.setString(1, newBlockchainName);
|
||||||
|
ps.setString(2, oldBlockchainName);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuildInboundMessageCounters(c, login, newBlockchainName);
|
||||||
|
rebuildStatsState(c);
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
if (e instanceof SQLException sql) throw sql;
|
||||||
|
throw new SQLException("Failed to refresh logical target blockchain cache for login=" + login, e);
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* После replay candidate-цепочки её собственные message_stats уже существуют, но входящие реакции/ответы
|
||||||
|
* других пользователей были созданы раньше и не проходили триггеры повторно. Поэтому пересчитываем
|
||||||
|
* агрегаты из исходных таблиц после перепривязки runtime to_bch_name cache.
|
||||||
|
*/
|
||||||
|
private void rebuildInboundMessageCounters(Connection c, String login, String newBlockchainName) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE message_stats ms
|
||||||
|
SET likes_count = (
|
||||||
|
SELECT COUNT(*)::INTEGER
|
||||||
|
FROM reactions_state rs
|
||||||
|
WHERE rs.reaction_type = ?
|
||||||
|
AND rs.last_sub_type = ?
|
||||||
|
AND LOWER(rs.to_login) = LOWER(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
|
||||||
|
),
|
||||||
|
replies_count = (
|
||||||
|
SELECT COUNT(*)::INTEGER
|
||||||
|
FROM blocks b
|
||||||
|
WHERE b.msg_type = 1
|
||||||
|
AND b.msg_sub_type = ?
|
||||||
|
AND LOWER(b.to_login) = LOWER(ms.to_login)
|
||||||
|
AND b.to_bch_name = ms.to_bch_name
|
||||||
|
AND b.to_block_number = ms.to_block_number
|
||||||
|
AND b.to_block_hash = ms.to_block_hash
|
||||||
|
)
|
||||||
|
WHERE LOWER(ms.to_login) = LOWER(?)
|
||||||
|
AND ms.to_bch_name = ?
|
||||||
|
""")) {
|
||||||
|
ps.setInt(1, DatabaseInitializer.REACTION_LIKE);
|
||||||
|
ps.setInt(2, DatabaseInitializer.REACTION_LIKE);
|
||||||
|
ps.setInt(3, DatabaseInitializer.TEXT_REPLY);
|
||||||
|
ps.setString(4, login);
|
||||||
|
ps.setString(5, newBlockchainName);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateTargetCacheByLogin(Connection c, String table, String login, String newBlockchainName) throws SQLException {
|
||||||
|
String sql = "UPDATE " + table + " SET to_bch_name=? WHERE LOWER(to_login)=LOWER(?) AND to_bch_name IS DISTINCT FROM ?";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, newBlockchainName);
|
||||||
|
ps.setString(2, login);
|
||||||
|
ps.setString(3, newBlockchainName);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
|
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login
|
SELECT login
|
||||||
|
|||||||
@@ -9,286 +9,146 @@ 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 (
|
||||||
login,
|
login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||||
bch_name,
|
to_login,to_bch_name,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.setString(i++, e.getBchName());
|
||||||
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());
|
setNullableString(ps, i++, e.getToBchName());
|
||||||
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,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,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 bch_name 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
|
||||||
|
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 bch_name=? AND block_number=? LIMIT 1")) {
|
||||||
|
ps.setString(1,bchName); ps.setInt(2,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 bch_name=? AND block_number=? LIMIT 1";
|
||||||
SELECT
|
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,bchName); ps.setInt(2,blockNumber); try(ResultSet rs=ps.executeQuery()){return rs.next()?mapRow(rs):null;} }
|
||||||
login,
|
}
|
||||||
bch_name,
|
public BlockEntry getByNumber(String bchName,int blockNumber)throws SQLException{try(Connection c=db.getConnection()){return getByNumber(c,bchName,blockNumber);} }
|
||||||
block_number,
|
|
||||||
msg_type,
|
public List<BlockEntry> listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} }
|
||||||
msg_sub_type,
|
public List<BlockEntry> listRangeByNumber(Connection c,String bchName,int from,int to)throws SQLException{
|
||||||
block_bytes,
|
String sql=baseSelect()+" WHERE bch_name=? AND block_number>=? AND block_number<=? ORDER BY block_number ASC";
|
||||||
to_login,
|
List<BlockEntry> out=new ArrayList<>();
|
||||||
to_bch_name,
|
try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,bchName);ps.setInt(2,from);ps.setInt(3,to);try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(mapRow(rs));}}
|
||||||
to_block_number,
|
return out;
|
||||||
to_block_hash,
|
}
|
||||||
block_hash,
|
|
||||||
block_signature,
|
private static String baseSelect(){return """
|
||||||
edited_by_block_number,
|
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||||
line_code,
|
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
|
||||||
prev_line_number,
|
arweave_publish_pending,arweave_published_at_ms,
|
||||||
prev_line_hash,
|
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||||
this_line_number
|
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name = ? AND block_number = ?
|
""";}
|
||||||
LIMIT 1
|
|
||||||
""";
|
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, bchName);
|
|
||||||
ps.setInt(2, blockNumber);
|
|
||||||
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
|
||||||
if (!rs.next()) return null;
|
|
||||||
return mapRow(rs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public BlockEntry getByNumber(String bchName, int blockNumber) throws SQLException {
|
|
||||||
try (Connection c = db.getConnection()) {
|
|
||||||
return getByNumber(c, bchName, blockNumber);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.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"));
|
||||||
|
e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); 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 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,298 @@
|
|||||||
|
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 owner_bch_name=? AND channel_name=?
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, viewerLogin);
|
||||||
|
ps.setString(2, 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 cs.to_bch_name=cns.owner_bch_name
|
||||||
|
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=crs.owner_bch_name
|
||||||
|
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 cs.to_bch_name=cns.owner_bch_name
|
||||||
|
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_bch_name, channel_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
owner_login = EXCLUDED.owner_login,
|
||||||
|
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 crs.owner_bch_name = ?
|
||||||
|
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 cs.to_bch_name=cns.owner_bch_name
|
||||||
|
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=crs.owner_bch_name
|
||||||
|
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, 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 crs.owner_bch_name=?
|
||||||
|
AND cns.owner_bch_name=crs.owner_bch_name
|
||||||
|
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, ownerBchName);
|
||||||
|
ps.setInt(3, rootBlockNumber);
|
||||||
|
return ps.executeUpdate() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-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,35 @@ 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' = ?
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+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,29 @@ public final class UserProfileStateDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective outgoing social relation with priority close_friend > friend > contact > none.
|
||||||
|
*
|
||||||
|
* Use the canonical relation lookup instead of comparing only connections_state.to_login:
|
||||||
|
* older/current blocks may address a user by blockchain name, so a direct to_login-only
|
||||||
|
* query can incorrectly report "none" even when ListContacts/Connections sees the relation.
|
||||||
|
*/
|
||||||
|
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 +119,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 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 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<>();
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public class BlockEntry {
|
|||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -85,6 +88,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; }
|
||||||
|
|
||||||
|
|||||||
+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;
|
||||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
|||||||
);
|
);
|
||||||
|
|
||||||
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, 26, 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 +84,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 +292,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 +420,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,
|
||||||
@@ -483,6 +498,10 @@ CREATE TABLE IF NOT EXISTS blocks (
|
|||||||
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 +513,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 +813,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 +1688,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 +2051,141 @@ 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;
|
||||||
|
|
||||||
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,27,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),
|
||||||
|
|||||||
+113
-159
@@ -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,223 +172,165 @@ 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;
|
|
||||||
} else if (blockType == 2) {
|
|
||||||
clientKey32 = slice(raw, c, 32);
|
|
||||||
c += 32;
|
|
||||||
} else if (blockType == 3) {
|
|
||||||
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
|
if (blockType == 2) {
|
||||||
accessServers.add(normalizedAccessServerLogin);
|
if (c + 32 > recordLen) return null;
|
||||||
}
|
clientKey32 = slice(raw, c, 32);
|
||||||
}
|
|
||||||
} 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;
|
c += 32;
|
||||||
sessions.add(new ParsedSessionRecord(
|
continue;
|
||||||
sessionType,
|
|
||||||
sessionVersion,
|
|
||||||
sessionName,
|
|
||||||
sessionPubKey32
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
} else if (blockType == 70) {
|
|
||||||
c += 1;
|
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 {
|
} else {
|
||||||
return null;
|
// Unknown variable PDA 1.2 block: forward-compatible skip by payload_len.
|
||||||
|
c = payloadEnd;
|
||||||
|
}
|
||||||
|
c = payloadEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c > recordLen) return null;
|
if (blockchainKey32 == null || clientKey32 == null || forkCount <= 0) return null;
|
||||||
}
|
String blockchainName = login + "-" + String.format(Locale.ROOT, "%03d", forkCount);
|
||||||
|
|
||||||
if (blockchainName == null || blockchainKey32 == null || clientKey32 == null) return null;
|
|
||||||
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++) {
|
|
||||||
c += 1; // blockchain_type
|
|
||||||
int bchLen = u8(raw, c++);
|
|
||||||
c += bchLen;
|
|
||||||
c += 32; // blockchain pubkey
|
|
||||||
c += 8; // paid_limit_bytes
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
if (c + 2 > recordLen) return null;
|
||||||
} else if (blockType == 30) {
|
int payloadLen = u16le(raw, c);
|
||||||
int isServerValue = u8(raw, c++);
|
c += 2;
|
||||||
if (isServerValue == 1) {
|
int payloadEnd = c + payloadLen;
|
||||||
isServer = true;
|
if (payloadEnd < c || payloadEnd > recordLen) return null;
|
||||||
|
|
||||||
|
if (blockType == 30) {
|
||||||
|
if (c >= payloadEnd) return null;
|
||||||
|
int addressCount = u8(raw, c++);
|
||||||
|
if (addressCount != 1) return null;
|
||||||
|
for (int j = 0; j < addressCount; j++) {
|
||||||
|
if (c + 3 > payloadEnd) return null;
|
||||||
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++);
|
|
||||||
serverAddress = new String(raw, c, addrLen, StandardCharsets.UTF_8);
|
|
||||||
c += addrLen;
|
|
||||||
int syncCount = u8(raw, c++);
|
|
||||||
for (int j = 0; j < syncCount; j++) {
|
|
||||||
int n = u8(raw, c++);
|
int n = u8(raw, c++);
|
||||||
String syncLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
if (c + n > payloadEnd) return null;
|
||||||
|
String address = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||||
c += n;
|
c += n;
|
||||||
syncServers.add(normalizeLogin(syncLogin));
|
if (!isServer) {
|
||||||
|
isServer = true;
|
||||||
|
serverAddress = address;
|
||||||
}
|
}
|
||||||
} 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) {
|
c = payloadEnd;
|
||||||
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) {
|
return new ParsedServerProfile(login, isServer, serverAddress, List.of());
|
||||||
c += 1;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c > recordLen) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -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);
|
||||||
|
|||||||
+149
-12
@@ -26,6 +26,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 +43,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 +98,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 +164,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 +193,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 +213,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 +223,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 +307,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();
|
||||||
@@ -397,6 +440,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 {
|
||||||
@@ -454,7 +513,7 @@ 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)
|
// 6) ANS-104 Ed25519 подпись и owner
|
||||||
boolean sigOk;
|
boolean sigOk;
|
||||||
try {
|
try {
|
||||||
sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32);
|
sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32);
|
||||||
@@ -512,7 +571,7 @@ 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);
|
||||||
@@ -525,6 +584,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);
|
||||||
@@ -586,9 +647,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 +672,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);
|
||||||
|
|
||||||
|
if (replicateAfterWrite) {
|
||||||
addBlockSyncService.replicateAsync(blockchainName, block.blockNumber);
|
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;
|
||||||
@@ -916,17 +1046,24 @@ 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)
|
||||||
|
|| msgSubType == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||||
|
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||||
|
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||||
|
boolean channelEvent = msgSubType == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||||
|
|| msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF);
|
||||||
|
if (personalConnection || channelEvent) {
|
||||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||||
entry.setNotificationKind("connection");
|
entry.setNotificationKind(channelEvent ? "event" : "connection");
|
||||||
entry.setSourceText("");
|
entry.setSourceText("");
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-115
@@ -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 (channelReadStateDAO.deleteForUnfollowTarget(
|
||||||
|
c, be.getLogin(), be.getToBchName(), be.getToBlockNumber())) {
|
||||||
|
counterChangedLogins.add(be.getLogin());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channelMetaUpdateEntry != null) {
|
|
||||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 — атомарно подменяем основной файл.
|
private static boolean isPublicChannelPublication(int msgType, int msgSubType) {
|
||||||
try {
|
if (msgType != 1) return false;
|
||||||
fs.atomicReplaceBlockchainFile(blockchainName);
|
return msgSubType == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
} catch (RuntimeException e) {
|
|| msgSubType == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
// marker/check/tmp оставляем для startup-recovery
|
|| msgSubType == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
throw e;
|
|| msgSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
}
|
|| msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|
|| msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||||
// 4) После успешной подмены — чистим временные артефакты.
|
|
||||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
|
||||||
byte[] base;
|
|
||||||
try {
|
|
||||||
if (Files.exists(fs.resolveBlockchainPath(blockchainName))) {
|
|
||||||
base = fs.readBlockchain(blockchainName);
|
|
||||||
} else {
|
|
||||||
base = new byte[0];
|
|
||||||
}
|
|
||||||
} 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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
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.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; }
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
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 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 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-33
@@ -144,38 +144,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 {
|
||||||
@@ -262,12 +236,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 +253,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()) {
|
||||||
|
|||||||
+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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
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 rs.to_bch_name = ?
|
||||||
|
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, 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", "Внутренняя ошибка сервера");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-6
@@ -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_bch_name"),
|
||||||
|
(Integer) rs.getObject("to_block_number"), rs.getBytes("to_block_hash"));
|
||||||
out.add(item);
|
out.add(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,7 +217,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
|||||||
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 bch_name = ? AND block_number = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|||||||
+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", "Не удалось получить счётчики");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-2
@@ -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) {
|
||||||
|
|||||||
+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; }
|
||||||
|
}
|
||||||
+11
@@ -46,6 +46,7 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
|
|||||||
item.setFirstName(card.firstName());
|
item.setFirstName(card.firstName());
|
||||||
item.setLastName(card.lastName());
|
item.setLastName(card.lastName());
|
||||||
item.setAvatarAr(card.avatarAr());
|
item.setAvatarAr(card.avatarAr());
|
||||||
|
item.setAvatar(parseAvatar(card.avatarAr()));
|
||||||
item.setAccountRole(card.accountRole());
|
item.setAccountRole(card.accountRole());
|
||||||
item.setShineStatus(card.shineStatus());
|
item.setShineStatus(card.shineStatus());
|
||||||
item.setLastMessageBlobB64(dialog.lastMessageBlobB64());
|
item.setLastMessageBlobB64(dialog.lastMessageBlobB64());
|
||||||
@@ -56,4 +57,14 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
private static Net_ListContacts_Response.Avatar parseAvatar(String value) {
|
||||||
|
Net_ListContacts_Response.Avatar out = new Net_ListContacts_Response.Avatar();
|
||||||
|
String raw = value == null ? "" : value.trim();
|
||||||
|
java.util.regex.Matcher ar = java.util.regex.Pattern.compile("AR:([A-Za-z0-9_-]{43})").matcher(raw);
|
||||||
|
if (ar.find()) out.setAr(ar.group(1));
|
||||||
|
java.util.regex.Matcher sha = java.util.regex.Pattern.compile("SHA256:([A-Fa-f0-9]{64})").matcher(raw);
|
||||||
|
if (sha.find()) out.setSha256Hex(sha.group(1).toLowerCase());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -20,6 +20,7 @@ public class Net_ListContacts_Response extends Net_Response {
|
|||||||
private String firstName;
|
private String firstName;
|
||||||
private String lastName;
|
private String lastName;
|
||||||
private String avatarAr;
|
private String avatarAr;
|
||||||
|
private Avatar avatar;
|
||||||
private String accountRole;
|
private String accountRole;
|
||||||
private String shineStatus;
|
private String shineStatus;
|
||||||
private String lastMessageBlobB64;
|
private String lastMessageBlobB64;
|
||||||
@@ -37,6 +38,8 @@ public class Net_ListContacts_Response extends Net_Response {
|
|||||||
public void setLastName(String lastName) { this.lastName = lastName; }
|
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||||
public String getAvatarAr() { return avatarAr; }
|
public String getAvatarAr() { return avatarAr; }
|
||||||
public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; }
|
public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; }
|
||||||
|
public Avatar getAvatar() { return avatar; }
|
||||||
|
public void setAvatar(Avatar avatar) { this.avatar = avatar; }
|
||||||
public String getAccountRole() { return accountRole; }
|
public String getAccountRole() { return accountRole; }
|
||||||
public void setAccountRole(String accountRole) { this.accountRole = accountRole; }
|
public void setAccountRole(String accountRole) { this.accountRole = accountRole; }
|
||||||
public String getShineStatus() { return shineStatus; }
|
public String getShineStatus() { return shineStatus; }
|
||||||
@@ -50,4 +53,13 @@ public class Net_ListContacts_Response extends Net_Response {
|
|||||||
public boolean isHasDialog() { return hasDialog; }
|
public boolean isHasDialog() { return hasDialog; }
|
||||||
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
|
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class Avatar {
|
||||||
|
private String ar;
|
||||||
|
private String sha256Hex;
|
||||||
|
public String getAr() { return ar; }
|
||||||
|
public void setAr(String ar) { this.ar = ar; }
|
||||||
|
public String getSha256Hex() { return sha256Hex; }
|
||||||
|
public void setSha256Hex(String sha256Hex) { this.sha256Hex = sha256Hex; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationState_Response;
|
||||||
|
import shine.db.KeyEncodingUtil;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
final class KeyRotationApiSupport {
|
||||||
|
private KeyRotationApiSupport() {}
|
||||||
|
|
||||||
|
static String normalizePublicKey32(String raw) {
|
||||||
|
String normalized = KeyEncodingUtil.normalizeKeyToBase64_32(raw);
|
||||||
|
if (normalized == null || normalized.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("public key is empty");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] decoded = Base64.getDecoder().decode(normalized);
|
||||||
|
if (decoded.length != 32) throw new IllegalArgumentException("public key must be 32 bytes");
|
||||||
|
return Base64.getEncoder().encodeToString(decoded);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new IllegalArgumentException("public key must be Base58/Base64 of 32 bytes", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] parseHash32(String hex) {
|
||||||
|
if (hex == null) throw new IllegalArgumentException("hash is null");
|
||||||
|
String s = hex.trim();
|
||||||
|
if (s.length() != 64) throw new IllegalArgumentException("hash must contain 64 hex chars");
|
||||||
|
byte[] out = new byte[32];
|
||||||
|
for (int i = 0; i < 32; i++) {
|
||||||
|
int hi = Character.digit(s.charAt(i * 2), 16);
|
||||||
|
int lo = Character.digit(s.charAt(i * 2 + 1), 16);
|
||||||
|
if (hi < 0 || lo < 0) throw new IllegalArgumentException("hash contains non-hex chars");
|
||||||
|
out[i] = (byte) ((hi << 4) | lo);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String toHex(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();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String nextBlockchainName(String login, String source) {
|
||||||
|
if (login == null || login.isBlank() || source == null || source.length() < 5) {
|
||||||
|
throw new IllegalArgumentException("bad source blockchain name");
|
||||||
|
}
|
||||||
|
String prefix = login + "-";
|
||||||
|
if (!source.startsWith(prefix) || source.length() != prefix.length() + 3) {
|
||||||
|
throw new IllegalArgumentException("source blockchain name does not match login");
|
||||||
|
}
|
||||||
|
String suffix = source.substring(prefix.length());
|
||||||
|
if (!suffix.chars().allMatch(Character::isDigit)) {
|
||||||
|
throw new IllegalArgumentException("bad blockchain suffix");
|
||||||
|
}
|
||||||
|
int n = Integer.parseInt(suffix);
|
||||||
|
if (n >= 999) throw new IllegalArgumentException("blockchain fork limit reached");
|
||||||
|
return login + "-" + String.format("%03d", n + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void requireKeysChangedAndDistinct(String oldRoot, String oldBlockchain, String oldClient,
|
||||||
|
String newRoot, String newBlockchain, String newClient) {
|
||||||
|
java.util.Set<String> oldKeys = java.util.Set.of(oldRoot, oldBlockchain, oldClient);
|
||||||
|
java.util.Set<String> newKeys = java.util.Set.of(newRoot, newBlockchain, newClient);
|
||||||
|
if (newKeys.size() != 3) {
|
||||||
|
throw new IllegalArgumentException("new root/blockchain/client keys must be distinct");
|
||||||
|
}
|
||||||
|
for (String newKey : newKeys) {
|
||||||
|
if (oldKeys.contains(newKey)) {
|
||||||
|
throw new IllegalArgumentException("new keys must not reuse any key from the old key set");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean hashesEqual(byte[] a, byte[] b) {
|
||||||
|
return Arrays.equals(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Net_KeyRotationState_Response response(String op, String requestId, KeyRotationSessionEntry e) {
|
||||||
|
Net_KeyRotationState_Response r = new Net_KeyRotationState_Response();
|
||||||
|
r.setOp(op);
|
||||||
|
r.setRequestId(requestId);
|
||||||
|
r.setStatus(200);
|
||||||
|
if (e == null) {
|
||||||
|
r.setRotationStatus(KeyRotationStatus.NONE.name());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
r.setRotationSessionId(e.getId());
|
||||||
|
r.setRotationStatus(e.getStatus().name());
|
||||||
|
r.setSourceBlockchainName(e.getSourceBlockchainName());
|
||||||
|
r.setCandidateBlockchainName(e.getCandidateBlockchainName());
|
||||||
|
r.setOldRootKey(e.getOldRootKey());
|
||||||
|
r.setOldBlockchainKey(e.getOldBlockchainKey());
|
||||||
|
r.setOldClientKey(e.getOldClientKey());
|
||||||
|
r.setNewRootKey(e.getNewRootKey());
|
||||||
|
r.setNewBlockchainKey(e.getNewBlockchainKey());
|
||||||
|
r.setNewClientKey(e.getNewClientKey());
|
||||||
|
r.setForkFromBlock(e.getForkFromBlock());
|
||||||
|
r.setForkFromHash(toHex(e.getForkFromHash()));
|
||||||
|
r.setSourceTipBlock(e.getSourceTipBlock());
|
||||||
|
r.setSourceTipHash(toHex(e.getSourceTipHash()));
|
||||||
|
r.setReasonCode(e.getReasonCode());
|
||||||
|
r.setComment(e.getComment());
|
||||||
|
r.setProgressCurrent(e.getProgressCurrent());
|
||||||
|
r.setProgressTotal(e.getProgressTotal());
|
||||||
|
r.setPdaRotationSignature(e.getPdaRotationSignature());
|
||||||
|
r.setWalletMigrationStatus(e.getWalletMigrationStatus());
|
||||||
|
r.setMessageMigrationStatus(e.getMessageMigrationStatus());
|
||||||
|
r.setLastError(e.getLastError());
|
||||||
|
r.setRetryCount(e.getRetryCount());
|
||||||
|
r.setCreatedAtMs(e.getCreatedAtMs());
|
||||||
|
r.setUpdatedAtMs(e.getUpdatedAtMs());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
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.keyRotation.entyties.Net_KeyRotationAbort_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
/** Прерывание разрешено только пока Solana-транзакция ротации ещё не могла быть отправлена. */
|
||||||
|
public final class Net_KeyRotationAbort_Handler implements JsonMessageHandler {
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationAbort_Request req = (Net_KeyRotationAbort_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
|
||||||
|
if (current == null) return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), null);
|
||||||
|
if (current.getStatus() != KeyRotationStatus.COPYING_CHAIN && current.getStatus() != KeyRotationStatus.CHAIN_READY) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_ABORT_TOO_LATE",
|
||||||
|
"После начала ротации PDA процесс можно только завершить");
|
||||||
|
}
|
||||||
|
KeyRotationSessionEntry aborted = rotations.transition(current.getId(), current.getStatus(), KeyRotationStatus.ABORTED);
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), aborted);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ABORT_FAILED", "Не удалось прервать смену ключей");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
import blockchain.BchBlockEntry;
|
||||||
|
import blockchain.BchCryptoVerifier;
|
||||||
|
import blockchain.MsgSubType;
|
||||||
|
import blockchain.body.ForkBody;
|
||||||
|
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.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationCandidateBlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Принимает DataItem будущего fork во время COPYING_CHAIN.
|
||||||
|
* Candidate-блок сохраняется отдельно от blocks и не влияет на materialized state.
|
||||||
|
*/
|
||||||
|
public final class Net_KeyRotationAddBlock_Handler implements JsonMessageHandler {
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
|
||||||
|
private final BlocksDAO blocks = BlocksDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationAddBlock_Request req = (Net_KeyRotationAddBlock_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
if (req.getBlockNumber() < 0 || req.getBlockBytesB64() == null || req.getBlockBytesB64().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "blockNumber и blockBytesB64 обязательны");
|
||||||
|
}
|
||||||
|
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
KeyRotationSessionEntry initial;
|
||||||
|
try {
|
||||||
|
initial = rotations.getActiveByLogin(login);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_DB_ERROR", "Не удалось прочитать состояние смены ключей");
|
||||||
|
}
|
||||||
|
if (initial == null || initial.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация не находится на этапе COPYING_CHAIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
ReentrantLock lock = BlockchainLocks.lockFor(initial.getCandidateBlockchainName());
|
||||||
|
lock.lock();
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
KeyRotationSessionEntry session = rotations.getActiveByLogin(c, login);
|
||||||
|
if (session == null || session.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация больше не находится на этапе COPYING_CHAIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
int stored = candidates.countStored(c, session.getId());
|
||||||
|
if (req.getBlockNumber() < stored) {
|
||||||
|
KeyRotationCandidateBlockEntry existing = candidates.getByNumber(c, session.getId(), req.getBlockNumber());
|
||||||
|
if (existing == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CANDIDATE_GAP", "Нарушена последовательность candidate-блоков");
|
||||||
|
}
|
||||||
|
BchBlockEntry repeated;
|
||||||
|
try { repeated = new BchBlockEntry(Base64Ws.decode(req.getBlockBytesB64())); }
|
||||||
|
catch (Exception e) { return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "Не удалось распарсить DataItem"); }
|
||||||
|
if (!Arrays.equals(existing.getBlockHash(), repeated.getHash32())
|
||||||
|
|| !Arrays.equals(existing.getDataItemId(), repeated.getDataItemId32())) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_CONFLICT", "На этом номере уже сохранён другой candidate-блок");
|
||||||
|
}
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), session);
|
||||||
|
}
|
||||||
|
if (req.getBlockNumber() != stored) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_OUT_OF_ORDER", "Candidate-блоки нужно добавлять последовательно с block 0");
|
||||||
|
}
|
||||||
|
if (stored >= session.getProgressTotal()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_ALREADY_COMPLETE", "Все candidate-блоки уже приняты сервером");
|
||||||
|
}
|
||||||
|
|
||||||
|
final byte[] raw;
|
||||||
|
final BchBlockEntry block;
|
||||||
|
try {
|
||||||
|
raw = Base64Ws.decode(req.getBlockBytesB64());
|
||||||
|
block = new BchBlockEntry(raw);
|
||||||
|
block.body.check();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "Некорректный SHiNE/ANS-104 блок");
|
||||||
|
}
|
||||||
|
if (block.blockNumber != req.getBlockNumber()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BLOCK_NUMBER_MISMATCH", "blockNumber запроса не совпадает с блоком");
|
||||||
|
}
|
||||||
|
if (!block.getDataItem().hasTag("App", "test5590")) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_APP_TAG", "Отсутствует App=test5590");
|
||||||
|
}
|
||||||
|
if (!requestPrevHashMatches(req.getPrevBlockHash(), block.prevHash32)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_PREV_HASH_MISMATCH", "prevBlockHash запроса не совпадает с блоком");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] newBlockchainKey;
|
||||||
|
try {
|
||||||
|
newBlockchainKey = Base64.getDecoder().decode(session.getNewBlockchainKey());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_BAD_STORED_KEY", "Некорректный new blockchain public key в rotation session");
|
||||||
|
}
|
||||||
|
if (newBlockchainKey.length != 32 || !BchCryptoVerifier.verifyBlock(block, newBlockchainKey)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_SIGNATURE", "Candidate DataItem должен быть подписан новым blockchain key");
|
||||||
|
}
|
||||||
|
|
||||||
|
String validationError;
|
||||||
|
try {
|
||||||
|
validationError = validateCandidate(c, session, block);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_VALIDATE_FAILED", "Не удалось проверить candidate-блок");
|
||||||
|
}
|
||||||
|
if (validationError != null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, validationError, "Candidate-блок не соответствует выбранному fork");
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyRotationCandidateBlockEntry entry = new KeyRotationCandidateBlockEntry();
|
||||||
|
entry.setRotationSessionId(session.getId());
|
||||||
|
entry.setLogin(login);
|
||||||
|
entry.setCandidateBlockchainName(session.getCandidateBlockchainName());
|
||||||
|
entry.setBlockNumber(block.blockNumber);
|
||||||
|
entry.setBlockHash(block.getHash32());
|
||||||
|
entry.setDataItemId(block.getDataItemId32());
|
||||||
|
entry.setBlockBytes(raw);
|
||||||
|
entry.setCreatedAtMs(System.currentTimeMillis());
|
||||||
|
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
candidates.insertOrGet(c, entry);
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_CONFLICT", "Не удалось сохранить candidate-блок");
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// progressCurrent обновляет Arweave publisher только после реальной публикации.
|
||||||
|
KeyRotationSessionEntry refreshed = rotations.getActiveByLogin(login);
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), refreshed == null ? session : refreshed);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ADD_BLOCK_FAILED", "Не удалось принять candidate-блок");
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateCandidate(Connection c, KeyRotationSessionEntry session, BchBlockEntry candidate) throws Exception {
|
||||||
|
if (candidate.blockNumber <= session.getForkFromBlock()) {
|
||||||
|
BlockEntry sourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), candidate.blockNumber);
|
||||||
|
if (sourceEntry == null || sourceEntry.getBlockBytes() == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
|
||||||
|
BchBlockEntry source = new BchBlockEntry(sourceEntry.getBlockBytes());
|
||||||
|
|
||||||
|
// Перепубликуется тот же Frame и те же ANS-104 tags/target/anchor; меняются только owner/signature/DataItem id.
|
||||||
|
if (!Arrays.equals(source.getFrameBytes(), candidate.getFrameBytes())) return "KEY_ROTATION_FRAME_MISMATCH";
|
||||||
|
if (!Arrays.equals(source.getDataItem().rawTags(), candidate.getDataItem().rawTags())) return "KEY_ROTATION_TAGS_MISMATCH";
|
||||||
|
if (!Arrays.equals(source.getDataItem().target(), candidate.getDataItem().target())) return "KEY_ROTATION_TARGET_MISMATCH";
|
||||||
|
if (!Arrays.equals(source.getDataItem().anchor(), candidate.getDataItem().anchor())) return "KEY_ROTATION_ANCHOR_MISMATCH";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int techForkNumber = session.getForkFromBlock() + 1;
|
||||||
|
if (candidate.blockNumber != techForkNumber) return "KEY_ROTATION_UNEXPECTED_BLOCK_NUMBER";
|
||||||
|
if ((candidate.type & 0xFFFF) != 0
|
||||||
|
|| (candidate.subType & 0xFFFF) != (MsgSubType.TECH_FORK & 0xFFFF)
|
||||||
|
|| !(candidate.body instanceof ForkBody fork)) {
|
||||||
|
return "KEY_ROTATION_TECH_FORK_REQUIRED";
|
||||||
|
}
|
||||||
|
if (!Arrays.equals(candidate.prevHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_PREV_HASH";
|
||||||
|
|
||||||
|
BlockEntry forkSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getForkFromBlock());
|
||||||
|
BlockEntry tipSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getSourceTipBlock());
|
||||||
|
if (forkSourceEntry == null || tipSourceEntry == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
|
||||||
|
BchBlockEntry forkSource = new BchBlockEntry(forkSourceEntry.getBlockBytes());
|
||||||
|
BchBlockEntry tipSource = new BchBlockEntry(tipSourceEntry.getBlockBytes());
|
||||||
|
|
||||||
|
byte[] oldBlockchain = Base64.getDecoder().decode(session.getOldBlockchainKey());
|
||||||
|
if (!Arrays.equals(fork.parentBlockchainKey32, oldBlockchain)) return "KEY_ROTATION_TECH_FORK_PARENT_KEY";
|
||||||
|
if (fork.forkPointBlockNumber != session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_POINT";
|
||||||
|
if (!Arrays.equals(fork.forkPointBlockHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_POINT_HASH";
|
||||||
|
if (fork.forkPointTimestampMs != Math.multiplyExact(forkSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_POINT_TIME";
|
||||||
|
if (fork.parentTipBlockNumber != session.getSourceTipBlock()) return "KEY_ROTATION_TECH_FORK_TIP";
|
||||||
|
if (!Arrays.equals(fork.parentTipBlockHash32, session.getSourceTipHash())) return "KEY_ROTATION_TECH_FORK_TIP_HASH";
|
||||||
|
if (fork.parentTipTimestampMs != Math.multiplyExact(tipSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_TIP_TIME";
|
||||||
|
if (fork.discardedBlocksCount != session.getSourceTipBlock() - session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_DISCARDED";
|
||||||
|
if (fork.reasonCode != session.getReasonCode()) return "KEY_ROTATION_TECH_FORK_REASON";
|
||||||
|
if (!normalizeComment(fork.comment).equals(normalizeComment(session.getComment()))) return "KEY_ROTATION_TECH_FORK_COMMENT";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean requestPrevHashMatches(String raw, byte[] actual) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return isZero32(actual);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Arrays.equals(KeyRotationApiSupport.parseHash32(raw), actual);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isZero32(byte[] value) {
|
||||||
|
if (value == null || value.length != 32) return false;
|
||||||
|
for (byte b : value) if (b != 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeComment(String value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
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.keyRotation.entyties.Net_KeyRotationContinue_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сейчас wallet/DM migration — явные заглушки. Continue фиксирует NOT_IMPLEMENTED
|
||||||
|
* и переводит state machine дальше, не делая вид, что данные были реально мигрированы.
|
||||||
|
*/
|
||||||
|
public final class Net_KeyRotationContinue_Handler implements JsonMessageHandler {
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationContinue_Request req = (Net_KeyRotationContinue_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
|
||||||
|
if (current == null) return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), null);
|
||||||
|
|
||||||
|
if (current.getStatus() == KeyRotationStatus.WALLET_MIGRATION) {
|
||||||
|
rotations.setMigrationSubStatus(current.getId(), "wallet_migration_status", "NOT_IMPLEMENTED");
|
||||||
|
current = rotations.transition(current.getId(), KeyRotationStatus.WALLET_MIGRATION, KeyRotationStatus.MESSAGE_MIGRATION);
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
|
||||||
|
}
|
||||||
|
if (current.getStatus() == KeyRotationStatus.MESSAGE_MIGRATION) {
|
||||||
|
rotations.setMigrationSubStatus(current.getId(), "message_migration_status", "NOT_IMPLEMENTED");
|
||||||
|
current = rotations.transition(current.getId(), KeyRotationStatus.MESSAGE_MIGRATION, KeyRotationStatus.FINALIZING);
|
||||||
|
// FINALIZING пока не содержит отдельной пользовательской работы: сразу завершаем.
|
||||||
|
current = rotations.transition(current.getId(), KeyRotationStatus.FINALIZING, KeyRotationStatus.COMPLETE);
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
|
||||||
|
}
|
||||||
|
if (current.getStatus() == KeyRotationStatus.FINALIZING) {
|
||||||
|
current = rotations.transition(current.getId(), KeyRotationStatus.FINALIZING, KeyRotationStatus.COMPLETE);
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
|
||||||
|
}
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CONTINUE_NOT_ALLOWED",
|
||||||
|
"На текущем этапе продолжение этой операцией не требуется");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_CONTINUE_FAILED",
|
||||||
|
"Не удалось продолжить смену ключей");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
import blockchain.BchBlockEntry;
|
||||||
|
import blockchain.BchCryptoVerifier;
|
||||||
|
import blockchain.MsgSubType;
|
||||||
|
import blockchain.body.ForkBody;
|
||||||
|
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.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationCandidateBlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, что candidate-chain полностью сохранена и опубликована в Arweave/Turbo,
|
||||||
|
* затем переводит ротацию COPYING_CHAIN -> CHAIN_READY.
|
||||||
|
*/
|
||||||
|
public final class Net_KeyRotationFinishChain_Handler implements JsonMessageHandler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_KeyRotationFinishChain_Handler.class);
|
||||||
|
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
|
||||||
|
private final BlocksDAO blocks = BlocksDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationFinishChain_Request req = (Net_KeyRotationFinishChain_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
|
||||||
|
KeyRotationSessionEntry initial;
|
||||||
|
try {
|
||||||
|
initial = rotations.getActiveByLogin(login);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_DB_ERROR", "Не удалось прочитать состояние смены ключей");
|
||||||
|
}
|
||||||
|
if (initial == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
|
||||||
|
}
|
||||||
|
if (initial.getStatus() == KeyRotationStatus.CHAIN_READY) {
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), initial);
|
||||||
|
}
|
||||||
|
if (initial.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация не находится на этапе COPYING_CHAIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
ReentrantLock lock = BlockchainLocks.lockFor(initial.getCandidateBlockchainName());
|
||||||
|
lock.lock();
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
KeyRotationSessionEntry session = rotations.getActiveByLogin(c, login);
|
||||||
|
if (session == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
|
||||||
|
}
|
||||||
|
if (session.getStatus() == KeyRotationStatus.CHAIN_READY) {
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), session);
|
||||||
|
}
|
||||||
|
if (session.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация больше не находится на этапе COPYING_CHAIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
int expected = session.getProgressTotal();
|
||||||
|
int stored = candidates.countStored(c, session.getId());
|
||||||
|
int published = candidates.countPublished(c, session.getId());
|
||||||
|
if (stored != expected) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_INCOMPLETE",
|
||||||
|
"Candidate-chain ещё не полностью загружена: " + stored + "/" + expected);
|
||||||
|
}
|
||||||
|
if (published != expected) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_NOT_PUBLISHED",
|
||||||
|
"Candidate-chain ещё не полностью опубликована в Arweave/Turbo: " + published + "/" + expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<KeyRotationCandidateBlockEntry> entries = candidates.listBySession(c, session.getId());
|
||||||
|
String validationError = validateCompleteChain(c, session, entries);
|
||||||
|
if (validationError != null) {
|
||||||
|
try { rotations.recordError(session.getId(), validationError); } catch (Exception ignored) { }
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, validationError, "Финальная проверка candidate-chain не пройдена");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_FINISH_CHAIN_FAILED", "Не удалось завершить проверку candidate-chain");
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry ready = rotations.transition(
|
||||||
|
initial.getId(), KeyRotationStatus.COPYING_CHAIN, KeyRotationStatus.CHAIN_READY);
|
||||||
|
rotations.clearError(initial.getId());
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), ready);
|
||||||
|
} catch (Exception transitionFailure) {
|
||||||
|
log.warn("KeyRotationFinishChain transition failed: login={}, sessionId={}",
|
||||||
|
login, initial.getId(), transitionFailure);
|
||||||
|
// Идемпотентность для параллельного FinishChain из другой сессии.
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry now = rotations.getActiveByLogin(login);
|
||||||
|
if (now != null && now.getStatus() == KeyRotationStatus.CHAIN_READY) {
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), now);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) { }
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_FINISH_CHAIN_RACE", "Состояние ротации изменилось во время FinishChain");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateCompleteChain(Connection c,
|
||||||
|
KeyRotationSessionEntry session,
|
||||||
|
List<KeyRotationCandidateBlockEntry> entries) throws Exception {
|
||||||
|
if (entries == null || entries.size() != session.getProgressTotal()) return "KEY_ROTATION_CHAIN_INCOMPLETE";
|
||||||
|
if (session.getProgressTotal() != session.getForkFromBlock() + 2) return "KEY_ROTATION_BAD_PROGRESS_TOTAL";
|
||||||
|
|
||||||
|
byte[] newBlockchainKey = Base64.getDecoder().decode(session.getNewBlockchainKey());
|
||||||
|
if (newBlockchainKey.length != 32) return "KEY_ROTATION_BAD_STORED_KEY";
|
||||||
|
|
||||||
|
BchBlockEntry previous = null;
|
||||||
|
for (int i = 0; i < entries.size(); i++) {
|
||||||
|
KeyRotationCandidateBlockEntry stored = entries.get(i);
|
||||||
|
if (stored.getBlockNumber() != i) return "KEY_ROTATION_CANDIDATE_GAP";
|
||||||
|
if (stored.isArweavePublishPending() || stored.getArweavePublishedAtMs() == null) {
|
||||||
|
return "KEY_ROTATION_CHAIN_NOT_PUBLISHED";
|
||||||
|
}
|
||||||
|
|
||||||
|
BchBlockEntry candidate;
|
||||||
|
try {
|
||||||
|
candidate = new BchBlockEntry(stored.getBlockBytes());
|
||||||
|
candidate.body.check();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "KEY_ROTATION_BAD_STORED_BLOCK";
|
||||||
|
}
|
||||||
|
if (candidate.blockNumber != i) return "KEY_ROTATION_BLOCK_NUMBER_MISMATCH";
|
||||||
|
if (!Arrays.equals(candidate.getHash32(), stored.getBlockHash())) return "KEY_ROTATION_STORED_HASH_MISMATCH";
|
||||||
|
if (!Arrays.equals(candidate.getDataItemId32(), stored.getDataItemId())) return "KEY_ROTATION_STORED_DATAITEM_MISMATCH";
|
||||||
|
if (!candidate.getDataItem().hasTag("App", "test5590")) return "KEY_ROTATION_BAD_APP_TAG";
|
||||||
|
if (!BchCryptoVerifier.verifyBlock(candidate, newBlockchainKey)) return "KEY_ROTATION_BAD_SIGNATURE";
|
||||||
|
|
||||||
|
if (i == 0) {
|
||||||
|
if (!isZero32(candidate.prevHash32)) return "KEY_ROTATION_GENESIS_PREV_HASH";
|
||||||
|
} else {
|
||||||
|
if (previous == null || !Arrays.equals(candidate.prevHash32, previous.getHash32())) {
|
||||||
|
return "KEY_ROTATION_CHAIN_HASH_MISMATCH";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String semanticError = validateAgainstRotation(c, session, candidate);
|
||||||
|
if (semanticError != null) return semanticError;
|
||||||
|
previous = candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateAgainstRotation(Connection c, KeyRotationSessionEntry session, BchBlockEntry candidate) throws Exception {
|
||||||
|
if (candidate.blockNumber <= session.getForkFromBlock()) {
|
||||||
|
BlockEntry sourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), candidate.blockNumber);
|
||||||
|
if (sourceEntry == null || sourceEntry.getBlockBytes() == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
|
||||||
|
BchBlockEntry source = new BchBlockEntry(sourceEntry.getBlockBytes());
|
||||||
|
if (!Arrays.equals(source.getFrameBytes(), candidate.getFrameBytes())) return "KEY_ROTATION_FRAME_MISMATCH";
|
||||||
|
if (!Arrays.equals(source.getDataItem().rawTags(), candidate.getDataItem().rawTags())) return "KEY_ROTATION_TAGS_MISMATCH";
|
||||||
|
if (!Arrays.equals(source.getDataItem().target(), candidate.getDataItem().target())) return "KEY_ROTATION_TARGET_MISMATCH";
|
||||||
|
if (!Arrays.equals(source.getDataItem().anchor(), candidate.getDataItem().anchor())) return "KEY_ROTATION_ANCHOR_MISMATCH";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.blockNumber != session.getForkFromBlock() + 1) return "KEY_ROTATION_UNEXPECTED_BLOCK_NUMBER";
|
||||||
|
if ((candidate.type & 0xFFFF) != 0
|
||||||
|
|| (candidate.subType & 0xFFFF) != (MsgSubType.TECH_FORK & 0xFFFF)
|
||||||
|
|| !(candidate.body instanceof ForkBody fork)) {
|
||||||
|
return "KEY_ROTATION_TECH_FORK_REQUIRED";
|
||||||
|
}
|
||||||
|
if (!Arrays.equals(candidate.prevHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_PREV_HASH";
|
||||||
|
|
||||||
|
BlockEntry forkSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getForkFromBlock());
|
||||||
|
BlockEntry tipSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getSourceTipBlock());
|
||||||
|
if (forkSourceEntry == null || tipSourceEntry == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
|
||||||
|
BchBlockEntry forkSource = new BchBlockEntry(forkSourceEntry.getBlockBytes());
|
||||||
|
BchBlockEntry tipSource = new BchBlockEntry(tipSourceEntry.getBlockBytes());
|
||||||
|
|
||||||
|
byte[] oldBlockchain = Base64.getDecoder().decode(session.getOldBlockchainKey());
|
||||||
|
if (!Arrays.equals(fork.parentBlockchainKey32, oldBlockchain)) return "KEY_ROTATION_TECH_FORK_PARENT_KEY";
|
||||||
|
if (fork.forkPointBlockNumber != session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_POINT";
|
||||||
|
if (!Arrays.equals(fork.forkPointBlockHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_POINT_HASH";
|
||||||
|
if (fork.forkPointTimestampMs != Math.multiplyExact(forkSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_POINT_TIME";
|
||||||
|
if (fork.parentTipBlockNumber != session.getSourceTipBlock()) return "KEY_ROTATION_TECH_FORK_TIP";
|
||||||
|
if (!Arrays.equals(fork.parentTipBlockHash32, session.getSourceTipHash())) return "KEY_ROTATION_TECH_FORK_TIP_HASH";
|
||||||
|
if (fork.parentTipTimestampMs != Math.multiplyExact(tipSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_TIP_TIME";
|
||||||
|
if (fork.discardedBlocksCount != session.getSourceTipBlock() - session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_DISCARDED";
|
||||||
|
if (fork.reasonCode != session.getReasonCode()) return "KEY_ROTATION_TECH_FORK_REASON";
|
||||||
|
if (!normalizeComment(fork.comment).equals(normalizeComment(session.getComment()))) return "KEY_ROTATION_TECH_FORK_COMMENT";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isZero32(byte[] value) {
|
||||||
|
if (value == null || value.length != 32) return false;
|
||||||
|
for (byte b : value) if (b != 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeComment(String value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
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.keyRotation.entyties.Net_KeyRotationRotatePda_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фиксирует уже отправленную клиентом Solana-транзакцию ротации PDA.
|
||||||
|
* Приватные ключи и подписанная транзакция через сервер не проходят: клиент передаёт только tx signature.
|
||||||
|
*/
|
||||||
|
public final class Net_KeyRotationRotatePda_Handler implements JsonMessageHandler {
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationRotatePda_Request req = (Net_KeyRotationRotatePda_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
|
||||||
|
final String signature;
|
||||||
|
try {
|
||||||
|
signature = normalizeSolanaSignature(req.getPdaRotationSignature());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_SOLANA_SIGNATURE", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = rotations.getActiveByLogin(login);
|
||||||
|
if (current == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.getStatus() == KeyRotationStatus.PDA_ROTATED) {
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.getStatus() == KeyRotationStatus.ROTATING_PDA) {
|
||||||
|
String stored = current.getPdaRotationSignature();
|
||||||
|
if (stored != null && !stored.isBlank() && !stored.equals(signature)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_PDA_SIGNATURE_CONFLICT",
|
||||||
|
"Для этой ротации уже сохранена другая Solana transaction signature");
|
||||||
|
}
|
||||||
|
if (stored == null || stored.isBlank()) {
|
||||||
|
rotations.setPdaRotationSignature(current.getId(), signature);
|
||||||
|
}
|
||||||
|
} else if (current.getStatus() == KeyRotationStatus.CHAIN_READY) {
|
||||||
|
current = rotations.beginPdaRotation(current.getId(), signature);
|
||||||
|
} else {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_CHAIN_READY",
|
||||||
|
"PDA можно ротировать только после CHAIN_READY");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solana sync мог увидеть новое PDA ещё до этого API-вызова.
|
||||||
|
KeyRotationSessionEntry confirmed = rotations.tryMarkPdaRotatedFromCurrentState(current.getId());
|
||||||
|
rotations.clearError(current.getId());
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), confirmed != null ? confirmed : rotations.getById(current.getId()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ROTATE_PDA_FAILED",
|
||||||
|
"Не удалось зафиксировать Solana-ротацию PDA");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeSolanaSignature(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) throw new IllegalArgumentException("pdaRotationSignature обязательна");
|
||||||
|
String s = raw.trim();
|
||||||
|
if (s.length() > 128) throw new IllegalArgumentException("Слишком длинная Solana transaction signature");
|
||||||
|
final String alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
if (alphabet.indexOf(s.charAt(i)) < 0) {
|
||||||
|
throw new IllegalArgumentException("Solana transaction signature должна быть Base58");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
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.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт серверную ротацию сразу в COPYING_CHAIN.
|
||||||
|
* До этого момента всё заполнение формы остаётся только локальным UI-состоянием.
|
||||||
|
*/
|
||||||
|
public final class Net_KeyRotationStart_Handler implements JsonMessageHandler {
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
|
||||||
|
private final BlocksDAO blocks = BlocksDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationStart_Request req = (Net_KeyRotationStart_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
String ctxBlockchainName = ctx.getCurrentUser() != null ? ctx.getCurrentUser().getBlockchainName() : null;
|
||||||
|
if (ctxBlockchainName == null || ctxBlockchainName.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_SESSION_STALE",
|
||||||
|
"В сессии отсутствует актуальный blockchainName; войдите заново");
|
||||||
|
}
|
||||||
|
|
||||||
|
final String newRoot;
|
||||||
|
final String newBlockchain;
|
||||||
|
final String newClient;
|
||||||
|
final byte[] requestedForkHash;
|
||||||
|
try {
|
||||||
|
newRoot = KeyRotationApiSupport.normalizePublicKey32(req.getNewRootKey());
|
||||||
|
newBlockchain = KeyRotationApiSupport.normalizePublicKey32(req.getNewBlockchainKey());
|
||||||
|
newClient = KeyRotationApiSupport.normalizePublicKey32(req.getNewClientKey());
|
||||||
|
requestedForkHash = KeyRotationApiSupport.parseHash32(req.getForkFromHash());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FIELDS", e.getMessage());
|
||||||
|
}
|
||||||
|
if (req.getReasonCode() < 1 || req.getReasonCode() > 4) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_REASON", "reasonCode должен быть 1..4");
|
||||||
|
}
|
||||||
|
String comment = req.getComment() == null ? "" : req.getComment();
|
||||||
|
if (comment.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1024) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_COMMENT_TOO_LONG", "Комментарий должен быть не длиннее 1024 UTF-8 байт");
|
||||||
|
}
|
||||||
|
if (req.getForkFromBlock() < 0) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "forkFromBlock должен быть >= 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
ReentrantLock lock = BlockchainLocks.lockFor(ctxBlockchainName);
|
||||||
|
lock.lock();
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
PdaSnapshot pda = loadPdaForUpdate(c, login);
|
||||||
|
if (pda == null) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "KEY_ROTATION_USER_NOT_FOUND", "Пользователь не найден в текущем Solana PDA state");
|
||||||
|
}
|
||||||
|
if (!KeyRotationStatus.NONE.name().equals(pda.rotationStatus)) {
|
||||||
|
KeyRotationSessionEntry active = rotations.getActiveByLogin(c, login);
|
||||||
|
c.rollback();
|
||||||
|
if (active != null
|
||||||
|
&& sameStartRequest(active, newRoot, newBlockchain, newClient,
|
||||||
|
req.getForkFromBlock(), requestedForkHash, req.getReasonCode(), comment)) {
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), active);
|
||||||
|
}
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_ALREADY_ACTIVE", "Смена ключей уже выполняется с другими параметрами");
|
||||||
|
}
|
||||||
|
if (!ctxBlockchainName.equals(pda.blockchainName)) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_SESSION_STALE",
|
||||||
|
"Активный blockchain изменился; обновите сессию и повторите");
|
||||||
|
}
|
||||||
|
|
||||||
|
String oldRoot = KeyRotationApiSupport.normalizePublicKey32(pda.rootKey);
|
||||||
|
String oldBlockchain = KeyRotationApiSupport.normalizePublicKey32(pda.blockchainKey);
|
||||||
|
String oldClient = KeyRotationApiSupport.normalizePublicKey32(pda.clientKey);
|
||||||
|
try {
|
||||||
|
KeyRotationApiSupport.requireKeysChangedAndDistinct(
|
||||||
|
oldRoot, oldBlockchain, oldClient, newRoot, newBlockchain, newClient);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_NEW_KEYS", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockchainStateEntry state = states.getByBlockchainName(c, pda.blockchainName);
|
||||||
|
if (state == null || state.getLastBlockHash() == null || state.getLastBlockHash().length != 32) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_STATE_MISSING", "Не найдено корректное текущее состояние блокчейна");
|
||||||
|
}
|
||||||
|
if (req.getForkFromBlock() > state.getLastBlockNumber()) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "Точка fork находится после текущего tip");
|
||||||
|
}
|
||||||
|
BlockEntry forkBlock = blocks.getByNumber(c, pda.blockchainName, req.getForkFromBlock());
|
||||||
|
if (forkBlock == null || forkBlock.getBlockHash() == null || forkBlock.getBlockHash().length != 32) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "KEY_ROTATION_FORK_BLOCK_NOT_FOUND", "Выбранный блок не найден на сервере");
|
||||||
|
}
|
||||||
|
if (!KeyRotationApiSupport.hashesEqual(forkBlock.getBlockHash(), requestedForkHash)) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_FORK_HASH_MISMATCH", "Хэш выбранного блока не совпадает с сервером");
|
||||||
|
}
|
||||||
|
|
||||||
|
final String candidateName;
|
||||||
|
try {
|
||||||
|
candidateName = KeyRotationApiSupport.nextBlockchainName(login, pda.blockchainName);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCKCHAIN_NAME", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyRotationSessionEntry entry = new KeyRotationSessionEntry();
|
||||||
|
entry.setLogin(login);
|
||||||
|
entry.setSourceBlockchainName(pda.blockchainName);
|
||||||
|
entry.setCandidateBlockchainName(candidateName);
|
||||||
|
entry.setOldRootKey(oldRoot);
|
||||||
|
entry.setOldBlockchainKey(oldBlockchain);
|
||||||
|
entry.setOldClientKey(oldClient);
|
||||||
|
entry.setNewRootKey(newRoot);
|
||||||
|
entry.setNewBlockchainKey(newBlockchain);
|
||||||
|
entry.setNewClientKey(newClient);
|
||||||
|
entry.setForkFromBlock(req.getForkFromBlock());
|
||||||
|
entry.setForkFromHash(requestedForkHash);
|
||||||
|
entry.setSourceTipBlock(state.getLastBlockNumber());
|
||||||
|
entry.setSourceTipHash(state.getLastBlockHash());
|
||||||
|
entry.setReasonCode(req.getReasonCode());
|
||||||
|
entry.setComment(comment);
|
||||||
|
entry.setProgressCurrent(0);
|
||||||
|
entry.setProgressTotal(Math.addExact(req.getForkFromBlock(), 2)); // 0..N + TECH_FORK
|
||||||
|
|
||||||
|
KeyRotationSessionEntry created = rotations.createCopyingSession(c, entry);
|
||||||
|
c.commit();
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), created);
|
||||||
|
} catch (ArithmeticException e) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "Слишком большой номер блока");
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_START_FAILED", "Не удалось запустить смену ключей");
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_START_FAILED", "Не удалось открыть транзакцию смены ключей");
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static boolean sameStartRequest(KeyRotationSessionEntry active,
|
||||||
|
String newRoot, String newBlockchain, String newClient,
|
||||||
|
int forkFromBlock, byte[] forkFromHash, short reasonCode, String comment) {
|
||||||
|
return active != null
|
||||||
|
&& java.util.Objects.equals(active.getNewRootKey(), newRoot)
|
||||||
|
&& java.util.Objects.equals(active.getNewBlockchainKey(), newBlockchain)
|
||||||
|
&& java.util.Objects.equals(active.getNewClientKey(), newClient)
|
||||||
|
&& active.getForkFromBlock() == forkFromBlock
|
||||||
|
&& java.util.Arrays.equals(active.getForkFromHash(), forkFromHash)
|
||||||
|
&& active.getReasonCode() == reasonCode
|
||||||
|
&& java.util.Objects.equals(active.getComment() == null ? "" : active.getComment(), comment == null ? "" : comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PdaSnapshot loadPdaForUpdate(Connection c, String login) throws Exception {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT root_key, blockchain_key, client_key, blockchain_name, rotation_status
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
WHERE normalized_login = LOWER(BTRIM(?))
|
||||||
|
FOR UPDATE
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
return new PdaSnapshot(
|
||||||
|
rs.getString("root_key"),
|
||||||
|
rs.getString("blockchain_key"),
|
||||||
|
rs.getString("client_key"),
|
||||||
|
rs.getString("blockchain_name"),
|
||||||
|
rs.getString("rotation_status")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record PdaSnapshot(String rootKey, String blockchainKey, String clientKey,
|
||||||
|
String blockchainName, String rotationStatus) { }
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
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.keyRotation.entyties.Net_KeyRotationStatus_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
|
||||||
|
public final class Net_KeyRotationStatus_Handler implements JsonMessageHandler {
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationStatus_Request req = (Net_KeyRotationStatus_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_STATUS_FAILED",
|
||||||
|
"Не удалось прочитать состояние смены ключей");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/** Прервать ротацию до отправки Solana-транзакции. */
|
||||||
|
public final class Net_KeyRotationAbort_Request extends Net_Request {
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/** Один подписанный новым blockchain key DataItem будущего fork. */
|
||||||
|
public final class Net_KeyRotationAddBlock_Request extends Net_Request {
|
||||||
|
private int blockNumber;
|
||||||
|
private String prevBlockHash;
|
||||||
|
private String blockBytesB64;
|
||||||
|
|
||||||
|
public int getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
public String getPrevBlockHash() { return prevBlockHash; }
|
||||||
|
public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; }
|
||||||
|
public String getBlockBytesB64() { return blockBytesB64; }
|
||||||
|
public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; }
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/** Продолжить один из интерактивных/заглушечных этапов после rebuild. */
|
||||||
|
public final class Net_KeyRotationContinue_Request extends Net_Request {
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user