SHA256
Compare commits
223
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7ab9fb7c28 | ||
|
|
6f499c68e2 | ||
|
|
19beba8fd7 | ||
|
|
f7e33802c8 | ||
|
|
c03d94105c | ||
|
|
632d56737b | ||
|
|
8801b93973 | ||
|
|
390a2b988e | ||
|
|
a66d7a64e6 | ||
|
|
a8596d3350 | ||
|
|
8a5dabee95 | ||
|
|
da859468d3 | ||
|
|
3e4583bbe9 | ||
|
|
b47440dd72 | ||
|
|
357a05f7ec | ||
|
|
f2fdb8195c | ||
|
|
8a0275d962 | ||
|
|
ef1fd9f579 | ||
|
|
f6b95d94bc | ||
|
|
2a65e6d359 | ||
|
|
83f87ba22c | ||
|
|
01321f5200 | ||
|
|
3a5851939e | ||
|
|
7d929e626e | ||
|
|
d8e0c77951 | ||
|
|
78ddb8ffb6 | ||
|
|
db37f35d09 | ||
|
|
d8f5fe4fd0 | ||
|
|
015fade4c0 | ||
|
|
da14b51e80 | ||
|
|
7a5ac01d1c | ||
|
|
5e6d64e965 | ||
|
|
c425fa41aa | ||
|
|
781157299f | ||
|
|
0f30317bb4 | ||
|
|
1f1bc0a7b9 | ||
|
|
60c8a6608a | ||
|
|
86adaf8c6b | ||
|
|
731688d16e | ||
|
|
63b66c48d8 | ||
|
|
ea19e511c0 | ||
|
|
f5e401cff8 | ||
|
|
bc8c7f318b | ||
|
|
585750007d | ||
|
|
6e80ac976a | ||
|
|
c70f18fcf4 | ||
|
|
d3e6aa2be2 | ||
|
|
b7a869c514 | ||
|
|
19362b950a | ||
|
|
0a4c31fb36 | ||
|
|
fef7694b48 | ||
|
|
60206e21df | ||
|
|
b9b77c66ce | ||
|
|
745a0e39d7 | ||
|
|
23bdadab56 | ||
|
|
47a4844ce8 | ||
|
|
8741be6cba | ||
|
|
4656a03ea9 | ||
|
|
5763eb828e | ||
|
|
6a5c20a165 | ||
|
|
fac166f186 | ||
|
|
091291bfa2 | ||
|
|
288fde67e8 | ||
|
|
ce30b77328 | ||
|
|
8c4e404f23 | ||
|
|
e8a4713f6e | ||
|
|
b4b23afc10 | ||
|
|
8f32e82d14 | ||
|
|
cf391bdd80 | ||
|
|
7120d6a9db | ||
|
|
4d80ab639e | ||
|
|
95bbd2852e | ||
|
|
4b0a934e51 | ||
|
|
ee5d86003b | ||
|
|
bc11b60147 | ||
|
|
801a4697ea | ||
|
|
628a970e6f | ||
|
|
566ea942dc | ||
|
|
e047c2d54e | ||
|
|
3fdd3d22e2 | ||
|
|
08e0fa9206 | ||
|
|
ed9e6ce676 | ||
|
|
a7bf07130e | ||
|
|
7c31662698 | ||
|
|
15df06a0bc | ||
|
|
47b5f8db7b | ||
|
|
0e763987aa | ||
|
|
37e8ea27d0 | ||
|
|
67d63256c2 | ||
|
|
ee185cf20a | ||
|
|
3552e05e4c | ||
|
|
735bb55b78 | ||
|
|
60f8a50a97 | ||
|
|
ffd01814b0 | ||
|
|
c511910b8f | ||
|
|
5c38f8f0d8 | ||
|
|
d512442325 | ||
|
|
43f54c90d2 | ||
|
|
57a99b478a | ||
|
|
3f402fbde7 | ||
|
|
98140c1f71 | ||
|
|
f00de85e38 | ||
|
|
b686b12c6a | ||
|
|
e4b903b13c | ||
|
|
72f576a70e | ||
|
|
c4f2c7be63 | ||
|
|
75b75fd223 | ||
|
|
391b18a5ac | ||
|
|
8de0f8e87c | ||
|
|
adb0b76559 | ||
|
|
b6df25c02c | ||
|
|
620186db1c | ||
|
|
21fac8046e | ||
|
|
de7c35f587 | ||
|
|
a23c979045 | ||
|
|
13da7287b1 | ||
|
|
e8398df9fc | ||
|
|
8303682bd7 | ||
|
|
8125d1a124 | ||
|
|
110ff5e12e | ||
|
|
24afcba20a | ||
|
|
d5f41d12e8 | ||
|
|
d640dd66e2 | ||
|
|
05bd9f57d6 | ||
|
|
60ab476ec7 | ||
|
|
bcae285ab3 | ||
|
|
cd67176b41 | ||
|
|
98eba54621 | ||
|
|
c530627078 | ||
|
|
f961947e1e | ||
|
|
e9e6628b21 | ||
|
|
c7684d6fe1 | ||
|
|
7d475ab403 | ||
|
|
143adcbbe4 | ||
|
|
ca8b6a33ba | ||
|
|
2f73cdb0f9 | ||
|
|
d1e06d9ef1 | ||
|
|
0ed78e2202 | ||
|
|
64818c586b | ||
|
|
578f5cad4a | ||
|
|
d2ff277b16 | ||
|
|
df4f9b3f8e | ||
|
|
be7f53a9ae | ||
|
|
6ed91a105e | ||
|
|
0db3c3af5a | ||
|
|
23748504e6 | ||
|
|
f74fecddd8 | ||
|
|
618a30c2ab | ||
|
|
b5116474c7 | ||
|
|
91e7239866 | ||
|
|
1dddb5fb3c | ||
|
|
8ddf592ffc | ||
|
|
59a1117ed3 | ||
|
|
3bb6fa1e59 | ||
|
|
5af98ac911 | ||
|
|
f197196c21 | ||
|
|
ce5595bc16 | ||
|
|
1406111f22 | ||
|
|
bfdada6792 | ||
|
|
5326ad85d8 | ||
|
|
f9dd245481 | ||
|
|
20677a9090 | ||
|
|
d54cb7507a | ||
|
|
d6b313b582 | ||
|
|
f31cdb413b | ||
|
|
69faf55b2b | ||
|
|
4d9e42205f | ||
|
|
5df69d73c7 | ||
|
|
4704f8485b |
@@ -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/
|
||||||
|
|||||||
@@ -45,6 +45,11 @@
|
|||||||
- `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.0.md`
|
||||||
|
- Актуальная документация по серверному модулю синхронизации Solana users находится в:
|
||||||
|
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
|
||||||
|
- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять:
|
||||||
|
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
|
||||||
|
- `deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md`
|
||||||
|
|
||||||
## Документация блокчейна
|
## Документация блокчейна
|
||||||
- Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`.
|
- Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`.
|
||||||
|
|||||||
@@ -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-среде.
|
||||||
@@ -9,7 +9,7 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
|
|||||||
|
|
||||||
- `shine-server-net-server/` — точка входа, запуск HTTP/WS сервера
|
- `shine-server-net-server/` — точка входа, запуск HTTP/WS сервера
|
||||||
- `shine-server-net-protocol/` — обработчики операций (RPC и события WS)
|
- `shine-server-net-protocol/` — обработчики операций (RPC и события WS)
|
||||||
- `shine-server-db/` — DAO, SQL-схема, SQLite
|
- `shine-server-db/` — DAO, SQL-схема, PostgreSQL runtime
|
||||||
- `shine-server-blockchain/` — логика хранения и проверки блоков блокчейна
|
- `shine-server-blockchain/` — логика хранения и проверки блоков блокчейна
|
||||||
- `shine-server-crypto/` — криптографические утилиты
|
- `shine-server-crypto/` — криптографические утилиты
|
||||||
- `shine-server-config/` — конфигурация сервера
|
- `shine-server-config/` — конфигурация сервера
|
||||||
@@ -23,7 +23,7 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
|
|||||||
|
|
||||||
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
|
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
|
||||||
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
|
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
|
||||||
с которыми синхронизируются блоки и DM;
|
с которыми синхронизируются пользовательские блокчейны;
|
||||||
- **корневой ключ** сервера (`root_key`).
|
- **корневой ключ** сервера (`root_key`).
|
||||||
|
|
||||||
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
|
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
|
||||||
@@ -49,7 +49,9 @@ shine-UI/server-ui.html
|
|||||||
|
|
||||||
## Синхронизация с партнёрскими серверами
|
## Синхронизация с партнёрскими серверами
|
||||||
|
|
||||||
Сервер должен синхронизировать блоки блокчейна и DM с серверами-партнёрами из `sync_servers`.
|
Сервер должен синхронизировать блоки пользовательских блокчейнов с
|
||||||
|
серверами-партнёрами из `sync_servers`. DM между партнёрами не реплицируются:
|
||||||
|
они доставляются только на первый access-сервер получателя.
|
||||||
Детали: `docs/Blockchain/sync-between-servers.md`
|
Детали: `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"); 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: interval={}m gateway={}", cfg.publishIntervalMinutes(), cfg.publishGateway());
|
||||||
|
} 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(); }
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package server.archive;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Publishes locally-created signed user DataItems as one standard ANS-104 bundle. */
|
||||||
|
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 ArweaveL1Uploader uploader;
|
||||||
|
|
||||||
|
public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) {
|
||||||
|
this.cfg = cfg;
|
||||||
|
this.uploader = new ArweaveL1Uploader(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int runCycle() throws Exception {
|
||||||
|
List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems());
|
||||||
|
if (candidates.isEmpty()) return 0;
|
||||||
|
|
||||||
|
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"),
|
||||||
|
// Deliberately different from App=test5590 so discovery returns child DataItems only.
|
||||||
|
new ArweaveL1Uploader.Tag("App", "test5590-batch")
|
||||||
|
);
|
||||||
|
ArweaveL1Uploader.UploadResult result = uploader.upload(bundle, rootTags);
|
||||||
|
blocksDAO.markArweavePublished(ids, result.txId(), System.currentTimeMillis());
|
||||||
|
log.info("Published {} SHiNE test DataItems in 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();}
|
||||||
|
}
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
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.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.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovers App=test5590 child DataItems, extracts their exact serialized bytes
|
||||||
|
* from the root ANS-104 bundle and imports them through the normal AddBlock checks.
|
||||||
|
*/
|
||||||
|
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)).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;
|
||||||
|
Map<String, byte[]> rootCache = new HashMap<>();
|
||||||
|
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 itemIdText = node.path("id").asText("").trim();
|
||||||
|
String rootTx = node.path("bundledIn").path("id").asText("").trim();
|
||||||
|
long height = node.path("block").path("height").asLong(-1L);
|
||||||
|
if (itemIdText.isBlank() || rootTx.isBlank() || height < 0) continue;
|
||||||
|
maxHeight = Math.max(maxHeight, height);
|
||||||
|
byte[] id32;
|
||||||
|
try { id32 = B64URL.decode(itemIdText); }
|
||||||
|
catch (IllegalArgumentException bad) { continue; }
|
||||||
|
if (id32.length != 32 || blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue;
|
||||||
|
|
||||||
|
byte[] bundle = rootCache.computeIfAbsent(rootTx, key -> {
|
||||||
|
try { return downloadRootBundle(key); }
|
||||||
|
catch (Exception e) { throw new RootDownloadRuntimeException(e); }
|
||||||
|
});
|
||||||
|
byte[] raw = Ans104Bundle.find(bundle, id32, Math.max(cfg.publishMaxItems() * 4, 100_000));
|
||||||
|
if (raw == null) throw new IOException("DataItem " + itemIdText + " not found in root bundle " + rootTx);
|
||||||
|
Ans104DataItem parsed = new Ans104DataItem(raw);
|
||||||
|
if (!parsed.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) continue;
|
||||||
|
if (!parsed.verifySignature()) throw new IOException("Bad ANS-104 signature for " + itemIdText);
|
||||||
|
importDAO.enqueueIfMissing(id32, rootTx, height, raw, System.currentTimeMillis());
|
||||||
|
discovered++;
|
||||||
|
}
|
||||||
|
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 one-height overlap: the next query includes this height and deduplicates IDs.
|
||||||
|
importDAO.setLastBlockHeight(maxHeight, System.currentTimeMillis());
|
||||||
|
if (discovered > 0) log.info("Arweave discovery queued {} new SHiNE test DataItems through height {}", discovered, maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
BchBlockEntry block;
|
||||||
|
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");
|
||||||
|
block = 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:\"App\",values:[\"test5590\"]}], block:{min:" + minHeight + "}, first:" + cfg.syncPageSize() + ", after:" + after + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id bundledIn { 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] downloadRootBundle(String txId) throws Exception {
|
||||||
|
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/" + txId))
|
||||||
|
.timeout(Duration.ofMinutes(5)).GET().build();
|
||||||
|
HttpResponse<byte[]> resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Arweave root HTTP " + resp.statusCode() + " tx=" + txId);
|
||||||
|
byte[] body = resp.body();
|
||||||
|
if (body == null || body.length == 0) throw new IOException("Empty Arweave root bundle " + txId);
|
||||||
|
if (body.length > cfg.syncMaxRootBundleBytes()) throw new IOException("Root bundle exceeds syncMaxRootBundleBytes: " + body.length);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");}
|
||||||
|
private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");}
|
||||||
|
private static final class RootDownloadRuntimeException extends RuntimeException { RootDownloadRuntimeException(Throwable cause){super(cause);} }
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package server.archive;
|
||||||
|
|
||||||
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/** Configuration of the new per-user-block ANS-104 Arweave transport. */
|
||||||
|
public record ArweaveBlocksConfig(
|
||||||
|
boolean publishEnabled,
|
||||||
|
int publishIntervalMinutes,
|
||||||
|
int publishMaxItems,
|
||||||
|
long publishMaxBundleBytes,
|
||||||
|
String publishGateway,
|
||||||
|
Path walletJwkPath,
|
||||||
|
int minConfirmations,
|
||||||
|
int confirmPollSeconds,
|
||||||
|
int confirmTimeoutMinutes,
|
||||||
|
boolean syncEnabled,
|
||||||
|
int syncIntervalMinutes,
|
||||||
|
int syncPageSize,
|
||||||
|
int syncQueueBatchSize,
|
||||||
|
long syncStartBlockHeight,
|
||||||
|
long syncMaxRootBundleBytes,
|
||||||
|
String syncGateway
|
||||||
|
) {
|
||||||
|
public static final String TEST_TAG_NAME = "App";
|
||||||
|
public static final String TEST_TAG_VALUE = "test5590";
|
||||||
|
public static final String CHANNEL_TAG_NAME = "c";
|
||||||
|
|
||||||
|
public static ArweaveBlocksConfig load() {
|
||||||
|
AppConfig c = AppConfig.getInstance();
|
||||||
|
return new ArweaveBlocksConfig(
|
||||||
|
c.getBoolean("arweave.blocks.publish.enabled", false),
|
||||||
|
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"),
|
||||||
|
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(parseLong(c.getParam("arweave.blocks.sync.maxRootBundleBytes"), 256L * 1024 * 1024), "sync.maxRootBundleBytes"),
|
||||||
|
orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void validatePublisher() {
|
||||||
|
if (publishEnabled && walletJwkPath == null) {
|
||||||
|
throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required when publisher is enabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+11
-1
@@ -36,7 +36,12 @@ public final class BodyRecordParser {
|
|||||||
case TextBody.KEY -> {
|
case TextBody.KEY -> {
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||||
yield new TextLineBody(subType, version, bodyBytes);
|
yield new TextLineBody(subType, version, bodyBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +50,17 @@ public final class BodyRecordParser {
|
|||||||
yield new TextReplyBody(subType, version, bodyBytes);
|
yield new TextReplyBody(subType, version, bodyBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (st == (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
||||||
|
yield new TextRatingBody(subType, version, bodyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
|
throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st);
|
||||||
}
|
}
|
||||||
|
|
||||||
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
|
case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes);
|
||||||
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
|
case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes);
|
||||||
case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
|
case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes);
|
||||||
|
case StatusActionBody.KEY -> new StatusActionBody(subType, version, bodyBytes);
|
||||||
|
|
||||||
default -> throw new IllegalArgumentException(String.format(
|
default -> throw new IllegalArgumentException(String.format(
|
||||||
"Unknown body type/version from header: type=%d ver=%d subType=%d",
|
"Unknown body type/version from header: type=%d ver=%d subType=%d",
|
||||||
|
|||||||
@@ -64,11 +64,29 @@ public final class MsgSubType {
|
|||||||
*/
|
*/
|
||||||
public static final short TEXT_EDIT_REPLY = 21;
|
public static final short TEXT_EDIT_REPLY = 21;
|
||||||
|
|
||||||
|
/** RATING — target-based отзыв на конкретный блок. */
|
||||||
|
public static final short TEXT_RATING = 30;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REPOST — репост сообщения в линии канала.
|
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
|
||||||
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
||||||
*/
|
*/
|
||||||
public static final short TEXT_REPOST = 30;
|
public static final short TEXT_REPOST = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CHANNEL_META — скрытый технический снимок профиля канала.
|
||||||
|
* Имеет hasLine, использует body как POST: line-поля + UTF-8 текст.
|
||||||
|
*/
|
||||||
|
public static final short TEXT_CHANNEL_META = 90;
|
||||||
|
|
||||||
|
/** ENTRYPOINT — входная страница канала (line-based). */
|
||||||
|
public static final short TEXT_ENTRYPOINT = 100;
|
||||||
|
/** EXERCISE — упражнение/комплекс (line-based). */
|
||||||
|
public static final short TEXT_EXERCISE = 110;
|
||||||
|
/** SERVICE — услуга/процедура (line-based). */
|
||||||
|
public static final short TEXT_SERVICE = 120;
|
||||||
|
/** COURSE — курс (line-based). */
|
||||||
|
public static final short TEXT_COURSE = 130;
|
||||||
|
|
||||||
/* ===================== REACTION (msg_type=2) ===================== */
|
/* ===================== REACTION (msg_type=2) ===================== */
|
||||||
|
|
||||||
@@ -80,25 +98,27 @@ public final class MsgSubType {
|
|||||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||||
|
|
||||||
/** Добавить в близкие друзья (close friend). */
|
/** Добавить в близкие друзья (close friend). */
|
||||||
public static final short CONNECTION_FRIEND = 10;
|
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||||
/** Удалить из близких друзей (close friend). */
|
/** Удалить из близких друзей (close friend). */
|
||||||
public static final short CONNECTION_UNFRIEND = 11;
|
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||||
|
|
||||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
/** Добавить в друзья. */
|
||||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
public static final short CONNECTION_FRIEND = 14;
|
||||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
/** Удалить из друзей. */
|
||||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
public static final short CONNECTION_UNFRIEND = 15;
|
||||||
|
|
||||||
/** Добавить в контакты. */
|
/** Добавить в контакты. */
|
||||||
public static final short CONNECTION_CONTACT = 20;
|
public static final short CONNECTION_CONTACT = 20;
|
||||||
/** Удалить из контактов. */
|
/** Удалить из контактов. */
|
||||||
public static final short CONNECTION_UNCONTACT = 21;
|
public static final short CONNECTION_UNCONTACT = 21;
|
||||||
|
|
||||||
|
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя (target header/block 0) считается legacy. DO NOT REMOVE.
|
||||||
/** Подписаться (follow). */
|
/** Подписаться (follow). */
|
||||||
public static final short CONNECTION_FOLLOW = 30;
|
public static final short CONNECTION_FOLLOW = 30;
|
||||||
/** Отписаться (unfollow). */
|
/** Отписаться (unfollow). */
|
||||||
public static final short CONNECTION_UNFOLLOW = 31;
|
public static final short CONNECTION_UNFOLLOW = 31;
|
||||||
|
|
||||||
|
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
|
||||||
/** Добавить связь "жена/муж". */
|
/** Добавить связь "жена/муж". */
|
||||||
public static final short CONNECTION_SPOUSE = 40;
|
public static final short CONNECTION_SPOUSE = 40;
|
||||||
/** Удалить связь "жена/муж". */
|
/** Удалить связь "жена/муж". */
|
||||||
@@ -119,9 +139,8 @@ public final class MsgSubType {
|
|||||||
/** Удалить связь "брат/сестра". */
|
/** Удалить связь "брат/сестра". */
|
||||||
public static final short CONNECTION_UNSIBLING = 55;
|
public static final short CONNECTION_UNSIBLING = 55;
|
||||||
|
|
||||||
/** Просто знаю этого человека. */
|
// Legacy. Допускаем старые блоки 60/61, но новый UI их не создаёт и не использует. DO NOT REMOVE.
|
||||||
public static final short CONNECTION_KNOWN_PERSON = 60;
|
public static final short CONNECTION_KNOWN_PERSON = 60;
|
||||||
/** Не знаю этого человека. */
|
|
||||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
||||||
|
|
||||||
/** Точно уверен, что сияющий. */
|
/** Точно уверен, что сияющий. */
|
||||||
@@ -129,13 +148,31 @@ public final class MsgSubType {
|
|||||||
/** Не подтверждаю, что сияющий. */
|
/** Не подтверждаю, что сияющий. */
|
||||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
||||||
|
|
||||||
|
// Пока не используется в UI. Сохраняем для совместимости протокола/блокчейна. DO NOT REMOVE.
|
||||||
/** Мало знаком, но видел сияющим. */
|
/** Мало знаком, но видел сияющим. */
|
||||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||||
/** Не отмечаю, что видел сияющим. */
|
/** Не отмечаю, что видел сияющим. */
|
||||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||||
|
|
||||||
|
/** Подтверждаю, что это действительно официальный аккаунт этого человека. */
|
||||||
|
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
|
||||||
|
/** Снимаю подтверждение официального аккаунта этого человека. */
|
||||||
|
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
|
||||||
|
|
||||||
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||||
|
|
||||||
/** Параметр профиля key/value (обе строки). */
|
/** Параметр профиля key/value (обе строки). */
|
||||||
public static final short USER_PARAM_TEXT_TEXT = 1;
|
public static final short USER_PARAM_TEXT_TEXT = 1;
|
||||||
|
|
||||||
|
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
|
||||||
|
|
||||||
|
public static final short STATUS_DONE_ONCE = 10;
|
||||||
|
public static final short STATUS_LEARNED = 20;
|
||||||
|
public static final short STATUS_SERVICE_PASSED = 30;
|
||||||
|
public static final short STATUS_CONFIRMED = 100;
|
||||||
|
public static final short STATUS_INTERESTED = 110;
|
||||||
|
public static final short STATUS_STARTED = 120;
|
||||||
|
public static final short STATUS_IN_STUDY = 130;
|
||||||
|
public static final short STATUS_ABANDONED = 140;
|
||||||
|
public static final short STATUS_COMPLETED = 150;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-5
@@ -20,9 +20,11 @@ import java.util.Objects;
|
|||||||
* PARENT=50, UNPARENT=51
|
* PARENT=50, UNPARENT=51
|
||||||
* CHILD=52, UNCHILD=53
|
* CHILD=52, UNCHILD=53
|
||||||
* SIBLING=54, UNSIBLING=55
|
* SIBLING=54, UNSIBLING=55
|
||||||
* KNOWN_PERSON=60, UNKNOWN_PERSON=61
|
* 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_CONFIRMED=70, SHINE_UNCONFIRMED=71
|
||||||
* SHINE_SEEN=74, SHINE_UNSEEN=75
|
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
|
||||||
|
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
|
||||||
*
|
*
|
||||||
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
||||||
* [4] lineCode
|
* [4] lineCode
|
||||||
@@ -66,7 +68,7 @@ import java.util.Objects;
|
|||||||
* toBlockHash32=hash32(CREATE_CHANNEL)
|
* toBlockHash32=hash32(CREATE_CHANNEL)
|
||||||
*
|
*
|
||||||
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
|
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
|
||||||
* - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
||||||
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
|
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
|
||||||
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
|
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
|
||||||
*
|
*
|
||||||
@@ -183,7 +185,9 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
|
|
||||||
private static boolean isValidSubType(short st) {
|
private static boolean isValidSubType(short st) {
|
||||||
int v = st & 0xFFFF;
|
int v = st & 0xFFFF;
|
||||||
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
||||||
@@ -202,7 +206,9 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF);
|
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||||
|
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+6
-7
@@ -19,7 +19,7 @@ import java.util.Objects;
|
|||||||
* [1] channelNameLen
|
* [1] channelNameLen
|
||||||
* [N] channelName UTF-8
|
* [N] channelName UTF-8
|
||||||
* [2] channelDescriptionLen
|
* [2] channelDescriptionLen
|
||||||
* [M] channelDescription UTF-8 (0..200 bytes)
|
* [M] channelDescription UTF-8 (0..2048 bytes)
|
||||||
* [2] channelTypeCode (uint16)
|
* [2] channelTypeCode (uint16)
|
||||||
* [2] channelTypeVersion (uint16)
|
* [2] channelTypeVersion (uint16)
|
||||||
*/
|
*/
|
||||||
@@ -38,7 +38,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
|
|
||||||
private static final byte[] ZERO32 = new byte[32];
|
private static final byte[] ZERO32 = new byte[32];
|
||||||
private static final int MAX_NAME_LENGTH = 32;
|
private static final int MAX_NAME_LENGTH = 32;
|
||||||
private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
|
public static final int MAX_DESCRIPTION_UTF8_LEN = 2048;
|
||||||
|
|
||||||
public final short subType;
|
public final short subType;
|
||||||
public final short version;
|
public final short version;
|
||||||
@@ -88,7 +88,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
|
|
||||||
int descriptionLen = Short.toUnsignedInt(bb.getShort());
|
int descriptionLen = Short.toUnsignedInt(bb.getShort());
|
||||||
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
|
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
|
||||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
|
||||||
}
|
}
|
||||||
if (bb.remaining() != descriptionLen + 4) {
|
if (bb.remaining() != descriptionLen + 4) {
|
||||||
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
|
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
|
||||||
@@ -145,7 +145,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
String normalizedDescription = normalizeDescription(channelDescription);
|
String normalizedDescription = normalizeDescription(channelDescription);
|
||||||
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
|
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
|
||||||
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
|
||||||
}
|
}
|
||||||
|
|
||||||
int typeCode = Short.toUnsignedInt(channelTypeCode);
|
int typeCode = Short.toUnsignedInt(channelTypeCode);
|
||||||
@@ -166,7 +166,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
|
|
||||||
private static String normalizeDescription(String value) {
|
private static String normalizeDescription(String value) {
|
||||||
if (value == null) return "";
|
if (value == null) return "";
|
||||||
return value.trim().replaceAll("\\s+", " ");
|
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -177,7 +177,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
}
|
}
|
||||||
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
|
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
|
||||||
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
|
||||||
}
|
}
|
||||||
|
|
||||||
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + 2 + descriptionUtf8.length + 4;
|
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + 2 + descriptionUtf8.length + 4;
|
||||||
@@ -204,4 +204,3 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
@Override
|
@Override
|
||||||
public int lineSeq() { return thisLineNumber; }
|
public int lineSeq() { return thisLineNumber; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
package blockchain.body;
|
||||||
|
|
||||||
|
import blockchain.MsgSubType;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.charset.CharacterCodingException;
|
||||||
|
import java.nio.charset.CodingErrorAction;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatusActionBody — type=5, ver=1.
|
||||||
|
*
|
||||||
|
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
|
||||||
|
*
|
||||||
|
* Формат bodyBytes (BigEndian):
|
||||||
|
* [1] toBlockchainNameLen (uint8)
|
||||||
|
* [N] toBlockchainName UTF-8
|
||||||
|
* [4] toBlockGlobalNumber
|
||||||
|
* [32] toBlockHash32
|
||||||
|
* [2] textLenBytes (uint16)
|
||||||
|
* [M] text UTF-8
|
||||||
|
*/
|
||||||
|
public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||||
|
|
||||||
|
public static final short TYPE = 5;
|
||||||
|
public static final short VER = 1;
|
||||||
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
|
public final short subType;
|
||||||
|
public final short version;
|
||||||
|
public final String toBlockchainName;
|
||||||
|
public final int toBlockGlobalNumber;
|
||||||
|
public final byte[] toBlockHash32;
|
||||||
|
public final String message;
|
||||||
|
|
||||||
|
public StatusActionBody(short subType, short version, byte[] bodyBytes) {
|
||||||
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
this.subType = subType;
|
||||||
|
this.version = version;
|
||||||
|
|
||||||
|
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("StatusActionBody version must be 1, got=" + (version & 0xFFFF));
|
||||||
|
}
|
||||||
|
if (!isSupportedSubType(subType)) {
|
||||||
|
throw new IllegalArgumentException("Unsupported STATUS_ACTION subType: " + (subType & 0xFFFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
|
||||||
|
|
||||||
|
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
|
||||||
|
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
||||||
|
|
||||||
|
byte[] nameBytes = new byte[nameLen];
|
||||||
|
bb.get(nameBytes);
|
||||||
|
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||||
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
|
this.toBlockHash32 = new byte[32];
|
||||||
|
bb.get(this.toBlockHash32);
|
||||||
|
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
|
||||||
|
|
||||||
|
ensureNoTail(bb, "StatusActionBody");
|
||||||
|
}
|
||||||
|
|
||||||
|
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||||
|
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||||
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
Objects.requireNonNull(message, "message == null");
|
||||||
|
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
|
||||||
|
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||||
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
|
this.subType = subType;
|
||||||
|
this.version = VER;
|
||||||
|
this.toBlockchainName = toBlockchainName;
|
||||||
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public StatusActionBody check() {
|
||||||
|
if (!isSupportedSubType(subType)) {
|
||||||
|
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
||||||
|
}
|
||||||
|
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
|
||||||
|
}
|
||||||
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
|
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
||||||
|
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
|
}
|
||||||
|
if (message == null) throw new IllegalArgumentException("message is null");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] toBytes() {
|
||||||
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
|
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||||
|
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255");
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||||
|
.order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.put((byte) nameUtf8.length);
|
||||||
|
bb.put(nameUtf8);
|
||||||
|
bb.putInt(toBlockGlobalNumber);
|
||||||
|
bb.put(toBlockHash32);
|
||||||
|
bb.putShort((short) msgUtf8.length);
|
||||||
|
bb.put(msgUtf8);
|
||||||
|
return bb.array();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public String toBchName() { return toBlockchainName; }
|
||||||
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
|
private static boolean isSupportedSubType(short subType) {
|
||||||
|
int st = subType & 0xFFFF;
|
||||||
|
return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_LEARNED & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_INTERESTED & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_STARTED & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.STATUS_COMPLETED & 0xFFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) {
|
||||||
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
|
if (len == 0) return "";
|
||||||
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
|
byte[] bytes = new byte[len];
|
||||||
|
bb.get(bytes);
|
||||||
|
|
||||||
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
|
} catch (CharacterCodingException e) {
|
||||||
|
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
||||||
|
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
||||||
|
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
-13
@@ -16,11 +16,16 @@ import java.util.Objects;
|
|||||||
* subType:
|
* subType:
|
||||||
* - POST (10)
|
* - POST (10)
|
||||||
* - EDIT_POST (11)
|
* - EDIT_POST (11)
|
||||||
* - REPOST (30)
|
* - REPOST (50)
|
||||||
|
* - CHANNEL_META (90)
|
||||||
|
* - ENTRYPOINT (100)
|
||||||
|
* - EXERCISE (110)
|
||||||
|
* - SERVICE (120)
|
||||||
|
* - COURSE (130)
|
||||||
*
|
*
|
||||||
* Формат bodyBytes (BigEndian):
|
* Формат bodyBytes (BigEndian):
|
||||||
*
|
*
|
||||||
* POST:
|
* POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
|
||||||
* [4] lineCode
|
* [4] lineCode
|
||||||
* [4] prevLineNumber
|
* [4] prevLineNumber
|
||||||
* [32] prevLineHash32
|
* [32] prevLineHash32
|
||||||
@@ -89,8 +94,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
int st = this.subType & 0xFFFF;
|
int st = this.subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st);
|
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st);
|
||||||
}
|
}
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
@@ -133,7 +143,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
this.toBlockHash32 = null;
|
this.toBlockHash32 = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF));
|
this.message = readStrictUtf8Len16(bb, "TextLineBody text",
|
||||||
|
st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF));
|
||||||
|
|
||||||
ensureNoTail(bb, "TextLineBody");
|
ensureNoTail(bb, "TextLineBody");
|
||||||
}
|
}
|
||||||
@@ -155,12 +166,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST");
|
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) {
|
if (requiresNonBlankMessage(st) && message.isBlank()) {
|
||||||
throw new IllegalArgumentException("message is blank");
|
throw new IllegalArgumentException("message is blank");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +222,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF))
|
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF))
|
||||||
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
|
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
@@ -231,10 +252,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
|
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
|
||||||
} else {
|
} else {
|
||||||
if (message == null || message.isBlank())
|
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||||
|
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
|
||||||
|
} else if (requiresNonBlankMessage(st) && (message == null || message.isBlank())) {
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
throw new IllegalArgumentException("Text message is blank");
|
||||||
|
} else if (message == null) {
|
||||||
|
throw new IllegalArgumentException("Text message is null");
|
||||||
|
}
|
||||||
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||||
throw new IllegalArgumentException("POST must not contain target fields");
|
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
@@ -246,12 +272,17 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
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_POST & 0xFFFF) && msgUtf8.length == 0) {
|
if (requiresNonBlankMessage(st) && msgUtf8.length == 0) {
|
||||||
throw new IllegalArgumentException("Text payload is empty");
|
throw new IllegalArgumentException("Text payload is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
int cap;
|
int cap;
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||||
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
// EDIT_POST
|
// EDIT_POST
|
||||||
@@ -310,6 +341,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean requiresNonBlankMessage(int st) {
|
||||||
|
return st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||||
|
}
|
||||||
|
|
||||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
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) {
|
||||||
|
|||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
package blockchain.body;
|
||||||
|
|
||||||
|
import blockchain.MsgSubType;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.charset.CharacterCodingException;
|
||||||
|
import java.nio.charset.CodingErrorAction;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TextRatingBody — type=1, ver=1.
|
||||||
|
*
|
||||||
|
* subType:
|
||||||
|
* - RATING (30)
|
||||||
|
*
|
||||||
|
* Формат bodyBytes (BigEndian):
|
||||||
|
* [1] toBlockchainNameLen (uint8)
|
||||||
|
* [N] toBlockchainName UTF-8
|
||||||
|
* [4] toBlockGlobalNumber
|
||||||
|
* [32] toBlockHash32
|
||||||
|
* [2] textLenBytes (uint16)
|
||||||
|
* [M] text UTF-8
|
||||||
|
*/
|
||||||
|
public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||||
|
|
||||||
|
public static final short TYPE = 1;
|
||||||
|
public static final short VER = 1;
|
||||||
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
|
public final short subType;
|
||||||
|
public final short version;
|
||||||
|
public final String toBlockchainName;
|
||||||
|
public final int toBlockGlobalNumber;
|
||||||
|
public final byte[] toBlockHash32;
|
||||||
|
public final String message;
|
||||||
|
|
||||||
|
public TextRatingBody(short subType, short version, byte[] bodyBytes) {
|
||||||
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
this.subType = subType;
|
||||||
|
this.version = version;
|
||||||
|
|
||||||
|
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("TextRatingBody version must be 1, got=" + (version & 0xFFFF));
|
||||||
|
}
|
||||||
|
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("TextRatingBody supports only TEXT_RATING");
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
|
||||||
|
|
||||||
|
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||||
|
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
|
||||||
|
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
|
||||||
|
|
||||||
|
byte[] nameBytes = new byte[nameLen];
|
||||||
|
bb.get(nameBytes);
|
||||||
|
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||||
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
|
this.toBlockHash32 = new byte[32];
|
||||||
|
bb.get(this.toBlockHash32);
|
||||||
|
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
|
||||||
|
|
||||||
|
ensureNoTail(bb, "TextRatingBody");
|
||||||
|
}
|
||||||
|
|
||||||
|
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||||
|
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||||
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
Objects.requireNonNull(message, "message == null");
|
||||||
|
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||||
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||||
|
|
||||||
|
this.subType = MsgSubType.TEXT_RATING;
|
||||||
|
this.version = VER;
|
||||||
|
this.toBlockchainName = toBlockchainName;
|
||||||
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TextRatingBody check() {
|
||||||
|
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
|
||||||
|
}
|
||||||
|
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("RATING toBlockchainName is blank");
|
||||||
|
}
|
||||||
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
|
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
||||||
|
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
|
}
|
||||||
|
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] toBytes() {
|
||||||
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
||||||
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
|
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||||
|
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||||
|
.order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.put((byte) nameUtf8.length);
|
||||||
|
bb.put(nameUtf8);
|
||||||
|
bb.putInt(toBlockGlobalNumber);
|
||||||
|
bb.put(toBlockHash32);
|
||||||
|
bb.putShort((short) msgUtf8.length);
|
||||||
|
bb.put(msgUtf8);
|
||||||
|
return bb.array();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public String toBchName() { return toBlockchainName; }
|
||||||
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
|
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
|
||||||
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
|
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
|
||||||
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
|
byte[] bytes = new byte[len];
|
||||||
|
bb.get(bytes);
|
||||||
|
|
||||||
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
|
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||||
|
return s;
|
||||||
|
} catch (CharacterCodingException e) {
|
||||||
|
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureMin(ByteBuffer bb, int need, String msg) {
|
||||||
|
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureNoTail(ByteBuffer bb, String ctx) {
|
||||||
|
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", "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"));
|
||||||
|
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", "books")
|
||||||
|
);
|
||||||
|
|
||||||
|
byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data);
|
||||||
|
byte[] expected = hex("7e67d0debce103606d697a1e3785130ca20cb89cd8a06f9a65b98b2d2427eaf411fcf7d482da268e44f3ac25c57c3cb9");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -59,23 +59,33 @@ public final class AppConfig {
|
|||||||
public String getParam(String name) {
|
public String getParam(String name) {
|
||||||
String fromSystem = System.getProperty(name);
|
String fromSystem = System.getProperty(name);
|
||||||
if (fromSystem != null) return fromSystem;
|
if (fromSystem != null) return fromSystem;
|
||||||
|
String fromEnv = System.getenv(toEnvName(name));
|
||||||
|
if (fromEnv != null && !fromEnv.isBlank()) return fromEnv.trim();
|
||||||
return properties.getProperty(name);
|
return properties.getProperty(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Вернёт строку или пустую строку, если параметр не найден. */
|
/** Вернёт строку или пустую строку, если параметр не найден. */
|
||||||
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) {
|
||||||
|
return name
|
||||||
|
.replace('.', '_')
|
||||||
|
.replace('-', '_')
|
||||||
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -14,6 +14,12 @@ public final class ShineSignatureConstants {
|
|||||||
/** Подписываемые данные параметра пользователя: prefix + login + param + time_ms + value */
|
/** Подписываемые данные параметра пользователя: prefix + login + param + time_ms + value */
|
||||||
public static final String USER_PARAMETER_PREFIX = "SHiNe/UserParameter:";
|
public static final String USER_PARAMETER_PREFIX = "SHiNe/UserParameter:";
|
||||||
|
|
||||||
|
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
|
||||||
|
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` больше не используются.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.xerial:sqlite-jdbc:3.47.0.0' // sqlite
|
implementation 'org.postgresql:postgresql:42.7.7'
|
||||||
|
|
||||||
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
|
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,569 +0,0 @@
|
|||||||
package shine.db;
|
|
||||||
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.Statement;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DatabaseTriggersInstaller — устанавливает триггеры, которые поддерживают бизнес-логику БД.
|
|
||||||
*
|
|
||||||
* Мы специально сделали триггеры максимально "совместимыми":
|
|
||||||
* - НЕТ динамических сообщений в RAISE(...): только фиксированные строки.
|
|
||||||
* (Некоторые SQLite-сборки / просмотрщики падают на "||" внутри RAISE.)
|
|
||||||
* - НЕТ UPSERT "ON CONFLICT DO UPDATE" — вместо него:
|
|
||||||
* INSERT OR IGNORE + UPDATE
|
|
||||||
* (Старые SQLite не знают UPSERT.)
|
|
||||||
*
|
|
||||||
* =============================================================================
|
|
||||||
* РћРџРРЎРђРќРР• РўР РГГЕРОВ
|
|
||||||
* =============================================================================
|
|
||||||
*
|
|
||||||
* [1] trg_blocks_line_integrity_bi (BEFORE INSERT ON blocks)
|
|
||||||
* Контроль целостности "линий" (line_code / prev_line_number / prev_line_hash / this_line_number).
|
|
||||||
*
|
|
||||||
* Зачем это нужно:
|
|
||||||
* - В каналах/ветках/действиях ты хочешь иметь "линейную" последовательность,
|
|
||||||
* где каждый следующий блок явно ссылается на предыдущий блок линии
|
|
||||||
* и подтверждает, что ссылка не подменена.
|
|
||||||
*
|
|
||||||
* Когда срабатывает:
|
|
||||||
* - ТОЛЬКО если при вставке передано ХОТЯ БЫ ОДНО из line-полей.
|
|
||||||
* - Если line-поля не переданы — триггер вообще не работает (это важно).
|
|
||||||
*
|
|
||||||
* Что проверяет:
|
|
||||||
* A) line-поля допускаются только для msg_type:
|
|
||||||
* 0 (TECH), 1 (TEXT), 3 (CONNECTION), 4 (USER_PARAM)
|
|
||||||
* B) Если пришло хоть одно line-поле — обязаны прийти ВСЕ 4 (никаких "частичных")
|
|
||||||
* C) prev-блок линии существует в той же цепочке bch_name
|
|
||||||
* D) prev_hash совпадает с block_hash найденного prev-блока
|
|
||||||
* E) line_code корректный:
|
|
||||||
* - либо первый шаг после root: prev_line_number == line_code
|
|
||||||
* - либо prev уже принадлежит этой линии: p.line_code == NEW.line_code
|
|
||||||
* F) this_line_number:
|
|
||||||
* - первый шаг после root:
|
|
||||||
* TEXT: this_line_number = 0
|
|
||||||
* TECH/CONNECTION/USER_PARAM: this_line_number = 1
|
|
||||||
* - обычный шаг:
|
|
||||||
* TEXT: допускаем same или +1 (чтобы "edit" мог не двигать шаг)
|
|
||||||
* TECH/CONNECTION/USER_PARAM: строго prev.this + 1
|
|
||||||
*
|
|
||||||
* Какие ошибки кидает:
|
|
||||||
* - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE
|
|
||||||
* - LINE_ERR_PARTIAL_FIELDS
|
|
||||||
* - LINE_ERR_NO_PREV
|
|
||||||
* - LINE_ERR_PREV_HASH_MISMATCH
|
|
||||||
* - LINE_ERR_LINE_CODE_MISMATCH
|
|
||||||
* - LINE_ERR_FIRST_STEP_BAD_THIS
|
|
||||||
* - LINE_ERR_THIS_LINE_BAD_STEP
|
|
||||||
*
|
|
||||||
* [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3)
|
|
||||||
* Поддерживает таблицу connections_state как "текущее состояние" отношений:
|
|
||||||
* - FRIEND/CONTACT/FOLLOW -> добавить/обновить состояние
|
|
||||||
* - UNFRIEND/UNCONTACT/UNFOLLOW -> удалить соответствующее "позитивное" состояние
|
|
||||||
*
|
|
||||||
* [3] trg_blocks_message_stats_like_ai (AFTER INSERT ON blocks WHEN msg_type=2 AND sub_type=LIKE)
|
|
||||||
* Поддерживает likes_count в message_stats для цели (to_*).
|
|
||||||
*
|
|
||||||
* [4] trg_blocks_message_stats_reply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=REPLY)
|
|
||||||
* Поддерживает replies_count в message_stats.
|
|
||||||
*
|
|
||||||
* [5] trg_blocks_edit_apply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=EDIT)
|
|
||||||
* Логика edit:
|
|
||||||
* - помечает исходный блок edited_by_block_number = NEW.block_number
|
|
||||||
* - увеличивает edits_count в message_stats
|
|
||||||
*/
|
|
||||||
public final class DatabaseTriggersInstaller {
|
|
||||||
|
|
||||||
private DatabaseTriggersInstaller() {}
|
|
||||||
|
|
||||||
public static void createAllTriggers(Statement st) throws SQLException {
|
|
||||||
dropTriggersByPrefix(st, "trg_blocks_");
|
|
||||||
|
|
||||||
// На всякий случай убираем старые "криво названные" триггеры,
|
|
||||||
// если они когда-то попадали в БД.
|
|
||||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_block_lini_integriti_by;");
|
|
||||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;");
|
|
||||||
|
|
||||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai;");
|
|
||||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_like_ai;");
|
|
||||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_reply_ai;");
|
|
||||||
st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_edit_apply_ai;");
|
|
||||||
|
|
||||||
createLineIntegrityTrigger(st);
|
|
||||||
createConnectionStateTrigger(st);
|
|
||||||
createMessageStatsLikeTrigger(st);
|
|
||||||
createMessageStatsReplyTrigger(st);
|
|
||||||
createEditApplyTrigger(st);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void dropTriggersByPrefix(Statement st, String prefix) throws SQLException {
|
|
||||||
List<String> triggerNames = new ArrayList<>();
|
|
||||||
String sql = "SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '" + prefix + "%'";
|
|
||||||
try (ResultSet rs = st.executeQuery(sql)) {
|
|
||||||
while (rs.next()) {
|
|
||||||
String name = rs.getString("name");
|
|
||||||
if (name != null && !name.isBlank()) {
|
|
||||||
triggerNames.add(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (String name : triggerNames) {
|
|
||||||
String safeName = name.replace("\"", "\"\"");
|
|
||||||
st.executeUpdate("DROP TRIGGER IF EXISTS \"" + safeName + "\"");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void createLineIntegrityTrigger(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi
|
|
||||||
BEFORE INSERT ON blocks
|
|
||||||
WHEN
|
|
||||||
NEW.line_code IS NOT NULL
|
|
||||||
OR NEW.prev_line_number IS NOT NULL
|
|
||||||
OR NEW.prev_line_hash IS NOT NULL
|
|
||||||
OR NEW.this_line_number IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
SELECT RAISE(ABORT, 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE')
|
|
||||||
WHERE NOT (NEW.msg_type IN (0, 1, 3, 4));
|
|
||||||
|
|
||||||
SELECT RAISE(ABORT, 'LINE_ERR_PARTIAL_FIELDS')
|
|
||||||
WHERE NEW.line_code IS NULL
|
|
||||||
OR NEW.prev_line_number IS NULL
|
|
||||||
OR NEW.prev_line_hash IS NULL
|
|
||||||
OR NEW.this_line_number IS NULL;
|
|
||||||
|
|
||||||
SELECT RAISE(ABORT, 'LINE_ERR_NO_PREV')
|
|
||||||
WHERE NOT EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM blocks p
|
|
||||||
WHERE p.bch_name = NEW.bch_name
|
|
||||||
AND p.block_number = NEW.prev_line_number
|
|
||||||
LIMIT 1
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT RAISE(ABORT, 'LINE_ERR_PREV_HASH_MISMATCH')
|
|
||||||
WHERE NOT EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM blocks p
|
|
||||||
WHERE p.bch_name = NEW.bch_name
|
|
||||||
AND p.block_number = NEW.prev_line_number
|
|
||||||
AND p.block_hash = NEW.prev_line_hash
|
|
||||||
LIMIT 1
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT RAISE(ABORT, 'LINE_ERR_LINE_CODE_MISMATCH')
|
|
||||||
WHERE NEW.prev_line_number <> NEW.line_code
|
|
||||||
AND NOT EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM blocks p
|
|
||||||
WHERE p.bch_name = NEW.bch_name
|
|
||||||
AND p.block_number = NEW.prev_line_number
|
|
||||||
AND p.line_code = NEW.line_code
|
|
||||||
LIMIT 1
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT RAISE(ABORT, 'LINE_ERR_FIRST_STEP_BAD_THIS')
|
|
||||||
WHERE NEW.prev_line_number = NEW.line_code
|
|
||||||
AND NEW.this_line_number <> (CASE WHEN NEW.msg_type = 1 THEN 0 ELSE 1 END);
|
|
||||||
|
|
||||||
SELECT RAISE(ABORT, 'LINE_ERR_THIS_LINE_BAD_STEP')
|
|
||||||
WHERE NEW.prev_line_number <> NEW.line_code
|
|
||||||
AND NOT EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM blocks p
|
|
||||||
WHERE p.bch_name = NEW.bch_name
|
|
||||||
AND p.block_number = NEW.prev_line_number
|
|
||||||
AND p.this_line_number IS NOT NULL
|
|
||||||
AND (
|
|
||||||
(NEW.msg_type = 1 AND
|
|
||||||
(NEW.this_line_number = p.this_line_number OR NEW.this_line_number = p.this_line_number + 1)
|
|
||||||
)
|
|
||||||
OR
|
|
||||||
(NEW.msg_type IN (0,3,4) AND NEW.this_line_number = p.this_line_number + 1)
|
|
||||||
)
|
|
||||||
LIMIT 1
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void createConnectionStateTrigger(Statement st) throws SQLException {
|
|
||||||
int FRIEND = (int) DatabaseInitializer.CONNECTION_FRIEND;
|
|
||||||
int CONTACT = (int) DatabaseInitializer.CONNECTION_CONTACT;
|
|
||||||
int FOLLOW = (int) DatabaseInitializer.CONNECTION_FOLLOW;
|
|
||||||
int SPOUSE = (int) DatabaseInitializer.CONNECTION_SPOUSE;
|
|
||||||
int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT;
|
|
||||||
int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD;
|
|
||||||
int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING;
|
|
||||||
int KNOWN = (int) DatabaseInitializer.CONNECTION_KNOWN_PERSON;
|
|
||||||
int SHINE_CONF = (int) DatabaseInitializer.CONNECTION_SHINE_CONFIRMED;
|
|
||||||
int SHINE_SEEN = (int) DatabaseInitializer.CONNECTION_SHINE_SEEN;
|
|
||||||
|
|
||||||
int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND;
|
|
||||||
int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT;
|
|
||||||
int UNFOLLOW = (int) DatabaseInitializer.CONNECTION_UNFOLLOW;
|
|
||||||
int UNSPOUSE = (int) DatabaseInitializer.CONNECTION_UNSPOUSE;
|
|
||||||
int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT;
|
|
||||||
int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD;
|
|
||||||
int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING;
|
|
||||||
int UNKNOWN = (int) DatabaseInitializer.CONNECTION_UNKNOWN_PERSON;
|
|
||||||
int SHINE_UNCONF = (int) DatabaseInitializer.CONNECTION_SHINE_UNCONFIRMED;
|
|
||||||
int SHINE_UNSEEN = (int) DatabaseInitializer.CONNECTION_SHINE_UNSEEN;
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai
|
|
||||||
AFTER INSERT ON blocks
|
|
||||||
WHEN NEW.msg_type = 3
|
|
||||||
BEGIN
|
|
||||||
-- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING/KNOWN_PERSON/SHINE_*:
|
|
||||||
-- 1) если записи нет — создаём
|
|
||||||
INSERT OR IGNORE INTO connections_state (
|
|
||||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
NEW.login,
|
|
||||||
NEW.msg_sub_type,
|
|
||||||
COALESCE(
|
|
||||||
NEW.to_login,
|
|
||||||
(
|
|
||||||
SELECT su.login
|
|
||||||
FROM solana_users su
|
|
||||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
CASE
|
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
|
||||||
AND length(NEW.to_bch_name) > 4
|
|
||||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
|
||||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
|
||||||
ELSE NULL
|
|
||||||
END
|
|
||||||
),
|
|
||||||
NEW.to_bch_name,
|
|
||||||
NEW.to_block_number,
|
|
||||||
NEW.to_block_hash
|
|
||||||
WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
|
|
||||||
AND COALESCE(
|
|
||||||
NEW.to_login,
|
|
||||||
(
|
|
||||||
SELECT su.login
|
|
||||||
FROM solana_users su
|
|
||||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
CASE
|
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
|
||||||
AND length(NEW.to_bch_name) > 4
|
|
||||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
|
||||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
|
||||||
ELSE NULL
|
|
||||||
END
|
|
||||||
) IS NOT NULL
|
|
||||||
AND NEW.to_bch_name IS NOT NULL;
|
|
||||||
|
|
||||||
-- 2) если запись есть — обновляем актуальные to_*
|
|
||||||
UPDATE connections_state
|
|
||||||
SET
|
|
||||||
to_bch_name = NEW.to_bch_name,
|
|
||||||
to_block_number = NEW.to_block_number,
|
|
||||||
to_block_hash = NEW.to_block_hash
|
|
||||||
WHERE login = NEW.login
|
|
||||||
AND rel_type = NEW.msg_sub_type
|
|
||||||
AND to_login = COALESCE(
|
|
||||||
NEW.to_login,
|
|
||||||
(
|
|
||||||
SELECT su.login
|
|
||||||
FROM solana_users su
|
|
||||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
CASE
|
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
|
||||||
AND length(NEW.to_bch_name) > 4
|
|
||||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
|
||||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
|
||||||
ELSE NULL
|
|
||||||
END
|
|
||||||
)
|
|
||||||
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
|
|
||||||
AND COALESCE(
|
|
||||||
NEW.to_login,
|
|
||||||
(
|
|
||||||
SELECT su.login
|
|
||||||
FROM solana_users su
|
|
||||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
CASE
|
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
|
||||||
AND length(NEW.to_bch_name) > 4
|
|
||||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
|
||||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
|
||||||
ELSE NULL
|
|
||||||
END
|
|
||||||
) IS NOT NULL
|
|
||||||
AND NEW.to_bch_name IS NOT NULL;
|
|
||||||
|
|
||||||
-- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING/UNKNOWN_PERSON/SHINE_UN*:
|
|
||||||
-- удаляем соответствующее "позитивное" состояние
|
|
||||||
DELETE FROM connections_state
|
|
||||||
WHERE login = NEW.login
|
|
||||||
AND to_login = COALESCE(
|
|
||||||
NEW.to_login,
|
|
||||||
(
|
|
||||||
SELECT su.login
|
|
||||||
FROM solana_users su
|
|
||||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
CASE
|
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
|
||||||
AND length(NEW.to_bch_name) > 4
|
|
||||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
|
||||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
|
||||||
ELSE NULL
|
|
||||||
END
|
|
||||||
)
|
|
||||||
AND rel_type = CASE NEW.msg_sub_type
|
|
||||||
WHEN %d THEN %d
|
|
||||||
WHEN %d THEN %d
|
|
||||||
WHEN %d THEN %d
|
|
||||||
WHEN %d THEN %d
|
|
||||||
WHEN %d THEN %d
|
|
||||||
WHEN %d THEN %d
|
|
||||||
WHEN %d THEN %d
|
|
||||||
WHEN %d THEN %d
|
|
||||||
WHEN %d THEN %d
|
|
||||||
WHEN %d THEN %d
|
|
||||||
ELSE rel_type
|
|
||||||
END
|
|
||||||
AND COALESCE(
|
|
||||||
NEW.to_login,
|
|
||||||
(
|
|
||||||
SELECT su.login
|
|
||||||
FROM solana_users su
|
|
||||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
CASE
|
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
|
||||||
AND length(NEW.to_bch_name) > 4
|
|
||||||
AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-'
|
|
||||||
THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4)
|
|
||||||
ELSE NULL
|
|
||||||
END
|
|
||||||
) IS NOT NULL
|
|
||||||
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d);
|
|
||||||
END;
|
|
||||||
""".formatted(
|
|
||||||
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
|
|
||||||
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
|
|
||||||
|
|
||||||
UNFRIEND, FRIEND,
|
|
||||||
UNCONTACT, CONTACT,
|
|
||||||
UNFOLLOW, FOLLOW,
|
|
||||||
UNSPOUSE, SPOUSE,
|
|
||||||
UNPARENT, PARENT,
|
|
||||||
UNCHILD, CHILD,
|
|
||||||
UNSIBLING, SIBLING,
|
|
||||||
UNKNOWN, KNOWN,
|
|
||||||
SHINE_UNCONF, SHINE_CONF,
|
|
||||||
SHINE_UNSEEN, SHINE_SEEN,
|
|
||||||
|
|
||||||
UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING, UNKNOWN, SHINE_UNCONF, SHINE_UNSEEN
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void createMessageStatsLikeTrigger(Statement st) throws SQLException {
|
|
||||||
int LIKE = (int) DatabaseInitializer.REACTION_LIKE;
|
|
||||||
int UNLIKE = (int) DatabaseInitializer.REACTION_UNLIKE;
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai
|
|
||||||
AFTER INSERT ON blocks
|
|
||||||
WHEN NEW.msg_type = 2 AND NEW.msg_sub_type IN (%d, %d)
|
|
||||||
BEGIN
|
|
||||||
-- ensure target stats row exists
|
|
||||||
INSERT OR IGNORE INTO message_stats (
|
|
||||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
|
||||||
likes_count, replies_count, edits_count
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
|
||||||
0, 0, 0
|
|
||||||
WHERE NEW.to_login IS NOT NULL
|
|
||||||
AND NEW.to_bch_name IS NOT NULL
|
|
||||||
AND NEW.to_block_number IS NOT NULL
|
|
||||||
AND NEW.to_block_hash IS NOT NULL;
|
|
||||||
|
|
||||||
-- apply delta by state transition (none/unlike->like = +1, like->unlike = -1)
|
|
||||||
UPDATE message_stats
|
|
||||||
SET likes_count = MAX(
|
|
||||||
0,
|
|
||||||
likes_count + (
|
|
||||||
CASE
|
|
||||||
WHEN NEW.msg_sub_type = %d
|
|
||||||
AND COALESCE((
|
|
||||||
SELECT b.msg_sub_type
|
|
||||||
FROM blocks b
|
|
||||||
WHERE b.login = NEW.login
|
|
||||||
AND b.bch_name = NEW.bch_name
|
|
||||||
AND b.msg_type = 2
|
|
||||||
AND b.to_login = NEW.to_login
|
|
||||||
AND b.to_bch_name = NEW.to_bch_name
|
|
||||||
AND b.to_block_number = NEW.to_block_number
|
|
||||||
AND b.to_block_hash = NEW.to_block_hash
|
|
||||||
AND b.block_number < NEW.block_number
|
|
||||||
ORDER BY b.block_number DESC
|
|
||||||
LIMIT 1
|
|
||||||
), -1) <> %d
|
|
||||||
THEN 1
|
|
||||||
WHEN NEW.msg_sub_type = %d
|
|
||||||
AND COALESCE((
|
|
||||||
SELECT b.msg_sub_type
|
|
||||||
FROM blocks b
|
|
||||||
WHERE b.login = NEW.login
|
|
||||||
AND b.bch_name = NEW.bch_name
|
|
||||||
AND b.msg_type = 2
|
|
||||||
AND b.to_login = NEW.to_login
|
|
||||||
AND b.to_bch_name = NEW.to_bch_name
|
|
||||||
AND b.to_block_number = NEW.to_block_number
|
|
||||||
AND b.to_block_hash = NEW.to_block_hash
|
|
||||||
AND b.block_number < NEW.block_number
|
|
||||||
ORDER BY b.block_number DESC
|
|
||||||
LIMIT 1
|
|
||||||
), -1) = %d
|
|
||||||
THEN -1
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WHERE to_login = NEW.to_login
|
|
||||||
AND to_bch_name = NEW.to_bch_name
|
|
||||||
AND to_block_number = NEW.to_block_number
|
|
||||||
AND to_block_hash = NEW.to_block_hash
|
|
||||||
AND NEW.to_login IS NOT NULL
|
|
||||||
AND NEW.to_bch_name IS NOT NULL
|
|
||||||
AND NEW.to_block_number IS NOT NULL
|
|
||||||
AND NEW.to_block_hash IS NOT NULL;
|
|
||||||
|
|
||||||
-- persist latest actor->target reaction state
|
|
||||||
INSERT OR IGNORE INTO reactions_state (
|
|
||||||
from_login, from_bch_name, reaction_type,
|
|
||||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
|
||||||
last_sub_type
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
NEW.login, NEW.bch_name, %d,
|
|
||||||
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
|
||||||
NEW.msg_sub_type
|
|
||||||
WHERE NEW.to_login IS NOT NULL
|
|
||||||
AND NEW.to_bch_name IS NOT NULL
|
|
||||||
AND NEW.to_block_number IS NOT NULL
|
|
||||||
AND NEW.to_block_hash IS NOT NULL;
|
|
||||||
|
|
||||||
UPDATE reactions_state
|
|
||||||
SET last_sub_type = NEW.msg_sub_type
|
|
||||||
WHERE from_login = NEW.login
|
|
||||||
AND from_bch_name = NEW.bch_name
|
|
||||||
AND reaction_type = %d
|
|
||||||
AND to_login = NEW.to_login
|
|
||||||
AND to_bch_name = NEW.to_bch_name
|
|
||||||
AND to_block_number = NEW.to_block_number
|
|
||||||
AND to_block_hash = NEW.to_block_hash
|
|
||||||
AND NEW.to_login IS NOT NULL
|
|
||||||
AND NEW.to_bch_name IS NOT NULL
|
|
||||||
AND NEW.to_block_number IS NOT NULL
|
|
||||||
AND NEW.to_block_hash IS NOT NULL;
|
|
||||||
END;
|
|
||||||
""".formatted(
|
|
||||||
LIKE, UNLIKE,
|
|
||||||
LIKE, LIKE,
|
|
||||||
UNLIKE, LIKE,
|
|
||||||
LIKE,
|
|
||||||
LIKE
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void createMessageStatsReplyTrigger(Statement st) throws SQLException {
|
|
||||||
int REPLY = (int) DatabaseInitializer.TEXT_REPLY;
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_reply_ai
|
|
||||||
AFTER INSERT ON blocks
|
|
||||||
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type = %d
|
|
||||||
BEGIN
|
|
||||||
INSERT OR IGNORE INTO message_stats (
|
|
||||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
|
||||||
likes_count, replies_count, edits_count
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
|
||||||
0, 0, 0
|
|
||||||
WHERE NEW.to_login IS NOT NULL
|
|
||||||
AND NEW.to_bch_name IS NOT NULL
|
|
||||||
AND NEW.to_block_number IS NOT NULL
|
|
||||||
AND NEW.to_block_hash IS NOT NULL;
|
|
||||||
|
|
||||||
UPDATE message_stats
|
|
||||||
SET replies_count = replies_count + 1
|
|
||||||
WHERE to_login = NEW.to_login
|
|
||||||
AND to_bch_name = NEW.to_bch_name
|
|
||||||
AND to_block_number = NEW.to_block_number
|
|
||||||
AND to_block_hash = NEW.to_block_hash
|
|
||||||
AND NEW.to_login IS NOT NULL
|
|
||||||
AND NEW.to_bch_name IS NOT NULL
|
|
||||||
AND NEW.to_block_number IS NOT NULL
|
|
||||||
AND NEW.to_block_hash IS NOT NULL;
|
|
||||||
END;
|
|
||||||
""".formatted(REPLY));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void createEditApplyTrigger(Statement st) throws SQLException {
|
|
||||||
int EDIT_POST = (int) DatabaseInitializer.TEXT_EDIT_POST;
|
|
||||||
int EDIT_REPLY = (int) DatabaseInitializer.TEXT_EDIT_REPLY;
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_edit_apply_ai
|
|
||||||
AFTER INSERT ON blocks
|
|
||||||
WHEN NEW.msg_type = 1 AND NEW.msg_sub_type IN (%d, %d)
|
|
||||||
BEGIN
|
|
||||||
-- 1) помечаем исходный блок, что его "перекрыл" этот edit
|
|
||||||
UPDATE blocks
|
|
||||||
SET edited_by_block_number = NEW.block_number
|
|
||||||
WHERE login = NEW.login
|
|
||||||
AND bch_name = NEW.bch_name
|
|
||||||
AND block_number = NEW.to_block_number
|
|
||||||
AND NEW.to_block_number IS NOT NULL;
|
|
||||||
|
|
||||||
-- 2) создаём stats-строку если её не было
|
|
||||||
INSERT OR IGNORE INTO message_stats (
|
|
||||||
to_login, to_bch_name, to_block_number, to_block_hash,
|
|
||||||
likes_count, replies_count, edits_count
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash,
|
|
||||||
0, 0, 0
|
|
||||||
WHERE NEW.to_login IS NOT NULL
|
|
||||||
AND NEW.to_bch_name IS NOT NULL
|
|
||||||
AND NEW.to_block_number IS NOT NULL
|
|
||||||
AND NEW.to_block_hash IS NOT NULL;
|
|
||||||
|
|
||||||
-- 3) +1 edit
|
|
||||||
UPDATE message_stats
|
|
||||||
SET edits_count = edits_count + 1
|
|
||||||
WHERE to_login = NEW.to_login
|
|
||||||
AND to_bch_name = NEW.to_bch_name
|
|
||||||
AND to_block_number = NEW.to_block_number
|
|
||||||
AND to_block_hash = NEW.to_block_hash
|
|
||||||
AND NEW.to_login IS NOT NULL
|
|
||||||
AND NEW.to_bch_name IS NOT NULL
|
|
||||||
AND NEW.to_block_number IS NOT NULL
|
|
||||||
AND NEW.to_block_hash IS NOT NULL;
|
|
||||||
END;
|
|
||||||
""".formatted(EDIT_POST, EDIT_REPLY));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package shine.db;
|
||||||
|
|
||||||
|
import shine.db.connection.DbProvider;
|
||||||
|
import shine.db.dao.DmDialogStateDAO;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нейтральная точка входа в runtime БД сервера.
|
||||||
|
*
|
||||||
|
* Runtime-сервер теперь поддерживает только PostgreSQL.
|
||||||
|
*/
|
||||||
|
public final class DbController implements DbProvider {
|
||||||
|
|
||||||
|
private static volatile DbController instance;
|
||||||
|
|
||||||
|
private final PostgresDbController delegate;
|
||||||
|
private volatile boolean dmDialogStateBootstrapped;
|
||||||
|
|
||||||
|
private DbController() {
|
||||||
|
this.delegate = PostgresDbController.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DbController getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (DbController.class) {
|
||||||
|
if (instance == null) {
|
||||||
|
DbController created = new DbController();
|
||||||
|
instance = created;
|
||||||
|
created.bootstrapDmDialogStateIfNeeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Connection getConnection() throws SQLException {
|
||||||
|
return delegate.getConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
delegate.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bootstrapDmDialogStateIfNeeded() {
|
||||||
|
if (dmDialogStateBootstrapped) return;
|
||||||
|
synchronized (this) {
|
||||||
|
if (dmDialogStateBootstrapped) return;
|
||||||
|
try (Connection connection = delegate.getConnection()) {
|
||||||
|
DmDialogStateDAO.getInstance().bootstrapIfEmpty(connection);
|
||||||
|
dmDialogStateBootstrapped = true;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
instance = null;
|
||||||
|
throw new RuntimeException("DM dialog state bootstrap failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package shine.db;
|
||||||
|
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нормализация ключей из разных storage-слоёв к каноническому Base64(32).
|
||||||
|
*/
|
||||||
|
public final class KeyEncodingUtil {
|
||||||
|
|
||||||
|
private static final String BASE58_ALPHABET =
|
||||||
|
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||||
|
|
||||||
|
private static final int[] BASE58_INDEXES = new int[128];
|
||||||
|
|
||||||
|
static {
|
||||||
|
for (int i = 0; i < BASE58_INDEXES.length; i++) {
|
||||||
|
BASE58_INDEXES[i] = -1;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < BASE58_ALPHABET.length(); i++) {
|
||||||
|
BASE58_INDEXES[BASE58_ALPHABET.charAt(i)] = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private KeyEncodingUtil() {}
|
||||||
|
|
||||||
|
public static String normalizeKeyToBase64_32(String rawKey) {
|
||||||
|
if (rawKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String value = rawKey.trim();
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] asBase64 = tryDecodeBase64_32(value);
|
||||||
|
if (asBase64 != null) {
|
||||||
|
return Base64.getEncoder().encodeToString(asBase64);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] asBase58 = tryDecodeBase58_32(value);
|
||||||
|
if (asBase58 != null) {
|
||||||
|
return Base64.getEncoder().encodeToString(asBase58);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] tryDecodeBase64_32(String value) {
|
||||||
|
try {
|
||||||
|
byte[] decoded = Base64.getDecoder().decode(value);
|
||||||
|
return decoded.length == 32 ? decoded : null;
|
||||||
|
} catch (IllegalArgumentException ignore) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] tryDecodeBase58_32(String value) {
|
||||||
|
byte[] decoded = decodeBase58(value);
|
||||||
|
return decoded.length == 32 ? decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] decodeBase58(String input) {
|
||||||
|
if (input.isEmpty()) {
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] input58 = new byte[input.length()];
|
||||||
|
for (int i = 0; i < input.length(); i++) {
|
||||||
|
char c = input.charAt(i);
|
||||||
|
if (c >= BASE58_INDEXES.length || BASE58_INDEXES[c] < 0) {
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
input58[i] = (byte) BASE58_INDEXES[c];
|
||||||
|
}
|
||||||
|
|
||||||
|
int zeros = 0;
|
||||||
|
while (zeros < input58.length && input58[zeros] == 0) {
|
||||||
|
zeros++;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] decoded = new byte[input.length()];
|
||||||
|
int outputStart = decoded.length;
|
||||||
|
int inputStart = zeros;
|
||||||
|
while (inputStart < input58.length) {
|
||||||
|
int mod = divmod256(input58, inputStart);
|
||||||
|
if (input58[inputStart] == 0) {
|
||||||
|
inputStart++;
|
||||||
|
}
|
||||||
|
decoded[--outputStart] = (byte) mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (outputStart < decoded.length && decoded[outputStart] == 0) {
|
||||||
|
outputStart++;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] result = new byte[decoded.length - outputStart + zeros];
|
||||||
|
for (int i = 0; i < zeros; i++) {
|
||||||
|
result[i] = 0;
|
||||||
|
}
|
||||||
|
System.arraycopy(decoded, outputStart, result, zeros, decoded.length - outputStart);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int divmod256(byte[] number58, int startAt) {
|
||||||
|
int remainder = 0;
|
||||||
|
for (int i = startAt; i < number58.length; i++) {
|
||||||
|
int digit58 = number58[i] & 0xFF;
|
||||||
|
int temp = remainder * 58 + digit58;
|
||||||
|
number58[i] = (byte) (temp / 256);
|
||||||
|
remainder = temp % 256;
|
||||||
|
}
|
||||||
|
return remainder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,8 +30,23 @@ public final class MsgSubType {
|
|||||||
/** EDIT_REPLY — редактирование исходного ответа. */
|
/** EDIT_REPLY — редактирование исходного ответа. */
|
||||||
public static final short TEXT_EDIT_REPLY = 21;
|
public static final short TEXT_EDIT_REPLY = 21;
|
||||||
|
|
||||||
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */
|
/** RATING — target-based отзыв на конкретный блок. */
|
||||||
public static final short TEXT_REPOST = 30;
|
public static final short TEXT_RATING = 30;
|
||||||
|
|
||||||
|
/** REPOST — отложенная будущая заготовка репоста сообщения в линии канала. */
|
||||||
|
public static final short TEXT_REPOST = 50;
|
||||||
|
|
||||||
|
/** CHANNEL_META — скрытый технический снимок профиля канала. */
|
||||||
|
public static final short TEXT_CHANNEL_META = 90;
|
||||||
|
|
||||||
|
/** ENTRYPOINT — входная страница канала (line-based). */
|
||||||
|
public static final short TEXT_ENTRYPOINT = 100;
|
||||||
|
/** EXERCISE — упражнение/комплекс (line-based). */
|
||||||
|
public static final short TEXT_EXERCISE = 110;
|
||||||
|
/** SERVICE — услуга/процедура (line-based). */
|
||||||
|
public static final short TEXT_SERVICE = 120;
|
||||||
|
/** COURSE — курс (line-based). */
|
||||||
|
public static final short TEXT_COURSE = 130;
|
||||||
|
|
||||||
/* ===================== REACTION (msg_type=2) ===================== */
|
/* ===================== REACTION (msg_type=2) ===================== */
|
||||||
|
|
||||||
@@ -43,23 +58,23 @@ public final class MsgSubType {
|
|||||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||||
/**
|
/**
|
||||||
* Совпадает с ConnectionBody:
|
* Совпадает с ConnectionBody:
|
||||||
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
* SET: CLOSE_FRIEND=10, FRIEND=14, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
||||||
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
|
* KNOWN_PERSON(legacy)=60, SHINE_CONFIRMED=70, SHINE_SEEN=74, OFFICIAL_ACCOUNT_CONFIRMED=80
|
||||||
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
* UNSET: UNCLOSE_FRIEND=11, UNFRIEND=15, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
||||||
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
|
* UNKNOWN_PERSON(legacy)=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75, OFFICIAL_ACCOUNT_UNCONFIRMED=81
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Добавить в близкие друзья (close friend). */
|
/** Добавить в близкие друзья (close friend). */
|
||||||
public static final short CONNECTION_FRIEND = 10;
|
public static final short CONNECTION_CLOSE_FRIEND = 10;
|
||||||
|
|
||||||
/** Удалить из близких друзей (close friend). */
|
/** Удалить из близких друзей (close friend). */
|
||||||
public static final short CONNECTION_UNFRIEND = 11;
|
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
|
||||||
|
|
||||||
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
|
/** Добавить в друзья. */
|
||||||
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
|
public static final short CONNECTION_FRIEND = 14;
|
||||||
|
|
||||||
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
|
/** Удалить из друзей. */
|
||||||
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
|
public static final short CONNECTION_UNFRIEND = 15;
|
||||||
|
|
||||||
/** Добавить в контакты. */
|
/** Добавить в контакты. */
|
||||||
public static final short CONNECTION_CONTACT = 20;
|
public static final short CONNECTION_CONTACT = 20;
|
||||||
@@ -67,12 +82,14 @@ public final class MsgSubType {
|
|||||||
/** Удалить из контактов. */
|
/** Удалить из контактов. */
|
||||||
public static final short CONNECTION_UNCONTACT = 21;
|
public static final short CONNECTION_UNCONTACT = 21;
|
||||||
|
|
||||||
|
// FOLLOW на публичный канал используется. Старый FOLLOW пользователя считается legacy. DO NOT REMOVE.
|
||||||
/** Подписаться (follow). */
|
/** Подписаться (follow). */
|
||||||
public static final short CONNECTION_FOLLOW = 30;
|
public static final short CONNECTION_FOLLOW = 30;
|
||||||
|
|
||||||
/** Отписаться (unfollow). */
|
/** Отписаться (unfollow). */
|
||||||
public static final short CONNECTION_UNFOLLOW = 31;
|
public static final short CONNECTION_UNFOLLOW = 31;
|
||||||
|
|
||||||
|
// Родственные связи пока не используются в UI. Сохраняем коды. DO NOT REMOVE.
|
||||||
/** Добавить связь "жена/муж". */
|
/** Добавить связь "жена/муж". */
|
||||||
public static final short CONNECTION_SPOUSE = 40;
|
public static final short CONNECTION_SPOUSE = 40;
|
||||||
|
|
||||||
@@ -97,10 +114,8 @@ public final class MsgSubType {
|
|||||||
/** Удалить связь "брат/сестра". */
|
/** Удалить связь "брат/сестра". */
|
||||||
public static final short CONNECTION_UNSIBLING = 55;
|
public static final short CONNECTION_UNSIBLING = 55;
|
||||||
|
|
||||||
/** Просто знаю этого человека. */
|
// Legacy 60/61: принимаем старые данные, но новый UI их не использует. DO NOT REMOVE.
|
||||||
public static final short CONNECTION_KNOWN_PERSON = 60;
|
public static final short CONNECTION_KNOWN_PERSON = 60;
|
||||||
|
|
||||||
/** Не знаю этого человека. */
|
|
||||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
||||||
|
|
||||||
/** Точно уверен, что сияющий. */
|
/** Точно уверен, что сияющий. */
|
||||||
@@ -109,17 +124,36 @@ public final class MsgSubType {
|
|||||||
/** Не подтверждаю, что сияющий. */
|
/** Не подтверждаю, что сияющий. */
|
||||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
||||||
|
|
||||||
|
// Пока не используется в UI. Сохраняем для совместимости. DO NOT REMOVE.
|
||||||
/** Мало знаком, но видел сияющим. */
|
/** Мало знаком, но видел сияющим. */
|
||||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
public static final short CONNECTION_SHINE_SEEN = 74;
|
||||||
|
|
||||||
/** Не отмечаю, что видел сияющим. */
|
/** Не отмечаю, что видел сияющим. */
|
||||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
||||||
|
|
||||||
|
/** Подтверждение официального аккаунта человека. */
|
||||||
|
public static final short CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED = 80;
|
||||||
|
|
||||||
|
/** Снятие подтверждения официального аккаунта человека. */
|
||||||
|
public static final short CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED = 81;
|
||||||
|
|
||||||
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||||
|
|
||||||
/** Параметр профиля key/value (обе строки). */
|
/** Параметр профиля key/value (обе строки). */
|
||||||
public static final short USER_PARAM_TEXT_TEXT = 1;
|
public static final short USER_PARAM_TEXT_TEXT = 1;
|
||||||
|
|
||||||
|
/* ===================== STATUS_ACTION (msg_type=5) ===================== */
|
||||||
|
|
||||||
|
public static final short STATUS_DONE_ONCE = 10;
|
||||||
|
public static final short STATUS_LEARNED = 20;
|
||||||
|
public static final short STATUS_SERVICE_PASSED = 30;
|
||||||
|
public static final short STATUS_CONFIRMED = 100;
|
||||||
|
public static final short STATUS_INTERESTED = 110;
|
||||||
|
public static final short STATUS_STARTED = 120;
|
||||||
|
public static final short STATUS_IN_STUDY = 130;
|
||||||
|
public static final short STATUS_ABANDONED = 140;
|
||||||
|
public static final short STATUS_COMPLETED = 150;
|
||||||
|
|
||||||
/* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */
|
/* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */
|
||||||
// Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения,
|
// Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения,
|
||||||
// не трогая уже занятые коды.
|
// не трогая уже занятые коды.
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package shine.db;
|
||||||
|
|
||||||
|
import shine.db.connection.DriverManagerDbProvider;
|
||||||
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
public final class PostgresDbController {
|
||||||
|
|
||||||
|
private static volatile PostgresDbController instance;
|
||||||
|
|
||||||
|
private final DriverManagerDbProvider delegate;
|
||||||
|
|
||||||
|
private PostgresDbController() {
|
||||||
|
AppConfig config = AppConfig.getInstance();
|
||||||
|
String jdbcUrl = trimToNull(config.getParam("db.url"));
|
||||||
|
String dbUser = trimToNull(config.getParam("db.user"));
|
||||||
|
String dbPassword = trimToNull(config.getParam("db.password"));
|
||||||
|
|
||||||
|
if (jdbcUrl == null) {
|
||||||
|
throw new IllegalStateException("Config param 'db.url' is required and must point to PostgreSQL");
|
||||||
|
}
|
||||||
|
if (!jdbcUrl.startsWith("jdbc:postgresql:")) {
|
||||||
|
throw new IllegalStateException("Only PostgreSQL runtime is supported. Unsupported db.url=" + jdbcUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DatabaseInitializer.ensurePostgresSchemaInitialized(jdbcUrl, dbUser, dbPassword);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("PostgreSQL schema auto-init failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.delegate = new DriverManagerDbProvider(jdbcUrl, dbUser, dbPassword, connection -> {
|
||||||
|
connection.setAutoCommit(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PostgresDbController getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (PostgresDbController.class) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new PostgresDbController();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Connection getConnection() throws SQLException {
|
||||||
|
return delegate.getConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void close() {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimToNull(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,846 +0,0 @@
|
|||||||
package shine.db;
|
|
||||||
|
|
||||||
import utils.config.AppConfig;
|
|
||||||
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.sql.Statement;
|
|
||||||
|
|
||||||
public final class SqliteDbController {
|
|
||||||
|
|
||||||
private static volatile SqliteDbController instance;
|
|
||||||
private static final int LATEST_SCHEMA_VERSION = 12;
|
|
||||||
|
|
||||||
private final String jdbcUrl;
|
|
||||||
|
|
||||||
private SqliteDbController() {
|
|
||||||
try {
|
|
||||||
Class.forName("org.sqlite.JDBC");
|
|
||||||
} catch (ClassNotFoundException e) {
|
|
||||||
throw new RuntimeException("SQLite JDBC driver not found", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
String dbPath = AppConfig.getInstance().getParam("db.path");
|
|
||||||
if (dbPath == null || dbPath.isBlank()) {
|
|
||||||
throw new RuntimeException("Config param 'db.path' is not set in application.properties");
|
|
||||||
}
|
|
||||||
|
|
||||||
Path dbFile = Paths.get(dbPath);
|
|
||||||
|
|
||||||
if (!Files.exists(dbFile)) {
|
|
||||||
System.out.println("[DB] Файл БД не найден: " + dbFile.toAbsolutePath());
|
|
||||||
System.out.println("[DB] Создаём новую БД с помощью DatabaseInitializer...");
|
|
||||||
DatabaseInitializer.createNewDB(new String[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.jdbcUrl = "jdbc:sqlite:" + dbPath;
|
|
||||||
ensureSchemaMigrations();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static SqliteDbController getInstance() {
|
|
||||||
if (instance == null) {
|
|
||||||
synchronized (SqliteDbController.class) {
|
|
||||||
if (instance == null) {
|
|
||||||
instance = new SqliteDbController();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Connection getConnection() throws SQLException {
|
|
||||||
Connection conn = DriverManager.getConnection(jdbcUrl);
|
|
||||||
conn.setAutoCommit(true);
|
|
||||||
|
|
||||||
try (Statement st = conn.createStatement()) {
|
|
||||||
st.execute("PRAGMA foreign_keys = ON");
|
|
||||||
st.execute("PRAGMA journal_mode = WAL");
|
|
||||||
st.execute("PRAGMA synchronous = NORMAL");
|
|
||||||
st.execute("PRAGMA busy_timeout = 5000");
|
|
||||||
}
|
|
||||||
|
|
||||||
return conn;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void close() {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ensureSchemaMigrations() {
|
|
||||||
int currentVersion = getCurrentSchemaVersion();
|
|
||||||
|
|
||||||
while (currentVersion < LATEST_SCHEMA_VERSION) {
|
|
||||||
int nextVersion = currentVersion + 1;
|
|
||||||
applyMigration(nextVersion);
|
|
||||||
currentVersion = nextVersion;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void applyMigration(int targetVersion) {
|
|
||||||
switch (targetVersion) {
|
|
||||||
case 1 -> migrateToV1();
|
|
||||||
case 2 -> migrateToV2();
|
|
||||||
case 3 -> migrateToV3();
|
|
||||||
case 4 -> migrateToV4();
|
|
||||||
case 5 -> migrateToV5();
|
|
||||||
case 6 -> migrateToV6();
|
|
||||||
case 7 -> migrateToV7();
|
|
||||||
case 8 -> migrateToV8();
|
|
||||||
case 9 -> migrateToV9();
|
|
||||||
case 10 -> migrateToV10();
|
|
||||||
case 11 -> migrateToV11();
|
|
||||||
case 12 -> migrateToV12();
|
|
||||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateToV1() {
|
|
||||||
try {
|
|
||||||
DatabaseInitializer.ensureSchemaV1Structure(jdbcUrl);
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("DB migration to v1 failed (base schema)", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
|
||||||
Statement st = c.createStatement()) {
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
try {
|
|
||||||
st.execute("PRAGMA foreign_keys = OFF");
|
|
||||||
|
|
||||||
if (tableExists(c, "connections_state") && needsConnectionsStateUpgrade(c)) {
|
|
||||||
rebuildConnectionsStateTable(st);
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureChannelNamesDescriptionColumn(c, st);
|
|
||||||
ensureChannelNamesTypeColumns(c, st);
|
|
||||||
ensureSignedMessageReceiptUniq(c, st);
|
|
||||||
DatabaseTriggersInstaller.createAllTriggers(st);
|
|
||||||
setSchemaVersion(c, 1);
|
|
||||||
|
|
||||||
st.execute("PRAGMA foreign_keys = ON");
|
|
||||||
c.commit();
|
|
||||||
} catch (Exception e) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw new RuntimeException("DB migration to v1 failed", e);
|
|
||||||
} finally {
|
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("DB migration to v1 failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateToV2() {
|
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
|
||||||
Statement st = c.createStatement()) {
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
try {
|
|
||||||
st.execute("PRAGMA foreign_keys = OFF");
|
|
||||||
st.executeUpdate("DROP TABLE IF EXISTS message_views_state");
|
|
||||||
st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_target");
|
|
||||||
st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_viewer_channel");
|
|
||||||
setSchemaVersion(c, 2);
|
|
||||||
st.execute("PRAGMA foreign_keys = ON");
|
|
||||||
c.commit();
|
|
||||||
} catch (Exception e) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw new RuntimeException("DB migration to v2 failed", e);
|
|
||||||
} finally {
|
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("DB migration to v2 failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateToV3() {
|
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
|
||||||
Statement st = c.createStatement()) {
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
try {
|
|
||||||
ensureChat200StateTables(st);
|
|
||||||
setSchemaVersion(c, 3);
|
|
||||||
c.commit();
|
|
||||||
} catch (Exception e) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw new RuntimeException("DB migration to v3 failed", e);
|
|
||||||
} finally {
|
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("DB migration to v3 failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateToV4() {
|
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
|
||||||
Statement st = c.createStatement()) {
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
try {
|
|
||||||
ensureActiveSessionsSessionTypeColumn(c, st);
|
|
||||||
ensureActiveSessionsClientPlatformColumn(c, st);
|
|
||||||
setSchemaVersion(c, 4);
|
|
||||||
c.commit();
|
|
||||||
} catch (Exception e) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw new RuntimeException("DB migration to v4 failed", e);
|
|
||||||
} finally {
|
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("DB migration to v4 failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateToV5() {
|
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
|
||||||
Statement st = c.createStatement()) {
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
try {
|
|
||||||
ensureEspPairingTables(st);
|
|
||||||
setSchemaVersion(c, 5);
|
|
||||||
c.commit();
|
|
||||||
} catch (Exception e) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw new RuntimeException("DB migration to v5 failed", e);
|
|
||||||
} finally {
|
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("DB migration to v5 failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateToV6() {
|
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
|
||||||
Statement st = c.createStatement()) {
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
try {
|
|
||||||
ensureSignedMessagesRevisionColumn(c, st);
|
|
||||||
setSchemaVersion(c, 6);
|
|
||||||
c.commit();
|
|
||||||
} catch (Exception e) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw new RuntimeException("DB migration to v6 failed", e);
|
|
||||||
} finally {
|
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("DB migration to v6 failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateToV7() {
|
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
|
||||||
Statement st = c.createStatement()) {
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
try {
|
|
||||||
dropDmFileTables(st);
|
|
||||||
setSchemaVersion(c, 7);
|
|
||||||
c.commit();
|
|
||||||
} catch (Exception e) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw new RuntimeException("DB migration to v7 failed", e);
|
|
||||||
} finally {
|
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("DB migration to v7 failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateToV8() {
|
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
|
||||||
Statement st = c.createStatement()) {
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
try {
|
|
||||||
ensureTestFreeAvatarUploadsTable(st);
|
|
||||||
setSchemaVersion(c, 8);
|
|
||||||
c.commit();
|
|
||||||
} catch (Exception e) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw new RuntimeException("DB migration to v8 failed", e);
|
|
||||||
} finally {
|
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("DB migration to v8 failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateToV9() {
|
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
|
||||||
Statement st = c.createStatement()) {
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
try {
|
|
||||||
ensureSyncServersTable(st);
|
|
||||||
setSchemaVersion(c, 9);
|
|
||||||
c.commit();
|
|
||||||
} catch (Exception e) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw new RuntimeException("DB migration to v9 failed", e);
|
|
||||||
} finally {
|
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("DB migration to v9 failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateToV10() {
|
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
|
||||||
Statement st = c.createStatement()) {
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
try {
|
|
||||||
ensureSignedMessagesReencryptedColumn(c, st);
|
|
||||||
setSchemaVersion(c, 10);
|
|
||||||
c.commit();
|
|
||||||
} catch (Exception e) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw new RuntimeException("DB migration to v10 failed", e);
|
|
||||||
} finally {
|
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("DB migration to v10 failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateToV11() {
|
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
|
||||||
Statement st = c.createStatement()) {
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
try {
|
|
||||||
clearLegacySignedMessagesForDmV11(c, st);
|
|
||||||
setSchemaVersion(c, 11);
|
|
||||||
c.commit();
|
|
||||||
} catch (Exception e) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw new RuntimeException("DB migration to v11 failed", e);
|
|
||||||
} finally {
|
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("DB migration to v11 failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateToV12() {
|
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
|
||||||
Statement st = c.createStatement()) {
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
try {
|
|
||||||
ensureSignedMessagesReadAtColumn(c, st);
|
|
||||||
backfillSignedMessagesReadAt(st);
|
|
||||||
setSchemaVersion(c, 12);
|
|
||||||
c.commit();
|
|
||||||
} catch (Exception e) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw new RuntimeException("DB migration to v12 failed", e);
|
|
||||||
} finally {
|
|
||||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("DB migration to v12 failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
|
||||||
owner_login TEXT NOT NULL,
|
|
||||||
owner_bch_name TEXT NOT NULL,
|
|
||||||
channel_root_block_number INTEGER NOT NULL,
|
|
||||||
channel_root_block_hash BLOB NOT NULL,
|
|
||||||
channel_name TEXT NOT NULL,
|
|
||||||
channel_type_version INTEGER NOT NULL,
|
|
||||||
chat_title TEXT NOT NULL DEFAULT '',
|
|
||||||
updated_at_ms INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (owner_bch_name, channel_root_block_number)
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TABLE IF NOT EXISTS chat200_members_state (
|
|
||||||
owner_bch_name TEXT NOT NULL,
|
|
||||||
channel_root_block_number INTEGER NOT NULL,
|
|
||||||
member_login TEXT NOT NULL,
|
|
||||||
member_channel_name TEXT NOT NULL,
|
|
||||||
is_active INTEGER NOT NULL,
|
|
||||||
updated_at_ms INTEGER NOT NULL,
|
|
||||||
updated_by_block_number INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (
|
|
||||||
owner_bch_name,
|
|
||||||
channel_root_block_number,
|
|
||||||
member_login,
|
|
||||||
member_channel_name
|
|
||||||
)
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chat200_state_owner
|
|
||||||
ON chat200_state (owner_login, owner_bch_name);
|
|
||||||
""");
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chat200_members_owner
|
|
||||||
ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active);
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
private int getCurrentSchemaVersion() {
|
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl)) {
|
|
||||||
if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
try (var ps = c.prepareStatement("""
|
|
||||||
SELECT schema_version
|
|
||||||
FROM db_schema_version
|
|
||||||
WHERE id = 1
|
|
||||||
LIMIT 1
|
|
||||||
""");
|
|
||||||
ResultSet rs = ps.executeQuery()) {
|
|
||||||
if (rs.next()) {
|
|
||||||
return rs.getInt(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException("Cannot read DB schema version", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureActiveSessionsSessionTypeColumn(Connection c, Statement st) throws SQLException {
|
|
||||||
if (columnExists(c, "active_sessions", "session_type")) return;
|
|
||||||
st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN session_type INTEGER NOT NULL DEFAULT 1");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureActiveSessionsClientPlatformColumn(Connection c, Statement st) throws SQLException {
|
|
||||||
if (columnExists(c, "active_sessions", "client_platform")) return;
|
|
||||||
st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN client_platform TEXT NOT NULL DEFAULT ''");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureEspPairingTables(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TABLE IF NOT EXISTS esp_pairing_settings (
|
|
||||||
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
|
||||||
password_hash TEXT NOT NULL DEFAULT '',
|
|
||||||
ttl_seconds INTEGER NOT NULL DEFAULT 300,
|
|
||||||
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
first_failed_at_ms INTEGER NOT NULL DEFAULT 0,
|
|
||||||
blocked_until_ms INTEGER NOT NULL DEFAULT 0,
|
|
||||||
updated_at_ms INTEGER NOT NULL,
|
|
||||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TABLE IF NOT EXISTS esp_pairing_requests (
|
|
||||||
pairing_id TEXT NOT NULL PRIMARY KEY,
|
|
||||||
login TEXT NOT NULL,
|
|
||||||
requester_session_key TEXT NOT NULL,
|
|
||||||
requester_session_type INTEGER NOT NULL DEFAULT 1,
|
|
||||||
requester_client_platform TEXT NOT NULL DEFAULT '',
|
|
||||||
payload_type INTEGER NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
short_code TEXT NOT NULL,
|
|
||||||
fingerprint_b58 TEXT NOT NULL,
|
|
||||||
encrypted_payload TEXT,
|
|
||||||
reject_reason TEXT,
|
|
||||||
approved_by_session_id TEXT,
|
|
||||||
created_at_ms INTEGER NOT NULL,
|
|
||||||
expires_at_ms INTEGER NOT NULL,
|
|
||||||
updated_at_ms INTEGER NOT NULL,
|
|
||||||
delivered_to_homeserver INTEGER NOT NULL DEFAULT 0,
|
|
||||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status
|
|
||||||
ON esp_pairing_requests (login, status, expires_at_ms);
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureSignedMessagesRevisionColumn(Connection c, Statement st) throws SQLException {
|
|
||||||
if (!tableExists(c, "signed_messages_v2")) return;
|
|
||||||
if (!columnExists(c, "signed_messages_v2", "revision_time_ms")) {
|
|
||||||
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN revision_time_ms INTEGER NOT NULL DEFAULT 0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureSignedMessagesReencryptedColumn(Connection c, Statement st) throws SQLException {
|
|
||||||
if (!tableExists(c, "signed_messages_v2")) return;
|
|
||||||
if (!columnExists(c, "signed_messages_v2", "reencrypted_at_ms")) {
|
|
||||||
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN reencrypted_at_ms INTEGER NOT NULL DEFAULT 0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureSignedMessagesReadAtColumn(Connection c, Statement st) throws SQLException {
|
|
||||||
if (!tableExists(c, "signed_messages_v2")) return;
|
|
||||||
if (!columnExists(c, "signed_messages_v2", "read_at_ms")) {
|
|
||||||
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN read_at_ms INTEGER");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void backfillSignedMessagesReadAt(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("""
|
|
||||||
UPDATE signed_messages_v2 AS content
|
|
||||||
SET read_at_ms = (
|
|
||||||
SELECT MIN(receipt.time_ms)
|
|
||||||
FROM signed_messages_v2 AS receipt
|
|
||||||
WHERE receipt.message_type IN (3, 4)
|
|
||||||
AND receipt.receipt_ref_base_key = content.base_key
|
|
||||||
)
|
|
||||||
WHERE content.message_type IN (1, 2)
|
|
||||||
AND (content.read_at_ms IS NULL OR content.read_at_ms <= 0)
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM signed_messages_v2 AS receipt
|
|
||||||
WHERE receipt.message_type IN (3, 4)
|
|
||||||
AND receipt.receipt_ref_base_key = content.base_key
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Временная одноразовая миграция на переходе к SHiNE_DM v1:
|
|
||||||
* старые строки signed_messages_v2 больше не гарантированно совместимы
|
|
||||||
* с новым бинарным форматом и правилами tombstone/reencrypt.
|
|
||||||
* Позже эту миграцию нужно убрать, чтобы случайно не очищать рабочие DM.
|
|
||||||
*/
|
|
||||||
private static void clearLegacySignedMessagesForDmV11(Connection c, Statement st) throws SQLException {
|
|
||||||
if (tableExists(c, "signed_message_session_delivery")) {
|
|
||||||
st.executeUpdate("DELETE FROM signed_message_session_delivery");
|
|
||||||
}
|
|
||||||
if (tableExists(c, "signed_messages_v2")) {
|
|
||||||
st.executeUpdate("DELETE FROM signed_messages_v2");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void dropDmFileTables(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_login");
|
|
||||||
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message");
|
|
||||||
st.executeUpdate("DROP TABLE IF EXISTS dm_message_file_links");
|
|
||||||
st.executeUpdate("DROP TABLE IF EXISTS dm_files");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException {
|
|
||||||
try (Statement probe = c.createStatement();
|
|
||||||
ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) {
|
|
||||||
while (rs.next()) {
|
|
||||||
if (columnName.equalsIgnoreCase(rs.getString("name"))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void setSchemaVersion(Connection c, int version) throws SQLException {
|
|
||||||
try (var ps = c.prepareStatement("""
|
|
||||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
|
||||||
VALUES (1, ?, CAST(strftime('%s','now') AS INTEGER) * 1000)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
schema_version = excluded.schema_version,
|
|
||||||
updated_at_ms = excluded.updated_at_ms
|
|
||||||
""")) {
|
|
||||||
ps.setInt(1, version);
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureReactionsStateTable(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TABLE IF NOT EXISTS reactions_state (
|
|
||||||
from_login TEXT NOT NULL,
|
|
||||||
from_bch_name TEXT NOT NULL,
|
|
||||||
reaction_type INTEGER NOT NULL,
|
|
||||||
to_login TEXT NOT NULL,
|
|
||||||
to_bch_name TEXT NOT NULL,
|
|
||||||
to_block_number INTEGER NOT NULL,
|
|
||||||
to_block_hash BLOB NOT NULL,
|
|
||||||
last_sub_type INTEGER NOT NULL,
|
|
||||||
UNIQUE (
|
|
||||||
from_login,
|
|
||||||
from_bch_name,
|
|
||||||
reaction_type,
|
|
||||||
to_login,
|
|
||||||
to_bch_name,
|
|
||||||
to_block_number,
|
|
||||||
to_block_hash
|
|
||||||
)
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureTestFreeAvatarUploadsTable(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
|
|
||||||
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
|
||||||
used_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
updated_at_ms INTEGER NOT NULL,
|
|
||||||
last_tx_id TEXT NOT NULL DEFAULT '',
|
|
||||||
FOREIGN KEY (login) REFERENCES solana_users(login)
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
|
|
||||||
ON test_free_avatar_uploads (updated_at_ms);
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureSyncServersTable(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TABLE IF NOT EXISTS sync_servers (
|
|
||||||
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
|
||||||
server_address TEXT NOT NULL DEFAULT '',
|
|
||||||
updated_at_ms INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sync_servers_updated
|
|
||||||
ON sync_servers (updated_at_ms);
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TABLE IF NOT EXISTS connections_state (
|
|
||||||
login TEXT NOT NULL,
|
|
||||||
rel_type INTEGER NOT NULL,
|
|
||||||
to_login TEXT NOT NULL,
|
|
||||||
to_bch_name TEXT NOT NULL,
|
|
||||||
to_block_number INTEGER NOT NULL,
|
|
||||||
to_block_hash BLOB NOT NULL,
|
|
||||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
|
||||||
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureConnectionsIndexes(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_connections_state_login
|
|
||||||
ON connections_state (login);
|
|
||||||
""");
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_connections_state_to_login
|
|
||||||
ON connections_state (to_login);
|
|
||||||
""");
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_connections_state_pair
|
|
||||||
ON connections_state (login, to_login);
|
|
||||||
""");
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_connections_state_target
|
|
||||||
ON connections_state (login, rel_type, to_bch_name, to_block_number);
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureReactionsIndexes(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_target
|
|
||||||
ON reactions_state (to_bch_name, to_block_number, to_block_hash);
|
|
||||||
""");
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_reactions_state_actor
|
|
||||||
ON reactions_state (from_login, from_bch_name, reaction_type);
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureChannelNamesStateTable(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TABLE IF NOT EXISTS channel_names_state (
|
|
||||||
slug TEXT NOT NULL,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
channel_description TEXT NOT NULL DEFAULT '',
|
|
||||||
owner_login TEXT NOT NULL,
|
|
||||||
owner_bch_name TEXT NOT NULL,
|
|
||||||
channel_type_code INTEGER NOT NULL DEFAULT 1,
|
|
||||||
channel_type_version INTEGER NOT NULL DEFAULT 1,
|
|
||||||
channel_root_block_number INTEGER NOT NULL,
|
|
||||||
channel_root_block_hash BLOB NOT NULL,
|
|
||||||
created_at_ms INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureChannelNamesDescriptionColumn(Connection c, Statement st) throws SQLException {
|
|
||||||
boolean hasDescription = false;
|
|
||||||
try (Statement probe = c.createStatement();
|
|
||||||
ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) {
|
|
||||||
while (rs.next()) {
|
|
||||||
String name = rs.getString("name");
|
|
||||||
if ("channel_description".equalsIgnoreCase(name)) {
|
|
||||||
hasDescription = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasDescription) {
|
|
||||||
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_description TEXT NOT NULL DEFAULT ''");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureChannelNamesTypeColumns(Connection c, Statement st) throws SQLException {
|
|
||||||
boolean hasTypeCode = false;
|
|
||||||
boolean hasTypeVersion = false;
|
|
||||||
|
|
||||||
try (Statement probe = c.createStatement();
|
|
||||||
ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) {
|
|
||||||
while (rs.next()) {
|
|
||||||
String name = rs.getString("name");
|
|
||||||
if ("channel_type_code".equalsIgnoreCase(name)) hasTypeCode = true;
|
|
||||||
if ("channel_type_version".equalsIgnoreCase(name)) hasTypeVersion = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasTypeCode) {
|
|
||||||
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_code INTEGER NOT NULL DEFAULT 1");
|
|
||||||
}
|
|
||||||
if (!hasTypeVersion) {
|
|
||||||
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_version INTEGER NOT NULL DEFAULT 1");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureChannelNamesIndexes(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_slug");
|
|
||||||
st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_owner_slug");
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
|
|
||||||
ON channel_names_state (owner_bch_name, channel_type_code, slug);
|
|
||||||
""");
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target
|
|
||||||
ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash);
|
|
||||||
""");
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
|
|
||||||
ON channel_names_state (owner_login, owner_bch_name);
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ensureSignedMessageReceiptUniq(Connection c, Statement st) throws SQLException {
|
|
||||||
if (!tableExists(c, "signed_messages_v2")) return;
|
|
||||||
|
|
||||||
if (tableExists(c, "signed_message_session_delivery")) {
|
|
||||||
st.executeUpdate("""
|
|
||||||
DELETE FROM signed_message_session_delivery
|
|
||||||
WHERE message_key IN (
|
|
||||||
SELECT message_key
|
|
||||||
FROM signed_messages_v2
|
|
||||||
WHERE message_type IN (3, 4)
|
|
||||||
AND receipt_ref_base_key IS NOT NULL
|
|
||||||
AND rowid NOT IN (
|
|
||||||
SELECT MIN(rowid)
|
|
||||||
FROM signed_messages_v2
|
|
||||||
WHERE message_type IN (3, 4)
|
|
||||||
AND receipt_ref_base_key IS NOT NULL
|
|
||||||
GROUP BY target_login, message_type, receipt_ref_base_key
|
|
||||||
)
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
DELETE FROM signed_messages_v2
|
|
||||||
WHERE message_type IN (3, 4)
|
|
||||||
AND receipt_ref_base_key IS NOT NULL
|
|
||||||
AND rowid NOT IN (
|
|
||||||
SELECT MIN(rowid)
|
|
||||||
FROM signed_messages_v2
|
|
||||||
WHERE message_type IN (3, 4)
|
|
||||||
AND receipt_ref_base_key IS NOT NULL
|
|
||||||
GROUP BY target_login, message_type, receipt_ref_base_key
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming
|
|
||||||
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
|
||||||
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
|
|
||||||
""");
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing
|
|
||||||
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
|
||||||
WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL;
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void rebuildConnectionsStateTable(Statement st) throws SQLException {
|
|
||||||
st.executeUpdate("DROP TABLE IF EXISTS connections_state_v2");
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TABLE connections_state_v2 (
|
|
||||||
login TEXT NOT NULL,
|
|
||||||
rel_type INTEGER NOT NULL,
|
|
||||||
to_login TEXT NOT NULL,
|
|
||||||
to_bch_name TEXT NOT NULL,
|
|
||||||
to_block_number INTEGER NOT NULL,
|
|
||||||
to_block_hash BLOB NOT NULL,
|
|
||||||
FOREIGN KEY (login) REFERENCES solana_users(login),
|
|
||||||
UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
INSERT OR IGNORE INTO connections_state_v2
|
|
||||||
(login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
|
||||||
SELECT
|
|
||||||
login,
|
|
||||||
rel_type,
|
|
||||||
to_login,
|
|
||||||
to_bch_name,
|
|
||||||
COALESCE(to_block_number, 0),
|
|
||||||
COALESCE(to_block_hash, zeroblob(32))
|
|
||||||
FROM connections_state
|
|
||||||
WHERE login IS NOT NULL
|
|
||||||
AND to_login IS NOT NULL
|
|
||||||
AND to_bch_name IS NOT NULL;
|
|
||||||
""");
|
|
||||||
|
|
||||||
st.executeUpdate("DROP TABLE connections_state");
|
|
||||||
st.executeUpdate("ALTER TABLE connections_state_v2 RENAME TO connections_state");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean tableExists(Connection c, String tableName) throws SQLException {
|
|
||||||
String sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1";
|
|
||||||
try (var ps = c.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, tableName);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
|
||||||
return rs.next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean needsConnectionsStateUpgrade(Connection c) throws SQLException {
|
|
||||||
boolean toBlockNumberNotNull = false;
|
|
||||||
boolean toBlockHashNotNull = false;
|
|
||||||
|
|
||||||
try (Statement st = c.createStatement();
|
|
||||||
ResultSet rs = st.executeQuery("PRAGMA table_info(connections_state)")) {
|
|
||||||
while (rs.next()) {
|
|
||||||
String name = rs.getString("name");
|
|
||||||
int notNull = rs.getInt("notnull");
|
|
||||||
if ("to_block_number".equalsIgnoreCase(name)) {
|
|
||||||
toBlockNumberNotNull = notNull == 1;
|
|
||||||
}
|
|
||||||
if ("to_block_hash".equalsIgnoreCase(name)) {
|
|
||||||
toBlockHashNotNull = notNull == 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return !toBlockNumberNotNull || !toBlockHashNotNull;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,8 @@ public final class ChannelNameRules {
|
|||||||
private static final int MAX_DISPLAY_NAME_LENGTH = 32;
|
private static final int MAX_DISPLAY_NAME_LENGTH = 32;
|
||||||
private static final Pattern DISPLAY_ALLOWED_PATTERN =
|
private static final Pattern DISPLAY_ALLOWED_PATTERN =
|
||||||
Pattern.compile("^[A-Za-z0-9_-]+$");
|
Pattern.compile("^[A-Za-z0-9_-]+$");
|
||||||
|
private static final Pattern PUBLIC_CHANNEL_ALLOWED_PATTERN =
|
||||||
|
Pattern.compile("^[A-Za-z0-9_-]+$");
|
||||||
|
|
||||||
private ChannelNameRules() {}
|
private ChannelNameRules() {}
|
||||||
|
|
||||||
@@ -34,6 +36,14 @@ public final class ChannelNameRules {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String requireValidPublicDisplayNameForCreate(String rawName) {
|
||||||
|
String normalized = requireValidDisplayNameForCreate(rawName);
|
||||||
|
if (!PUBLIC_CHANNEL_ALLOWED_PATTERN.matcher(normalized).matches()) {
|
||||||
|
throw new IllegalArgumentException("channelName contains unsupported characters");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
public static String toCanonicalSlug(String rawName) {
|
public static String toCanonicalSlug(String rawName) {
|
||||||
String normalized = normalizeDisplayName(rawName);
|
String normalized = normalizeDisplayName(rawName);
|
||||||
if (normalized.isEmpty()) {
|
if (normalized.isEmpty()) {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package shine.db.connection;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ConnectionFactory {
|
||||||
|
Connection getConnection() throws SQLException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package shine.db.connection;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нейтральная точка доступа к JDBC-соединениям без привязки к конкретной БД.
|
||||||
|
*
|
||||||
|
* На первом этапе переносов этот интерфейс нужен как новая опора для DAO,
|
||||||
|
* чтобы постепенно убрать прямую зависимость от DbController.
|
||||||
|
*/
|
||||||
|
public interface DbProvider extends ConnectionFactory, AutoCloseable {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
Connection getConnection() throws SQLException;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
default void close() throws Exception {
|
||||||
|
// По умолчанию ресурсов на уровне provider нет.
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package shine.db.connection;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовый JDBC provider поверх DriverManager.
|
||||||
|
*
|
||||||
|
* Не знает ничего про конкретный доменный режим БД:
|
||||||
|
* конкретные параметры и post-connect инициализация задаются снаружи.
|
||||||
|
*/
|
||||||
|
public final class DriverManagerDbProvider implements DbProvider {
|
||||||
|
|
||||||
|
private final String jdbcUrl;
|
||||||
|
private final String user;
|
||||||
|
private final String password;
|
||||||
|
private final ConnectionInitializer initializer;
|
||||||
|
|
||||||
|
public DriverManagerDbProvider(String jdbcUrl,
|
||||||
|
String user,
|
||||||
|
String password,
|
||||||
|
ConnectionInitializer initializer) {
|
||||||
|
if (jdbcUrl == null || jdbcUrl.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("jdbcUrl is blank");
|
||||||
|
}
|
||||||
|
this.jdbcUrl = jdbcUrl;
|
||||||
|
this.user = blankToNull(user);
|
||||||
|
this.password = blankToNull(password);
|
||||||
|
this.initializer = initializer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Connection getConnection() throws SQLException {
|
||||||
|
Connection connection;
|
||||||
|
if (user == null) {
|
||||||
|
connection = DriverManager.getConnection(jdbcUrl);
|
||||||
|
} else {
|
||||||
|
connection = DriverManager.getConnection(jdbcUrl, user, password == null ? "" : password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initializer != null) {
|
||||||
|
initializer.initialize(connection);
|
||||||
|
}
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ConnectionInitializer {
|
||||||
|
void initialize(Connection connection) throws SQLException;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
import shine.db.entities.ActiveSessionEntry;
|
import shine.db.entities.ActiveSessionEntry;
|
||||||
|
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
@@ -17,7 +17,7 @@ import java.util.List;
|
|||||||
public final class ActiveSessionsDAO {
|
public final class ActiveSessionsDAO {
|
||||||
|
|
||||||
private static volatile ActiveSessionsDAO instance;
|
private static volatile ActiveSessionsDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private ActiveSessionsDAO() { }
|
private ActiveSessionsDAO() { }
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ public final class ActiveSessionsDAO {
|
|||||||
client_platform,
|
client_platform,
|
||||||
user_language
|
user_language
|
||||||
FROM active_sessions
|
FROM active_sessions
|
||||||
WHERE login = ? COLLATE NOCASE
|
WHERE LOWER(login) = LOWER(?)
|
||||||
""";
|
""";
|
||||||
|
|
||||||
List<ActiveSessionEntry> result = new ArrayList<>();
|
List<ActiveSessionEntry> result = new ArrayList<>();
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
|
||||||
|
import java.sql.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Persistent discovery/import queue for ANS-104 SHiNE blocks 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, String rootTxId, 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, String rootTxId, long blockHeight, byte[] rawDataItem, long nowMs)
|
||||||
|
throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO arweave_block_import_queue(
|
||||||
|
data_item_id,root_tx_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.setString(2, rootTxId);
|
||||||
|
ps.setLong(3, blockHeight);
|
||||||
|
ps.setBytes(4, rawDataItem);
|
||||||
|
ps.setString(5, STATUS_PENDING);
|
||||||
|
ps.setLong(6, nowMs);
|
||||||
|
ps.setLong(7, 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,root_tx_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.getString("root_tx_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+129
-14
@@ -1,7 +1,7 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.DatabaseInitializer;
|
import shine.db.DatabaseInitializer;
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
@@ -23,13 +23,13 @@ import java.sql.SQLException;
|
|||||||
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
|
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
|
||||||
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
|
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
|
||||||
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
|
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
|
||||||
* - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию:
|
* - пользовательские blockchain-файлы больше не существуют; cleanup полностью DB-only.
|
||||||
* SQLite не может атомарно закоммитить и SQL, и файловую систему сразу;
|
* SQL-транзакция и файловая система не коммитятся атомарно вместе;
|
||||||
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
|
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
|
||||||
* отдельным recovery/resync-слоем после успешного commit.
|
* отдельным recovery/resync-слоем после успешного commit.
|
||||||
*
|
*
|
||||||
* Важный смысл текущей реализации:
|
* Важный смысл текущей реализации:
|
||||||
* - мы НЕ трогаем identity-слой (`solana_users`) и НЕ трогаем DM-таблицы;
|
* - мы НЕ трогаем current users слой (`solana_user_pda_current`) и НЕ трогаем DM-таблицы;
|
||||||
* - мы очищаем только блокчейн пользователя и derived-state, который строится из неё;
|
* - мы очищаем только блокчейн пользователя и derived-state, который строится из неё;
|
||||||
* - висячие cross-chain ссылки в чужих blocks допускаются как нормальное поведение системы.
|
* - висячие cross-chain ссылки в чужих blocks допускаются как нормальное поведение системы.
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +39,7 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
|
|
||||||
private static volatile BlockchainResyncCleanupDAO instance;
|
private static volatile BlockchainResyncCleanupDAO instance;
|
||||||
|
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private BlockchainResyncCleanupDAO() {}
|
private BlockchainResyncCleanupDAO() {}
|
||||||
|
|
||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
@@ -99,6 +99,8 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
|
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
|
||||||
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
|
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
|
||||||
|
|
||||||
|
rebuildStatsState(c);
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
|
|
||||||
return new CleanupResult(
|
return new CleanupResult(
|
||||||
@@ -185,10 +187,10 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
|
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
UPDATE message_stats
|
UPDATE message_stats
|
||||||
SET likes_count = MAX(
|
SET likes_count = GREATEST(
|
||||||
0,
|
0,
|
||||||
likes_count - (
|
likes_count - COALESCE((
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)::int
|
||||||
FROM reactions_state rs
|
FROM reactions_state rs
|
||||||
WHERE rs.from_bch_name = ?
|
WHERE rs.from_bch_name = ?
|
||||||
AND rs.reaction_type = ?
|
AND rs.reaction_type = ?
|
||||||
@@ -198,7 +200,7 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
AND rs.to_block_number = message_stats.to_block_number
|
AND rs.to_block_number = message_stats.to_block_number
|
||||||
AND rs.to_block_hash = message_stats.to_block_hash
|
AND rs.to_block_hash = message_stats.to_block_hash
|
||||||
AND rs.to_bch_name <> ?
|
AND rs.to_bch_name <> ?
|
||||||
)
|
), 0)
|
||||||
)
|
)
|
||||||
WHERE EXISTS (
|
WHERE EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -237,10 +239,10 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
|
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
UPDATE message_stats
|
UPDATE message_stats
|
||||||
SET replies_count = MAX(
|
SET replies_count = GREATEST(
|
||||||
0,
|
0,
|
||||||
replies_count - COALESCE((
|
replies_count - COALESCE((
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)::int
|
||||||
FROM blocks b
|
FROM blocks b
|
||||||
WHERE b.bch_name = ?
|
WHERE b.bch_name = ?
|
||||||
AND b.msg_type = 1
|
AND b.msg_type = 1
|
||||||
@@ -307,7 +309,7 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
private int deleteConnectionsStateForLogin(Connection c, String login) throws SQLException {
|
private int deleteConnectionsStateForLogin(Connection c, String login) throws SQLException {
|
||||||
return executeDelete(c, """
|
return executeDelete(c, """
|
||||||
DELETE FROM connections_state
|
DELETE FROM connections_state
|
||||||
WHERE login = ? COLLATE NOCASE
|
WHERE LOWER(login) = LOWER(?)
|
||||||
""", login);
|
""", login);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +319,7 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
private int deleteUsersParamsForLogin(Connection c, String login) throws SQLException {
|
private int deleteUsersParamsForLogin(Connection c, String login) throws SQLException {
|
||||||
return executeDelete(c, """
|
return executeDelete(c, """
|
||||||
DELETE FROM users_params
|
DELETE FROM users_params
|
||||||
WHERE login = ? COLLATE NOCASE
|
WHERE LOWER(login) = LOWER(?)
|
||||||
""", login);
|
""", login);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +368,119 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
""", blockchainName);
|
""", blockchainName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void rebuildStatsState(Connection c) throws SQLException {
|
||||||
|
try (PreparedStatement truncate = c.prepareStatement("""
|
||||||
|
TRUNCATE TABLE user_stats_state, channel_stats_state
|
||||||
|
""")) {
|
||||||
|
truncate.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
u.login,
|
||||||
|
COALESCE(own.owned_public_channels_count, 0),
|
||||||
|
COALESCE(fu.following_users_count, 0),
|
||||||
|
COALESCE(fc.following_channels_count, 0),
|
||||||
|
COALESCE(cf.close_friends_count, 0),
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
FROM solana_user_pda_current u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
|
||||||
|
FROM channel_names_state
|
||||||
|
WHERE channel_type_code = 1
|
||||||
|
GROUP BY owner_login
|
||||||
|
) own ON LOWER(own.owner_login) = LOWER(u.login)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT login, COUNT(*)::INTEGER AS following_users_count
|
||||||
|
FROM connections_state
|
||||||
|
WHERE rel_type = 30
|
||||||
|
AND to_block_number = 0
|
||||||
|
GROUP BY login
|
||||||
|
) fu ON LOWER(fu.login) = LOWER(u.login)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
|
||||||
|
FROM connections_state cs
|
||||||
|
JOIN channel_names_state cn
|
||||||
|
ON cn.owner_bch_name = cs.to_bch_name
|
||||||
|
AND cn.channel_root_block_number = cs.to_block_number
|
||||||
|
AND cn.channel_root_block_hash = cs.to_block_hash
|
||||||
|
WHERE cs.rel_type = 30
|
||||||
|
AND cn.channel_type_code = 1
|
||||||
|
GROUP BY cs.login
|
||||||
|
) fc ON LOWER(fc.login) = LOWER(u.login)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT login, COUNT(*)::INTEGER AS close_friends_count
|
||||||
|
FROM connections_state
|
||||||
|
WHERE rel_type = 10
|
||||||
|
GROUP BY login
|
||||||
|
) cf ON LOWER(cf.login) = LOWER(u.login)
|
||||||
|
""")) {
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(login) FROM solana_user_pda_current")) {
|
||||||
|
ps.execute();
|
||||||
|
}
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_stats(login) FROM solana_user_pda_current")) {
|
||||||
|
ps.execute();
|
||||||
|
}
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE message_stats ms SET
|
||||||
|
primary_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||||
|
AND rs.to_login=ms.to_login AND rs.to_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)),
|
||||||
|
shining_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||||
|
AND rs.to_login=ms.to_login AND rs.to_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))
|
||||||
|
""")) {
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
INSERT INTO channel_stats_state (
|
||||||
|
owner_bch_name,
|
||||||
|
channel_root_block_number,
|
||||||
|
channel_root_block_hash,
|
||||||
|
owner_login,
|
||||||
|
channel_type_code,
|
||||||
|
subscribers_count,
|
||||||
|
updated_at_ms
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
cn.owner_bch_name,
|
||||||
|
cn.channel_root_block_number,
|
||||||
|
cn.channel_root_block_hash,
|
||||||
|
cn.owner_login,
|
||||||
|
cn.channel_type_code,
|
||||||
|
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
FROM channel_names_state cn
|
||||||
|
LEFT JOIN connections_state cs
|
||||||
|
ON cs.rel_type = 30
|
||||||
|
AND cs.to_bch_name = cn.owner_bch_name
|
||||||
|
AND cs.to_block_number = cn.channel_root_block_number
|
||||||
|
AND cs.to_block_hash = cn.channel_root_block_hash
|
||||||
|
WHERE cn.channel_type_code = 1
|
||||||
|
GROUP BY
|
||||||
|
cn.owner_bch_name,
|
||||||
|
cn.channel_root_block_number,
|
||||||
|
cn.channel_root_block_hash,
|
||||||
|
cn.owner_login,
|
||||||
|
cn.channel_type_code
|
||||||
|
""")) {
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private int executeDelete(Connection c, String sql, String value) throws SQLException {
|
private int executeDelete(Connection c, String sql, String value) throws SQLException {
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, value);
|
ps.setString(1, value);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
import shine.db.entities.BlockchainStateEntry;
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
@@ -10,7 +10,7 @@ import java.util.List;
|
|||||||
public final class BlockchainStateDAO {
|
public final class BlockchainStateDAO {
|
||||||
|
|
||||||
private static volatile BlockchainStateDAO instance;
|
private static volatile BlockchainStateDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private BlockchainStateDAO() {}
|
private BlockchainStateDAO() {}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ public final class BlockchainStateDAO {
|
|||||||
last_block_hash,
|
last_block_hash,
|
||||||
updated_at_ms
|
updated_at_ms
|
||||||
FROM blockchain_state
|
FROM blockchain_state
|
||||||
ORDER BY blockchain_name COLLATE NOCASE
|
ORDER BY LOWER(blockchain_name)
|
||||||
""";
|
""";
|
||||||
|
|
||||||
List<BlockchainStateEntry> result = new ArrayList<>();
|
List<BlockchainStateEntry> result = new ArrayList<>();
|
||||||
@@ -142,7 +142,7 @@ public final class BlockchainStateDAO {
|
|||||||
* Строгая вставка state только если записи ещё нет.
|
* Строгая вставка state только если записи ещё нет.
|
||||||
*
|
*
|
||||||
* Нужна для recovery / resync:
|
* Нужна для recovery / resync:
|
||||||
* - identity пользователя уже может существовать в solana_users;
|
* - runtime-проекция пользователя уже может существовать в current users слое;
|
||||||
* - в таком случае нам надо восстановить только blockchain_state;
|
* - в таком случае нам надо восстановить только blockchain_state;
|
||||||
* - если запись уже есть, метод просто ничего не меняет.
|
* - если запись уже есть, метод просто ничего не меняет.
|
||||||
*/
|
*/
|
||||||
@@ -229,7 +229,7 @@ public final class BlockchainStateDAO {
|
|||||||
|
|
||||||
private static void setBytesNullable(PreparedStatement ps, int index, byte[] b) throws SQLException {
|
private static void setBytesNullable(PreparedStatement ps, int index, byte[] b) throws SQLException {
|
||||||
if (b != null) ps.setBytes(index, b);
|
if (b != null) ps.setBytes(index, b);
|
||||||
else ps.setNull(index, Types.BLOB);
|
else ps.setNull(index, Types.BINARY);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String nn(String s) { return s == null ? "" : s; }
|
private static String nn(String s) { return s == null ? "" : s; }
|
||||||
|
|||||||
@@ -2,293 +2,154 @@ package shine.db.dao;
|
|||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
import shine.db.entities.BlockEntry;
|
import shine.db.entities.BlockEntry;
|
||||||
|
|
||||||
import java.sql.*;
|
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 SqliteDbController db = SqliteDbController.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,arweave_root_tx_id,
|
||||||
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.BLOB);
|
|
||||||
|
|
||||||
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());
|
||||||
|
setNullableString(ps, i++, e.getArweaveRootTxId());
|
||||||
// NEW: line_code
|
setNullableInt(ps, i++, e.getEditedByBlockNumber());
|
||||||
if (e.getLineCode() != null) ps.setInt(i++, e.getLineCode());
|
setNullableInt(ps, i++, e.getLineCode());
|
||||||
else ps.setNull(i++, Types.INTEGER);
|
setNullableInt(ps, i++, e.getPrevLineNumber());
|
||||||
|
setNullableBytes(ps, i++, e.getPrevLineHash());
|
||||||
if (e.getPrevLineNumber() != null) ps.setInt(i++, e.getPrevLineNumber());
|
setNullableInt(ps, i++, e.getThisLineNumber());
|
||||||
else ps.setNull(i++, Types.INTEGER);
|
|
||||||
|
|
||||||
if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash());
|
|
||||||
else ps.setNull(i++, Types.BLOB);
|
|
||||||
|
|
||||||
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,arweave_root_tx_id,
|
||||||
|
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, String rootTxId, long publishedAtMs) throws SQLException {
|
||||||
|
if (dataItemIds == null || dataItemIds.isEmpty()) return;
|
||||||
|
String sql = """
|
||||||
|
UPDATE blocks
|
||||||
|
SET arweave_publish_pending=FALSE, arweave_published_at_ms=?, arweave_root_tx_id=?
|
||||||
|
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.setString(2, rootTxId); ps.setBytes(3, 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,arweave_root_tx_id,
|
||||||
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.setArweaveRootTxId(rs.getString("arweave_root_tx_id"));
|
||||||
|
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);}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
import shine.db.entities.ChannelNameStateEntry;
|
import shine.db.entities.ChannelNameStateEntry;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
@@ -11,7 +11,7 @@ import java.util.List;
|
|||||||
|
|
||||||
public final class ChannelNameStateDAO {
|
public final class ChannelNameStateDAO {
|
||||||
private static volatile ChannelNameStateDAO instance;
|
private static volatile ChannelNameStateDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private ChannelNameStateDAO() {}
|
private ChannelNameStateDAO() {}
|
||||||
|
|
||||||
@@ -65,8 +65,12 @@ public final class ChannelNameStateDAO {
|
|||||||
channel_type_version,
|
channel_type_version,
|
||||||
channel_root_block_number,
|
channel_root_block_number,
|
||||||
channel_root_block_hash,
|
channel_root_block_hash,
|
||||||
created_at_ms
|
created_at_ms,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
ava_ar,
|
||||||
|
ava_sha256,
|
||||||
|
ava_size,
|
||||||
|
meta_updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, entry.getSlug());
|
ps.setString(1, entry.getSlug());
|
||||||
@@ -79,6 +83,34 @@ public final class ChannelNameStateDAO {
|
|||||||
ps.setInt(8, entry.getChannelRootBlockNumber());
|
ps.setInt(8, entry.getChannelRootBlockNumber());
|
||||||
ps.setBytes(9, entry.getChannelRootBlockHash());
|
ps.setBytes(9, entry.getChannelRootBlockHash());
|
||||||
ps.setLong(10, entry.getCreatedAtMs());
|
ps.setLong(10, entry.getCreatedAtMs());
|
||||||
|
ps.setString(11, entry.getAvaAr() == null ? "" : entry.getAvaAr());
|
||||||
|
ps.setString(12, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
|
||||||
|
ps.setLong(13, Math.max(0L, entry.getAvaSize()));
|
||||||
|
ps.setLong(14, Math.max(0L, entry.getMetaUpdatedAtMs()));
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateMeta(Connection c, ChannelNameStateEntry entry) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
UPDATE channel_names_state
|
||||||
|
SET display_name = ?,
|
||||||
|
channel_description = ?,
|
||||||
|
ava_ar = ?,
|
||||||
|
ava_sha256 = ?,
|
||||||
|
ava_size = ?,
|
||||||
|
meta_updated_at_ms = ?
|
||||||
|
WHERE owner_bch_name = ? AND channel_root_block_number = ?
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, entry.getDisplayName() == null ? "" : entry.getDisplayName());
|
||||||
|
ps.setString(2, entry.getChannelDescription() == null ? "" : entry.getChannelDescription());
|
||||||
|
ps.setString(3, entry.getAvaAr() == null ? "" : entry.getAvaAr());
|
||||||
|
ps.setString(4, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
|
||||||
|
ps.setLong(5, Math.max(0L, entry.getAvaSize()));
|
||||||
|
ps.setLong(6, Math.max(0L, entry.getMetaUpdatedAtMs()));
|
||||||
|
ps.setString(7, entry.getOwnerBlockchainName());
|
||||||
|
ps.setInt(8, entry.getChannelRootBlockNumber());
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
|
import shine.db.sql.CurrentUsersSql;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
@@ -13,16 +14,14 @@ import java.util.List;
|
|||||||
* ConnectionsStateDAO — чтение текущего состояния связей из connections_state.
|
* ConnectionsStateDAO — чтение текущего состояния связей из connections_state.
|
||||||
*
|
*
|
||||||
* ВАЖНО:
|
* ВАЖНО:
|
||||||
* - login в запросах может быть в любом регистре, поэтому в WHERE используем COLLATE NOCASE
|
* - login в запросах может быть в любом регистре;
|
||||||
* - в ответах возвращаем логины в каноническом регистре через JOIN на solana_users
|
* - в ответах возвращаем логины в каноническом регистре через прямой lookup
|
||||||
*
|
* по актуальным PDA-данным и manual fallback.
|
||||||
* ПРИМЕЧАНИЕ:
|
|
||||||
* Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй в SQL.
|
|
||||||
*/
|
*/
|
||||||
public final class ConnectionsStateDAO {
|
public final class ConnectionsStateDAO {
|
||||||
|
|
||||||
private static volatile ConnectionsStateDAO instance;
|
private static volatile ConnectionsStateDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private ConnectionsStateDAO() {}
|
private ConnectionsStateDAO() {}
|
||||||
|
|
||||||
@@ -42,14 +41,17 @@ public final class ConnectionsStateDAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login
|
SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login
|
||||||
FROM connections_state cs
|
FROM connections_state cs
|
||||||
LEFT JOIN solana_users u_login
|
LEFT JOIN %s
|
||||||
ON u_login.login = cs.to_login COLLATE NOCASE
|
ON LOWER(u_login.login) = LOWER(cs.to_login)
|
||||||
LEFT JOIN solana_users u_bch
|
LEFT JOIN %s
|
||||||
ON u_bch.blockchain_name = cs.to_bch_name COLLATE NOCASE
|
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
|
||||||
WHERE cs.login = ? COLLATE NOCASE
|
WHERE LOWER(cs.login) = LOWER(?)
|
||||||
AND cs.rel_type = ?
|
AND cs.rel_type = ?
|
||||||
ORDER BY friend_login
|
ORDER BY friend_login
|
||||||
""";
|
""".formatted(
|
||||||
|
CurrentUsersSql.usersSubquery("u_login"),
|
||||||
|
CurrentUsersSql.usersSubquery("u_bch")
|
||||||
|
);
|
||||||
|
|
||||||
List<String> out = new ArrayList<>();
|
List<String> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
@@ -65,6 +67,36 @@ public final class ConnectionsStateDAO {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean hasOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, String peerLoginAnyCase, int relType) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT 1
|
||||||
|
FROM connections_state cs
|
||||||
|
LEFT JOIN %s
|
||||||
|
ON LOWER(u_login.login) = LOWER(cs.to_login)
|
||||||
|
LEFT JOIN %s
|
||||||
|
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
|
||||||
|
WHERE LOWER(cs.login) = LOWER(?)
|
||||||
|
AND cs.rel_type = ?
|
||||||
|
AND (
|
||||||
|
LOWER(cs.to_login) = LOWER(?)
|
||||||
|
OR LOWER(COALESCE(u_login.login, u_bch.login, cs.to_login)) = LOWER(?)
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
""".formatted(
|
||||||
|
CurrentUsersSql.usersSubquery("u_login"),
|
||||||
|
CurrentUsersSql.usersSubquery("u_bch")
|
||||||
|
);
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, loginAnyCase);
|
||||||
|
ps.setInt(2, relType);
|
||||||
|
ps.setString(3, peerLoginAnyCase);
|
||||||
|
ps.setString(4, peerLoginAnyCase);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Incoming: список логинов (канонических), кто поставил relType пользователю login.
|
* Incoming: список логинов (канонических), кто поставил relType пользователю login.
|
||||||
*/
|
*/
|
||||||
@@ -72,17 +104,20 @@ public final class ConnectionsStateDAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT COALESCE(u_actor.login, cs.login) AS friend_login
|
SELECT COALESCE(u_actor.login, cs.login) AS friend_login
|
||||||
FROM connections_state cs
|
FROM connections_state cs
|
||||||
LEFT JOIN solana_users u_actor
|
LEFT JOIN %s
|
||||||
ON u_actor.login = cs.login COLLATE NOCASE
|
ON LOWER(u_actor.login) = LOWER(cs.login)
|
||||||
LEFT JOIN solana_users u_target
|
LEFT JOIN %s
|
||||||
ON u_target.login = ? COLLATE NOCASE
|
ON LOWER(u_target.login) = LOWER(?)
|
||||||
WHERE (
|
WHERE (
|
||||||
cs.to_login = ? COLLATE NOCASE
|
LOWER(cs.to_login) = LOWER(?)
|
||||||
OR (u_target.blockchain_name IS NOT NULL AND cs.to_bch_name = u_target.blockchain_name COLLATE NOCASE)
|
OR (u_target.blockchain_name IS NOT NULL AND LOWER(cs.to_bch_name) = LOWER(u_target.blockchain_name))
|
||||||
)
|
)
|
||||||
AND cs.rel_type = ?
|
AND cs.rel_type = ?
|
||||||
ORDER BY friend_login
|
ORDER BY friend_login
|
||||||
""";
|
""".formatted(
|
||||||
|
CurrentUsersSql.usersSubquery("u_actor"),
|
||||||
|
CurrentUsersSql.usersSubquery("u_target")
|
||||||
|
);
|
||||||
|
|
||||||
List<String> out = new ArrayList<>();
|
List<String> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
@@ -106,19 +141,19 @@ public final class ConnectionsStateDAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT u.login AS friend_login
|
SELECT u.login AS friend_login
|
||||||
FROM connections_state a
|
FROM connections_state a
|
||||||
JOIN solana_users u
|
JOIN %s
|
||||||
ON u.login = a.to_login COLLATE NOCASE
|
ON LOWER(u.login) = LOWER(a.to_login)
|
||||||
WHERE a.login = ? COLLATE NOCASE
|
WHERE LOWER(a.login) = LOWER(?)
|
||||||
AND a.rel_type = ?
|
AND a.rel_type = ?
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM connections_state b
|
FROM connections_state b
|
||||||
WHERE b.login = a.to_login COLLATE NOCASE
|
WHERE LOWER(b.login) = LOWER(a.to_login)
|
||||||
AND b.to_login = a.login COLLATE NOCASE
|
AND LOWER(b.to_login) = LOWER(a.login)
|
||||||
AND b.rel_type = a.rel_type
|
AND b.rel_type = a.rel_type
|
||||||
)
|
)
|
||||||
ORDER BY u.login
|
ORDER BY u.login
|
||||||
""";
|
""".formatted(CurrentUsersSql.usersSubquery("u"));
|
||||||
|
|
||||||
List<String> out = new ArrayList<>();
|
List<String> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
@@ -141,10 +176,22 @@ public final class ConnectionsStateDAO {
|
|||||||
String toBchName,
|
String toBchName,
|
||||||
Integer toBlockNumber,
|
Integer toBlockNumber,
|
||||||
byte[] toBlockHash) throws SQLException {
|
byte[] toBlockHash) throws SQLException {
|
||||||
|
try (PreparedStatement deletePs = c.prepareStatement("""
|
||||||
|
DELETE FROM connections_state
|
||||||
|
WHERE LOWER(login) = LOWER(?)
|
||||||
|
AND rel_type = ?
|
||||||
|
AND LOWER(to_login) = LOWER(?)
|
||||||
|
""")) {
|
||||||
|
deletePs.setString(1, login);
|
||||||
|
deletePs.setInt(2, relType);
|
||||||
|
deletePs.setString(3, toLogin);
|
||||||
|
deletePs.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO connections_state (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
INSERT INTO connections_state (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(login, rel_type, to_login) DO UPDATE SET
|
ON CONFLICT(login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) DO UPDATE SET
|
||||||
to_bch_name=excluded.to_bch_name,
|
to_bch_name=excluded.to_bch_name,
|
||||||
to_block_number=excluded.to_block_number,
|
to_block_number=excluded.to_block_number,
|
||||||
to_block_hash=excluded.to_block_hash
|
to_block_hash=excluded.to_block_hash
|
||||||
|
|||||||
+60
-66
@@ -1,19 +1,22 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
import shine.db.entities.SolanaUserEntry;
|
import shine.db.KeyEncodingUtil;
|
||||||
|
import shine.db.entities.CurrentUserEntry;
|
||||||
|
import shine.db.sql.CurrentUsersSql;
|
||||||
|
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SolanaUsersDAO — локальная таблица пользователей из Solana.
|
* CurrentUsersDAO — совместимый runtime-доступ к текущему срезу пользователей.
|
||||||
*
|
*
|
||||||
* Таблица: solana_users
|
* Источник:
|
||||||
|
* - solana_user_pda_current
|
||||||
*
|
*
|
||||||
* Колонки:
|
* Колонки:
|
||||||
* - login TEXT PRIMARY KEY (COLLATE NOCASE)
|
* - login TEXT PRIMARY KEY
|
||||||
* - blockchain_name TEXT NOT NULL
|
* - blockchain_name TEXT NOT NULL
|
||||||
* - solana_key TEXT NOT NULL
|
* - solana_key TEXT NOT NULL
|
||||||
* - blockchain_key TEXT NOT NULL
|
* - blockchain_key TEXT NOT NULL
|
||||||
@@ -23,59 +26,32 @@ import java.util.List;
|
|||||||
* - методы с Connection НЕ закрывают соединение
|
* - методы с Connection НЕ закрывают соединение
|
||||||
* - методы без Connection сами открывают и закрывают соединение
|
* - методы без Connection сами открывают и закрывают соединение
|
||||||
*/
|
*/
|
||||||
public final class SolanaUsersDAO {
|
public final class CurrentUsersDAO {
|
||||||
|
|
||||||
private static volatile SolanaUsersDAO instance;
|
private static volatile CurrentUsersDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private SolanaUsersDAO() {}
|
private CurrentUsersDAO() {}
|
||||||
|
|
||||||
public static SolanaUsersDAO getInstance() {
|
public static CurrentUsersDAO getInstance() {
|
||||||
if (instance == null) {
|
if (instance == null) {
|
||||||
synchronized (SolanaUsersDAO.class) {
|
synchronized (CurrentUsersDAO.class) {
|
||||||
if (instance == null) instance = new SolanaUsersDAO();
|
if (instance == null) instance = new CurrentUsersDAO();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- INSERT --------------------
|
|
||||||
|
|
||||||
/** Вставка с внешним соединением. Соединение НЕ закрывает. */
|
|
||||||
public void insert(Connection c, SolanaUserEntry user) throws SQLException {
|
|
||||||
String sql = """
|
|
||||||
INSERT INTO solana_users (
|
|
||||||
login, blockchain_name, solana_key, blockchain_key, client_key
|
|
||||||
) VALUES (?, ?, ?, ?, ?)
|
|
||||||
""";
|
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, user.getLogin());
|
|
||||||
ps.setString(2, user.getBlockchainName());
|
|
||||||
ps.setString(3, user.getSolanaKey());
|
|
||||||
ps.setString(4, user.getBlockchainKey());
|
|
||||||
ps.setString(5, user.getClientKey());
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Вставка без внешнего соединения. Сам открывает/закрывает. */
|
|
||||||
public void insert(SolanaUserEntry user) throws SQLException {
|
|
||||||
try (Connection c = db.getConnection()) {
|
|
||||||
insert(c, user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------- EXISTS --------------------
|
// -------------------- EXISTS --------------------
|
||||||
|
|
||||||
/** Проверка существования по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */
|
/** Проверка существования по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */
|
||||||
public boolean existsByLogin(Connection c, String login) throws SQLException {
|
public boolean existsByLogin(Connection c, String login) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM solana_users
|
FROM %s
|
||||||
WHERE LOWER(login) = LOWER(?)
|
WHERE normalized_login = LOWER(BTRIM(?))
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, login);
|
ps.setString(1, login);
|
||||||
@@ -96,10 +72,10 @@ public final class SolanaUsersDAO {
|
|||||||
public boolean existsByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
public boolean existsByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM solana_users
|
FROM %s
|
||||||
WHERE blockchain_name = ?
|
WHERE blockchain_name = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, blockchainName);
|
ps.setString(1, blockchainName);
|
||||||
@@ -119,7 +95,7 @@ public final class SolanaUsersDAO {
|
|||||||
// -------------------- SELECT --------------------
|
// -------------------- SELECT --------------------
|
||||||
|
|
||||||
/** Получить по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */
|
/** Получить по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */
|
||||||
public SolanaUserEntry getByLogin(Connection c, String login) throws SQLException {
|
public CurrentUserEntry getByLogin(Connection c, String login) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
login,
|
login,
|
||||||
@@ -127,9 +103,9 @@ public final class SolanaUsersDAO {
|
|||||||
solana_key,
|
solana_key,
|
||||||
blockchain_key,
|
blockchain_key,
|
||||||
client_key
|
client_key
|
||||||
FROM solana_users
|
FROM %s
|
||||||
WHERE LOWER(login) = LOWER(?)
|
WHERE normalized_login = LOWER(BTRIM(?))
|
||||||
""";
|
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, login);
|
ps.setString(1, login);
|
||||||
@@ -141,14 +117,14 @@ public final class SolanaUsersDAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Получить по login (case-insensitive) без внешнего соединения. Сам открывает/закрывает. */
|
/** Получить по login (case-insensitive) без внешнего соединения. Сам открывает/закрывает. */
|
||||||
public SolanaUserEntry getByLogin(String login) throws SQLException {
|
public CurrentUserEntry getByLogin(String login) throws SQLException {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
return getByLogin(c, login);
|
return getByLogin(c, login);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Получить по blockchain_name (case-sensitive) с внешним соединением. Соединение НЕ закрывает. */
|
/** Получить по blockchain_name (case-sensitive) с внешним соединением. Соединение НЕ закрывает. */
|
||||||
public SolanaUserEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
public CurrentUserEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
login,
|
login,
|
||||||
@@ -156,9 +132,9 @@ public final class SolanaUsersDAO {
|
|||||||
solana_key,
|
solana_key,
|
||||||
blockchain_key,
|
blockchain_key,
|
||||||
client_key
|
client_key
|
||||||
FROM solana_users
|
FROM %s
|
||||||
WHERE blockchain_name = ?
|
WHERE blockchain_name = ?
|
||||||
""";
|
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, blockchainName);
|
ps.setString(1, blockchainName);
|
||||||
@@ -170,14 +146,19 @@ public final class SolanaUsersDAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Получить по blockchain_name без внешнего соединения. */
|
/** Получить по blockchain_name без внешнего соединения. */
|
||||||
public SolanaUserEntry getByBlockchainName(String blockchainName) throws SQLException {
|
public CurrentUserEntry getByBlockchainName(String blockchainName) throws SQLException {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
return getByBlockchainName(c, blockchainName);
|
return getByBlockchainName(c, blockchainName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поиск по префиксу с внешним соединением. Соединение НЕ закрывает. */
|
/** Поиск по префиксу с внешним соединением. Соединение НЕ закрывает. */
|
||||||
public List<SolanaUserEntry> searchByLoginPrefix(Connection c, String prefix) throws SQLException {
|
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix) throws SQLException {
|
||||||
|
return searchByLoginPrefix(c, prefix, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поиск по префиксу с optional-фильтром server PDA. Соединение НЕ закрывает. */
|
||||||
|
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix, Boolean isServer) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
login,
|
login,
|
||||||
@@ -185,16 +166,24 @@ public final class SolanaUsersDAO {
|
|||||||
solana_key,
|
solana_key,
|
||||||
blockchain_key,
|
blockchain_key,
|
||||||
client_key
|
client_key
|
||||||
FROM solana_users
|
FROM %s
|
||||||
WHERE LOWER(login) LIKE ?
|
WHERE normalized_login LIKE LOWER(BTRIM(?))
|
||||||
|
AND (? IS NULL OR is_server = ?)
|
||||||
ORDER BY login
|
ORDER BY login
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
""";
|
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||||
|
|
||||||
List<SolanaUserEntry> result = new ArrayList<>();
|
List<CurrentUserEntry> result = new ArrayList<>();
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, prefix.toLowerCase() + "%");
|
ps.setString(1, prefix.trim() + "%");
|
||||||
|
if (isServer == null) {
|
||||||
|
ps.setNull(2, Types.BOOLEAN);
|
||||||
|
ps.setNull(3, Types.BOOLEAN);
|
||||||
|
} else {
|
||||||
|
ps.setBoolean(2, isServer);
|
||||||
|
ps.setBoolean(3, isServer);
|
||||||
|
}
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) result.add(mapRow(rs));
|
while (rs.next()) result.add(mapRow(rs));
|
||||||
}
|
}
|
||||||
@@ -204,22 +193,27 @@ public final class SolanaUsersDAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */
|
/** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */
|
||||||
public List<SolanaUserEntry> searchByLoginPrefix(String prefix) throws SQLException {
|
public List<CurrentUserEntry> searchByLoginPrefix(String prefix) throws SQLException {
|
||||||
|
return searchByLoginPrefix(prefix, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поиск по префиксу без внешнего соединения с optional-фильтром server PDA. */
|
||||||
|
public List<CurrentUserEntry> searchByLoginPrefix(String prefix, Boolean isServer) throws SQLException {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
return searchByLoginPrefix(c, prefix);
|
return searchByLoginPrefix(c, prefix, isServer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- MAPPER --------------------
|
// -------------------- MAPPER --------------------
|
||||||
|
|
||||||
private SolanaUserEntry mapRow(ResultSet rs) throws SQLException {
|
private CurrentUserEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
SolanaUserEntry e = new SolanaUserEntry();
|
CurrentUserEntry e = new CurrentUserEntry();
|
||||||
|
|
||||||
e.setLogin(rs.getString("login"));
|
e.setLogin(rs.getString("login"));
|
||||||
e.setBlockchainName(rs.getString("blockchain_name"));
|
e.setBlockchainName(rs.getString("blockchain_name"));
|
||||||
e.setSolanaKey(rs.getString("solana_key"));
|
e.setSolanaKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("solana_key")));
|
||||||
e.setBlockchainKey(rs.getString("blockchain_key"));
|
e.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key")));
|
||||||
e.setClientKey(rs.getString("client_key"));
|
e.setClientKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("client_key")));
|
||||||
|
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package shine.db.dao;
|
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
|
||||||
import shine.db.entities.DirectMessageEntry;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.PreparedStatement;
|
|
||||||
|
|
||||||
public final class DirectMessagesDAO {
|
|
||||||
private static volatile DirectMessagesDAO instance;
|
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
|
||||||
|
|
||||||
private DirectMessagesDAO() {}
|
|
||||||
|
|
||||||
public static DirectMessagesDAO getInstance() {
|
|
||||||
if (instance == null) {
|
|
||||||
synchronized (DirectMessagesDAO.class) {
|
|
||||||
if (instance == null) instance = new DirectMessagesDAO();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void insert(DirectMessageEntry entry) throws Exception {
|
|
||||||
try (Connection c = db.getConnection()) {
|
|
||||||
String sql = """
|
|
||||||
INSERT INTO direct_messages (
|
|
||||||
message_id, from_login, to_login, text, created_at_ms
|
|
||||||
) VALUES (?, ?, ?, ?, ?)
|
|
||||||
""";
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, entry.getMessageId());
|
|
||||||
ps.setString(2, entry.getFromLogin());
|
|
||||||
ps.setString(3, entry.getToLogin());
|
|
||||||
ps.setString(4, entry.getText());
|
|
||||||
ps.setLong(5, entry.getCreatedAtMs());
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean existsFromTo(String fromLogin, String toLogin) throws Exception {
|
|
||||||
try (Connection c = db.getConnection()) {
|
|
||||||
String sql = "SELECT 1 FROM direct_messages WHERE from_login = ? AND to_login = ? LIMIT 1";
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, fromLogin);
|
|
||||||
ps.setString(2, toLogin);
|
|
||||||
return ps.executeQuery().next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.entities.DmDeliveryStateEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Хранилище изменяемого состояния межсерверной доставки DM-пары. */
|
||||||
|
public final class DmDeliveryStateDAO {
|
||||||
|
private static volatile DmDeliveryStateDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private DmDeliveryStateDAO() {}
|
||||||
|
|
||||||
|
public static DmDeliveryStateDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (DmDeliveryStateDAO.class) {
|
||||||
|
if (instance == null) instance = new DmDeliveryStateDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DmDeliveryStateEntry upsertPair(
|
||||||
|
String outgoingMessageKey,
|
||||||
|
String eventId,
|
||||||
|
String baseKey,
|
||||||
|
String fromLogin,
|
||||||
|
String toLogin,
|
||||||
|
String incomingMessageKey,
|
||||||
|
long createdAtMs,
|
||||||
|
long expiresAtMs,
|
||||||
|
int initialState,
|
||||||
|
String deliveredServerLogin,
|
||||||
|
String routesHash,
|
||||||
|
boolean assistImmediately
|
||||||
|
) throws SQLException {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
int safeState = normalizeState(initialState, deliveredServerLogin);
|
||||||
|
if (expiresAtMs <= now && safeState == DmDeliveryStateEntry.PENDING_NONE) {
|
||||||
|
safeState = DmDeliveryStateEntry.FAILED_FINAL;
|
||||||
|
}
|
||||||
|
Long nextAttemptAt = safeState == DmDeliveryStateEntry.ACCEPTED
|
||||||
|
? now
|
||||||
|
: null;
|
||||||
|
String safeDeliveredLogin = safeState == DmDeliveryStateEntry.DELIVERED_ONE
|
||||||
|
? normalize(deliveredServerLogin)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO dm_delivery_state (
|
||||||
|
outgoing_message_key, event_id, base_key, from_login, to_login,
|
||||||
|
incoming_message_key, created_at_ms, delivery_expires_at_ms,
|
||||||
|
delivery_state, delivered_server_login, recipient_routes_hash,
|
||||||
|
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, NULL, ?)
|
||||||
|
ON CONFLICT (outgoing_message_key) DO UPDATE SET
|
||||||
|
event_id = EXCLUDED.event_id,
|
||||||
|
base_key = EXCLUDED.base_key,
|
||||||
|
from_login = EXCLUDED.from_login,
|
||||||
|
to_login = EXCLUDED.to_login,
|
||||||
|
incoming_message_key = EXCLUDED.incoming_message_key,
|
||||||
|
created_at_ms = CASE
|
||||||
|
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.created_at_ms
|
||||||
|
ELSE EXCLUDED.created_at_ms
|
||||||
|
END,
|
||||||
|
delivery_expires_at_ms = CASE
|
||||||
|
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivery_expires_at_ms
|
||||||
|
ELSE EXCLUDED.delivery_expires_at_ms
|
||||||
|
END,
|
||||||
|
delivery_state = CASE
|
||||||
|
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivery_state
|
||||||
|
ELSE EXCLUDED.delivery_state
|
||||||
|
END,
|
||||||
|
delivered_server_login = CASE
|
||||||
|
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivered_server_login
|
||||||
|
ELSE EXCLUDED.delivered_server_login
|
||||||
|
END,
|
||||||
|
recipient_routes_hash = CASE
|
||||||
|
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN COALESCE(dm_delivery_state.recipient_routes_hash, EXCLUDED.recipient_routes_hash)
|
||||||
|
ELSE EXCLUDED.recipient_routes_hash
|
||||||
|
END,
|
||||||
|
attempt_index = CASE
|
||||||
|
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.attempt_index
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
|
next_attempt_at_ms = CASE
|
||||||
|
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.next_attempt_at_ms
|
||||||
|
ELSE EXCLUDED.next_attempt_at_ms
|
||||||
|
END,
|
||||||
|
last_attempt_at_ms = CASE
|
||||||
|
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.last_attempt_at_ms
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
last_error = CASE
|
||||||
|
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.last_error
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, outgoingMessageKey);
|
||||||
|
ps.setString(2, eventId);
|
||||||
|
ps.setString(3, baseKey);
|
||||||
|
ps.setString(4, normalize(fromLogin));
|
||||||
|
ps.setString(5, normalize(toLogin));
|
||||||
|
ps.setString(6, incomingMessageKey);
|
||||||
|
ps.setLong(7, createdAtMs);
|
||||||
|
ps.setLong(8, expiresAtMs);
|
||||||
|
ps.setInt(9, safeState);
|
||||||
|
setNullableString(ps, 10, safeDeliveredLogin);
|
||||||
|
setNullableString(ps, 11, normalizeBlank(routesHash));
|
||||||
|
setNullableLong(ps, 12, nextAttemptAt);
|
||||||
|
ps.setLong(13, now);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DmDeliveryStateEntry result;
|
||||||
|
if (initialState != DmDeliveryStateEntry.PENDING_NONE || !isBlank(deliveredServerLogin)) {
|
||||||
|
result = mergeRemote(eventId, initialState, deliveredServerLogin, routesHash, now);
|
||||||
|
} else {
|
||||||
|
result = getByEventId(eventId);
|
||||||
|
}
|
||||||
|
if (result != null && result.getDeliveryExpiresAtMs() <= now
|
||||||
|
&& result.getDeliveryState() == DmDeliveryStateEntry.PENDING_NONE) {
|
||||||
|
return finishAtExpiry(eventId, now);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DmDeliveryStateEntry getByEventId(String eventId) throws SQLException {
|
||||||
|
if (isBlank(eventId)) return null;
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE event_id = ?")) {
|
||||||
|
ps.setString(1, eventId.trim());
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? mapRow(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DmDeliveryStateEntry getByOutgoingMessageKey(String outgoingMessageKey) throws SQLException {
|
||||||
|
if (isBlank(outgoingMessageKey)) return null;
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE outgoing_message_key = ?")) {
|
||||||
|
ps.setString(1, outgoingMessageKey.trim());
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? mapRow(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, DmDeliveryStateEntry> listByOutgoingMessageKeys(List<String> keys) throws SQLException {
|
||||||
|
Map<String, DmDeliveryStateEntry> out = new HashMap<>();
|
||||||
|
if (keys == null || keys.isEmpty()) return out;
|
||||||
|
String placeholders = String.join(",", java.util.Collections.nCopies(keys.size(), "?"));
|
||||||
|
String sql = selectColumns() + " WHERE outgoing_message_key IN (" + placeholders + ")";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
for (int i = 0; i < keys.size(); i++) ps.setString(i + 1, keys.get(i));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
DmDeliveryStateEntry row = mapRow(rs);
|
||||||
|
out.put(row.getOutgoingMessageKey(), row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DmDeliveryStateEntry> listDue(long nowMs, int limit) throws SQLException {
|
||||||
|
String sql = selectColumns() + """
|
||||||
|
WHERE delivery_state = 0
|
||||||
|
AND next_attempt_at_ms IS NOT NULL
|
||||||
|
AND next_attempt_at_ms <= ?
|
||||||
|
ORDER BY next_attempt_at_ms ASC, created_at_ms ASC
|
||||||
|
LIMIT ?
|
||||||
|
""";
|
||||||
|
List<DmDeliveryStateEntry> out = new ArrayList<>();
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setLong(1, nowMs);
|
||||||
|
ps.setInt(2, Math.max(1, limit));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean claimAttempt(DmDeliveryStateEntry row, long nowMs, Long nextAttemptAtMs) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
UPDATE dm_delivery_state
|
||||||
|
SET attempt_index = attempt_index + 1,
|
||||||
|
next_attempt_at_ms = ?,
|
||||||
|
last_attempt_at_ms = ?,
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE outgoing_message_key = ?
|
||||||
|
AND event_id = ?
|
||||||
|
AND attempt_index = ?
|
||||||
|
AND delivery_state = 0
|
||||||
|
AND next_attempt_at_ms IS NOT NULL
|
||||||
|
AND next_attempt_at_ms <= ?
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
long networkLeaseUntilMs = nowMs + 60_000L;
|
||||||
|
Long claimedNextAttemptAtMs = nextAttemptAtMs == null
|
||||||
|
? networkLeaseUntilMs
|
||||||
|
: Math.max(nextAttemptAtMs, networkLeaseUntilMs);
|
||||||
|
setNullableLong(ps, 1, claimedNextAttemptAtMs);
|
||||||
|
ps.setLong(2, nowMs);
|
||||||
|
ps.setLong(3, nowMs);
|
||||||
|
ps.setString(4, row.getOutgoingMessageKey());
|
||||||
|
ps.setString(5, row.getEventId());
|
||||||
|
ps.setInt(6, row.getAttemptIndex());
|
||||||
|
ps.setLong(7, nowMs);
|
||||||
|
return ps.executeUpdate() == 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DmDeliveryStateEntry mergeRemote(
|
||||||
|
String eventId,
|
||||||
|
int remoteState,
|
||||||
|
String remoteDeliveredLogin,
|
||||||
|
String remoteRoutesHash,
|
||||||
|
long nowMs
|
||||||
|
) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean previousAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
DmDeliveryStateEntry current = getByEventIdForUpdate(c, eventId);
|
||||||
|
if (current == null) {
|
||||||
|
c.rollback();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MergeResult merged = merge(
|
||||||
|
current.getDeliveryState(),
|
||||||
|
current.getDeliveredServerLogin(),
|
||||||
|
current.getRecipientRoutesHash(),
|
||||||
|
remoteState,
|
||||||
|
remoteDeliveredLogin,
|
||||||
|
remoteRoutesHash
|
||||||
|
);
|
||||||
|
updateMutableState(c, current, merged.state(), merged.deliveredLogin(),
|
||||||
|
merged.routesHash(), current.getNextAttemptAtMs(), current.getLastError(), nowMs);
|
||||||
|
c.commit();
|
||||||
|
return getByEventId(eventId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(previousAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DmDeliveryStateEntry updateAfterAttempt(
|
||||||
|
String eventId,
|
||||||
|
int attemptedState,
|
||||||
|
String deliveredServerLogin,
|
||||||
|
String routesHash,
|
||||||
|
Long nextAttemptAtMs,
|
||||||
|
String lastError,
|
||||||
|
long nowMs
|
||||||
|
) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean previousAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
DmDeliveryStateEntry current = getByEventIdForUpdate(c, eventId);
|
||||||
|
if (current == null) {
|
||||||
|
c.rollback();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MergeResult merged = merge(
|
||||||
|
current.getDeliveryState(), current.getDeliveredServerLogin(), current.getRecipientRoutesHash(),
|
||||||
|
attemptedState, deliveredServerLogin, routesHash
|
||||||
|
);
|
||||||
|
Long effectiveNext = merged.state() == DmDeliveryStateEntry.ACCEPTED
|
||||||
|
? nextAttemptAtMs
|
||||||
|
: null;
|
||||||
|
updateMutableState(c, current, merged.state(), merged.deliveredLogin(),
|
||||||
|
merged.routesHash(), effectiveNext, lastError, nowMs);
|
||||||
|
c.commit();
|
||||||
|
return getByEventId(eventId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(previousAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DmDeliveryStateEntry finishAtExpiry(String eventId, long nowMs) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
String sql = """
|
||||||
|
UPDATE dm_delivery_state
|
||||||
|
SET delivery_state = CASE WHEN delivery_state = 0 THEN 3 ELSE delivery_state END,
|
||||||
|
delivered_server_login = CASE WHEN delivery_state = 0 THEN NULL ELSE delivered_server_login END,
|
||||||
|
next_attempt_at_ms = NULL,
|
||||||
|
last_error = CASE WHEN delivery_state = 0 THEN 'DELIVERY_EXPIRED' ELSE last_error END,
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE event_id = ? AND delivery_state = 0
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setLong(1, nowMs);
|
||||||
|
ps.setString(2, eventId);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getByEventId(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only peer confirmed that at least one recipient server accepted the message. */
|
||||||
|
public DmDeliveryStateEntry markDeliveredFromPeer(String eventId, long nowMs) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE dm_delivery_state
|
||||||
|
SET delivery_state = 1,
|
||||||
|
delivered_server_login = NULL,
|
||||||
|
next_attempt_at_ms = NULL,
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE event_id = ? AND delivery_state = 0
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, nowMs);
|
||||||
|
ps.setString(2, eventId);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
return getByEventId(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int removeByBaseKey(String baseKey) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("DELETE FROM dm_delivery_state WHERE base_key = ?")) {
|
||||||
|
ps.setString(1, baseKey);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int removeConversationBefore(String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
DELETE FROM dm_delivery_state
|
||||||
|
WHERE created_at_ms < ?
|
||||||
|
AND ((LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
|
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)))
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setLong(1, boundaryTimeMs);
|
||||||
|
ps.setString(2, fromLogin);
|
||||||
|
ps.setString(3, toLogin);
|
||||||
|
ps.setString(4, toLogin);
|
||||||
|
ps.setString(5, fromLogin);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int removeMissingMessages() throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
DELETE FROM dm_delivery_state d
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM signed_messages m WHERE m.message_key = d.outgoing_message_key
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DmDeliveryStateEntry getByEventIdForUpdate(Connection c, String eventId) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE event_id = ? FOR UPDATE")) {
|
||||||
|
ps.setString(1, eventId);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? mapRow(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateMutableState(
|
||||||
|
Connection c,
|
||||||
|
DmDeliveryStateEntry current,
|
||||||
|
int state,
|
||||||
|
String deliveredLogin,
|
||||||
|
String routesHash,
|
||||||
|
Long nextAttemptAtMs,
|
||||||
|
String lastError,
|
||||||
|
long nowMs
|
||||||
|
) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
UPDATE dm_delivery_state
|
||||||
|
SET delivery_state = ?,
|
||||||
|
delivered_server_login = ?,
|
||||||
|
recipient_routes_hash = ?,
|
||||||
|
next_attempt_at_ms = ?,
|
||||||
|
last_error = ?,
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE outgoing_message_key = ? AND event_id = ?
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setInt(1, state);
|
||||||
|
setNullableString(ps, 2, state == DmDeliveryStateEntry.DELIVERED_ONE ? normalize(deliveredLogin) : null);
|
||||||
|
setNullableString(ps, 3, normalizeBlank(routesHash));
|
||||||
|
setNullableLong(ps, 4, nextAttemptAtMs);
|
||||||
|
setNullableString(ps, 5, normalizeBlank(lastError));
|
||||||
|
ps.setLong(6, nowMs);
|
||||||
|
ps.setString(7, current.getOutgoingMessageKey());
|
||||||
|
ps.setString(8, current.getEventId());
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MergeResult merge(
|
||||||
|
int localState,
|
||||||
|
String localLogin,
|
||||||
|
String localHash,
|
||||||
|
int remoteStateRaw,
|
||||||
|
String remoteLoginRaw,
|
||||||
|
String remoteHashRaw
|
||||||
|
) {
|
||||||
|
int remoteState = normalizeState(remoteStateRaw, remoteLoginRaw);
|
||||||
|
String remoteLogin = normalize(remoteLoginRaw);
|
||||||
|
String localNormalizedLogin = normalize(localLogin);
|
||||||
|
String localRoutesHash = normalizeBlank(localHash);
|
||||||
|
String remoteRoutesHash = normalizeBlank(remoteHashRaw);
|
||||||
|
String resultHash = remoteRoutesHash != null ? remoteRoutesHash : localRoutesHash;
|
||||||
|
|
||||||
|
if (localState == DmDeliveryStateEntry.DELIVERED_ONE || localState == DmDeliveryStateEntry.DELIVERED_ALL) {
|
||||||
|
return new MergeResult(DmDeliveryStateEntry.DELIVERED, localNormalizedLogin,
|
||||||
|
localRoutesHash != null ? localRoutesHash : remoteRoutesHash);
|
||||||
|
}
|
||||||
|
if (remoteState == DmDeliveryStateEntry.DELIVERED_ONE || remoteState == DmDeliveryStateEntry.DELIVERED_ALL) {
|
||||||
|
return new MergeResult(DmDeliveryStateEntry.DELIVERED, remoteLogin, resultHash);
|
||||||
|
}
|
||||||
|
if (localState == DmDeliveryStateEntry.FAILED_FINAL || remoteState == DmDeliveryStateEntry.FAILED_FINAL) {
|
||||||
|
return new MergeResult(DmDeliveryStateEntry.FAILED_FINAL, null, resultHash);
|
||||||
|
}
|
||||||
|
return new MergeResult(DmDeliveryStateEntry.PENDING_NONE, null, resultHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int normalizeState(int state, String deliveredLogin) {
|
||||||
|
if (state < DmDeliveryStateEntry.PENDING_NONE || state > DmDeliveryStateEntry.FAILED_FINAL) {
|
||||||
|
return DmDeliveryStateEntry.PENDING_NONE;
|
||||||
|
}
|
||||||
|
if (state == DmDeliveryStateEntry.DELIVERED_ALL) return DmDeliveryStateEntry.DELIVERED;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String selectColumns() {
|
||||||
|
return """
|
||||||
|
SELECT outgoing_message_key, event_id, base_key, from_login, to_login,
|
||||||
|
incoming_message_key, created_at_ms, delivery_expires_at_ms,
|
||||||
|
delivery_state, delivered_server_login, recipient_routes_hash,
|
||||||
|
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
|
||||||
|
FROM dm_delivery_state
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
private DmDeliveryStateEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
DmDeliveryStateEntry e = new DmDeliveryStateEntry();
|
||||||
|
e.setOutgoingMessageKey(rs.getString("outgoing_message_key"));
|
||||||
|
e.setEventId(rs.getString("event_id"));
|
||||||
|
e.setBaseKey(rs.getString("base_key"));
|
||||||
|
e.setFromLogin(rs.getString("from_login"));
|
||||||
|
e.setToLogin(rs.getString("to_login"));
|
||||||
|
e.setIncomingMessageKey(rs.getString("incoming_message_key"));
|
||||||
|
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||||
|
e.setDeliveryExpiresAtMs(rs.getLong("delivery_expires_at_ms"));
|
||||||
|
e.setDeliveryState(rs.getInt("delivery_state"));
|
||||||
|
e.setDeliveredServerLogin(rs.getString("delivered_server_login"));
|
||||||
|
e.setRecipientRoutesHash(rs.getString("recipient_routes_hash"));
|
||||||
|
e.setAttemptIndex(rs.getInt("attempt_index"));
|
||||||
|
long next = rs.getLong("next_attempt_at_ms");
|
||||||
|
e.setNextAttemptAtMs(rs.wasNull() ? null : next);
|
||||||
|
long last = rs.getLong("last_attempt_at_ms");
|
||||||
|
e.setLastAttemptAtMs(rs.wasNull() ? null : last);
|
||||||
|
e.setLastError(rs.getString("last_error"));
|
||||||
|
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setNullableString(PreparedStatement ps, int index, String value) throws SQLException {
|
||||||
|
if (isBlank(value)) ps.setNull(index, Types.VARCHAR);
|
||||||
|
else ps.setString(index, value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setNullableLong(PreparedStatement ps, int index, Long value) throws SQLException {
|
||||||
|
if (value == null) ps.setNull(index, Types.BIGINT);
|
||||||
|
else ps.setLong(index, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String normalized = value.trim().toLowerCase();
|
||||||
|
return normalized.isEmpty() ? null : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeBlank(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String normalized = value.trim();
|
||||||
|
return normalized.isEmpty() ? null : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record MergeResult(int state, String deliveredLogin, String routesHash) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.MsgSubType;
|
||||||
|
import shine.db.entities.SignedMessageEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class DmDialogStateDAO {
|
||||||
|
private static volatile DmDialogStateDAO instance;
|
||||||
|
|
||||||
|
private DmDialogStateDAO() {}
|
||||||
|
|
||||||
|
public static DmDialogStateDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (DmDialogStateDAO.class) {
|
||||||
|
if (instance == null) instance = new DmDialogStateDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void bootstrapIfEmpty(Connection c) throws SQLException {
|
||||||
|
if (c == null) return;
|
||||||
|
if (hasAnyRow(c)) return;
|
||||||
|
|
||||||
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
for (PairItem pair : listConversationPairs(c)) {
|
||||||
|
refreshConversationPair(c, pair.ownerLogin(), pair.peerLogin());
|
||||||
|
}
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
if (e instanceof SQLException sqlEx) throw sqlEx;
|
||||||
|
throw new SQLException("Failed to bootstrap dm_dialog_state", e);
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(prevAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refreshConversationPair(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||||
|
String cleanOwner = normalize(ownerLogin);
|
||||||
|
String cleanPeer = normalize(peerLogin);
|
||||||
|
if (cleanOwner.isEmpty() || cleanPeer.isEmpty()) return;
|
||||||
|
if (cleanOwner.equalsIgnoreCase(cleanPeer)) return;
|
||||||
|
refreshConversation(c, cleanOwner, cleanPeer);
|
||||||
|
refreshConversation(c, cleanPeer, cleanOwner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DialogSummary> listInboxDialogs(Connection c, String ownerLogin) throws SQLException {
|
||||||
|
String cleanOwner = normalize(ownerLogin);
|
||||||
|
if (cleanOwner.isEmpty()) return List.of();
|
||||||
|
|
||||||
|
Map<String, DialogSummary> byPeer = new LinkedHashMap<>();
|
||||||
|
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CONTACT);
|
||||||
|
List<String> friends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_FRIEND);
|
||||||
|
List<String> closeFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CLOSE_FRIEND);
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT owner_login, peer_login, relation_flag, last_message_blob_b64,
|
||||||
|
last_message_time_ms, unread_count, last_read_receipt_time_ms,
|
||||||
|
updated_at_ms
|
||||||
|
FROM dm_dialog_state
|
||||||
|
WHERE LOWER(owner_login) = LOWER(?)
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, cleanOwner);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
long lastMessageTimeMs = rs.getLong("last_message_time_ms");
|
||||||
|
int unreadCount = rs.getInt("unread_count");
|
||||||
|
DialogSummary row = new DialogSummary(
|
||||||
|
rs.getString("owner_login"),
|
||||||
|
rs.getString("peer_login"),
|
||||||
|
normalizeRelationFlag(rs.getString("relation_flag")),
|
||||||
|
rs.getString("last_message_blob_b64"),
|
||||||
|
lastMessageTimeMs,
|
||||||
|
unreadCount,
|
||||||
|
rs.getLong("last_read_receipt_time_ms"),
|
||||||
|
rs.getLong("updated_at_ms"),
|
||||||
|
lastMessageTimeMs > 0 || unreadCount > 0
|
||||||
|
);
|
||||||
|
byPeer.put(normKey(row.peerLogin()), row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String peer : contacts) {
|
||||||
|
addOrUpdateSummary(byPeer, cleanOwner, peer, "contact", false);
|
||||||
|
}
|
||||||
|
for (String peer : friends) {
|
||||||
|
addOrUpdateSummary(byPeer, cleanOwner, peer, "friend", false);
|
||||||
|
}
|
||||||
|
for (String peer : closeFriends) {
|
||||||
|
addOrUpdateSummary(byPeer, cleanOwner, peer, "close_friend", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (DialogSummary row : new ArrayList<>(byPeer.values())) {
|
||||||
|
String relationFlag = resolveRelationFlag(c, cleanOwner, row.peerLogin());
|
||||||
|
byPeer.put(normKey(row.peerLogin()), new DialogSummary(
|
||||||
|
row.ownerLogin(),
|
||||||
|
row.peerLogin(),
|
||||||
|
relationFlag,
|
||||||
|
row.lastMessageBlobB64(),
|
||||||
|
row.lastMessageTimeMs(),
|
||||||
|
row.unreadCount(),
|
||||||
|
row.lastReadReceiptTimeMs(),
|
||||||
|
row.updatedAtMs(),
|
||||||
|
row.hasDialog()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DialogSummary> out = new ArrayList<>(byPeer.values());
|
||||||
|
out.sort((a, b) -> {
|
||||||
|
int cmp = Long.compare(b.lastMessageTimeMs(), a.lastMessageTimeMs());
|
||||||
|
if (cmp != 0) return cmp;
|
||||||
|
return normalize(a.peerLogin()).compareToIgnoreCase(normalize(b.peerLogin()));
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refreshFromEntry(Connection c, SignedMessageEntry entry) throws SQLException {
|
||||||
|
if (entry == null) return;
|
||||||
|
refreshConversationPair(c, entry.getFromLogin(), entry.getToLogin());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshConversation(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||||
|
DialogSummary summary = loadConversationSummary(c, ownerLogin, peerLogin);
|
||||||
|
upsert(c, summary);
|
||||||
|
if (summary.lastReadReceiptTimeMs() > 0) {
|
||||||
|
syncMessagesToWatermark(c, summary.ownerLogin(), summary.peerLogin(), summary.lastReadReceiptTimeMs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DialogSummary loadConversationSummary(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||||
|
String cleanOwner = normalize(ownerLogin);
|
||||||
|
String cleanPeer = normalize(peerLogin);
|
||||||
|
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) {
|
||||||
|
return new DialogSummary(cleanOwner, cleanPeer, "none", "", 0L, 0, 0L, System.currentTimeMillis(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
String relationFlag = resolveRelationFlag(c, cleanOwner, cleanPeer);
|
||||||
|
long existingWatermark = loadExistingWatermark(c, cleanOwner, cleanPeer);
|
||||||
|
|
||||||
|
String latestSql = """
|
||||||
|
SELECT raw_block, time_ms
|
||||||
|
FROM signed_messages
|
||||||
|
WHERE (
|
||||||
|
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
|
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
|
)
|
||||||
|
AND message_type IN (1, 2)
|
||||||
|
ORDER BY time_ms DESC, revision_time_ms DESC, reencrypted_at_ms DESC, created_at_ms DESC, message_key DESC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
String unreadSql = """
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM signed_messages
|
||||||
|
WHERE LOWER(from_login) = LOWER(?)
|
||||||
|
AND LOWER(to_login) = LOWER(?)
|
||||||
|
AND message_type = 1
|
||||||
|
AND time_ms > ?
|
||||||
|
AND (read_at_ms IS NULL OR read_at_ms <= 0)
|
||||||
|
""";
|
||||||
|
String contentReadSql = """
|
||||||
|
SELECT MAX(read_at_ms)
|
||||||
|
FROM signed_messages
|
||||||
|
WHERE (
|
||||||
|
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
|
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
|
)
|
||||||
|
AND message_type IN (1, 2)
|
||||||
|
AND read_at_ms IS NOT NULL
|
||||||
|
AND read_at_ms > 0
|
||||||
|
""";
|
||||||
|
String receiptWatermarkSql = """
|
||||||
|
SELECT MAX(time_ms)
|
||||||
|
FROM signed_messages
|
||||||
|
WHERE (
|
||||||
|
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
|
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
|
)
|
||||||
|
AND message_type IN (3, 4)
|
||||||
|
""";
|
||||||
|
|
||||||
|
String lastMessageBlobB64 = "";
|
||||||
|
long lastMessageTimeMs = 0L;
|
||||||
|
int unreadCount = 0;
|
||||||
|
long lastReadReceiptTimeMs = existingWatermark;
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(latestSql)) {
|
||||||
|
ps.setString(1, cleanOwner);
|
||||||
|
ps.setString(2, cleanPeer);
|
||||||
|
ps.setString(3, cleanPeer);
|
||||||
|
ps.setString(4, cleanOwner);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
lastMessageTimeMs = rs.getLong("time_ms");
|
||||||
|
byte[] rawBlock = rs.getBytes("raw_block");
|
||||||
|
if (rawBlock != null && rawBlock.length > 0) {
|
||||||
|
lastMessageBlobB64 = Base64.getEncoder().encodeToString(rawBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(contentReadSql)) {
|
||||||
|
ps.setString(1, cleanOwner);
|
||||||
|
ps.setString(2, cleanPeer);
|
||||||
|
ps.setString(3, cleanPeer);
|
||||||
|
ps.setString(4, cleanOwner);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
long value = rs.getLong(1);
|
||||||
|
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(receiptWatermarkSql)) {
|
||||||
|
ps.setString(1, cleanOwner);
|
||||||
|
ps.setString(2, cleanPeer);
|
||||||
|
ps.setString(3, cleanPeer);
|
||||||
|
ps.setString(4, cleanOwner);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
long value = rs.getLong(1);
|
||||||
|
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(unreadSql)) {
|
||||||
|
ps.setString(1, cleanPeer);
|
||||||
|
ps.setString(2, cleanOwner);
|
||||||
|
ps.setLong(3, lastReadReceiptTimeMs);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) unreadCount = rs.getInt(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DialogSummary(
|
||||||
|
cleanOwner,
|
||||||
|
cleanPeer,
|
||||||
|
relationFlag,
|
||||||
|
lastMessageBlobB64,
|
||||||
|
lastMessageTimeMs,
|
||||||
|
unreadCount,
|
||||||
|
lastReadReceiptTimeMs,
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
lastMessageTimeMs > 0 || unreadCount > 0 || lastReadReceiptTimeMs > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void upsert(Connection c, DialogSummary summary) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
INSERT INTO dm_dialog_state (
|
||||||
|
owner_login, peer_login, relation_flag, last_message_blob_b64,
|
||||||
|
last_message_time_ms, unread_count, last_read_receipt_time_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (owner_login, peer_login) DO UPDATE SET
|
||||||
|
relation_flag = EXCLUDED.relation_flag,
|
||||||
|
last_message_blob_b64 = EXCLUDED.last_message_blob_b64,
|
||||||
|
last_message_time_ms = EXCLUDED.last_message_time_ms,
|
||||||
|
unread_count = EXCLUDED.unread_count,
|
||||||
|
last_read_receipt_time_ms = EXCLUDED.last_read_receipt_time_ms,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, summary.ownerLogin());
|
||||||
|
ps.setString(2, summary.peerLogin());
|
||||||
|
ps.setString(3, normalizeRelationFlag(summary.relationFlag()));
|
||||||
|
ps.setString(4, summary.lastMessageBlobB64());
|
||||||
|
ps.setLong(5, summary.lastMessageTimeMs());
|
||||||
|
ps.setInt(6, summary.unreadCount());
|
||||||
|
ps.setLong(7, summary.lastReadReceiptTimeMs());
|
||||||
|
ps.setLong(8, summary.updatedAtMs());
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveRelationFlag(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||||
|
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
|
||||||
|
return "close_friend";
|
||||||
|
}
|
||||||
|
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_FRIEND)) {
|
||||||
|
return "friend";
|
||||||
|
}
|
||||||
|
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CONTACT)) {
|
||||||
|
return "contact";
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasAnyRow(Connection c) throws SQLException {
|
||||||
|
try (Statement st = c.createStatement();
|
||||||
|
ResultSet rs = st.executeQuery("SELECT 1 FROM dm_dialog_state LIMIT 1")) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PairItem> listConversationPairs(Connection c) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT DISTINCT owner_login, peer_login
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
m.target_login AS owner_login,
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(m.target_login) = LOWER(m.to_login) THEN m.from_login
|
||||||
|
ELSE m.to_login
|
||||||
|
END AS peer_login
|
||||||
|
FROM signed_messages m
|
||||||
|
WHERE m.message_type IN (1, 2, 3, 4, 5, 6, 7, 8)
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT m.from_login AS owner_login, m.to_login AS peer_login
|
||||||
|
FROM signed_messages m
|
||||||
|
WHERE m.message_type IN (5, 6, 7, 8)
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT m.to_login AS owner_login, m.from_login AS peer_login
|
||||||
|
FROM signed_messages m
|
||||||
|
WHERE m.message_type IN (5, 6, 7, 8)
|
||||||
|
) pairs
|
||||||
|
WHERE owner_login IS NOT NULL
|
||||||
|
AND peer_login IS NOT NULL
|
||||||
|
AND BTRIM(owner_login) <> ''
|
||||||
|
AND BTRIM(peer_login) <> ''
|
||||||
|
AND LOWER(owner_login) <> LOWER(peer_login)
|
||||||
|
ORDER BY owner_login, peer_login
|
||||||
|
""";
|
||||||
|
List<PairItem> out = new ArrayList<>();
|
||||||
|
try (Statement st = c.createStatement();
|
||||||
|
ResultSet rs = st.executeQuery(sql)) {
|
||||||
|
while (rs.next()) {
|
||||||
|
String ownerLogin = rs.getString("owner_login");
|
||||||
|
String peerLogin = rs.getString("peer_login");
|
||||||
|
if (normalize(ownerLogin).isEmpty() || normalize(peerLogin).isEmpty()) continue;
|
||||||
|
out.add(new PairItem(ownerLogin, peerLogin));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addOrUpdateSummary(Map<String, DialogSummary> map, String ownerLogin, String peerLogin, String relationFlag, boolean hasDialog) {
|
||||||
|
String owner = normalize(ownerLogin);
|
||||||
|
String peer = normalize(peerLogin);
|
||||||
|
if (owner.isEmpty() || peer.isEmpty() || owner.equalsIgnoreCase(peer)) return;
|
||||||
|
String key = normKey(peer);
|
||||||
|
DialogSummary current = map.get(key);
|
||||||
|
if (current == null) {
|
||||||
|
map.put(key, new DialogSummary(owner, peer, relationFlag, "", 0L, 0, 0L, System.currentTimeMillis(), hasDialog));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String nextRelation = current.relationFlag();
|
||||||
|
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
|
||||||
|
nextRelation = "close_friend";
|
||||||
|
} else if ("friend".equalsIgnoreCase(relationFlag) || "friend".equalsIgnoreCase(nextRelation)) {
|
||||||
|
nextRelation = "friend";
|
||||||
|
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
|
||||||
|
nextRelation = "contact";
|
||||||
|
} else {
|
||||||
|
nextRelation = normalizeRelationFlag(nextRelation);
|
||||||
|
}
|
||||||
|
map.put(key, new DialogSummary(
|
||||||
|
current.ownerLogin().isEmpty() ? owner : current.ownerLogin(),
|
||||||
|
peer,
|
||||||
|
nextRelation,
|
||||||
|
current.lastMessageBlobB64(),
|
||||||
|
current.lastMessageTimeMs(),
|
||||||
|
current.unreadCount(),
|
||||||
|
current.lastReadReceiptTimeMs(),
|
||||||
|
current.updatedAtMs(),
|
||||||
|
current.hasDialog() || hasDialog
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void syncMessagesToWatermark(Connection c, String ownerLogin, String peerLogin, long watermark) throws SQLException {
|
||||||
|
if (watermark <= 0) return;
|
||||||
|
String cleanOwner = normalize(ownerLogin);
|
||||||
|
String cleanPeer = normalize(peerLogin);
|
||||||
|
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) return;
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE signed_messages
|
||||||
|
SET read_at_ms = CASE
|
||||||
|
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
|
||||||
|
WHEN read_at_ms > ? THEN read_at_ms
|
||||||
|
ELSE read_at_ms
|
||||||
|
END
|
||||||
|
WHERE (
|
||||||
|
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
|
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
|
)
|
||||||
|
AND message_type IN (1, 2)
|
||||||
|
AND time_ms <= ?
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, watermark);
|
||||||
|
ps.setLong(2, watermark);
|
||||||
|
ps.setString(3, cleanOwner);
|
||||||
|
ps.setString(4, cleanPeer);
|
||||||
|
ps.setString(5, cleanPeer);
|
||||||
|
ps.setString(6, cleanOwner);
|
||||||
|
ps.setLong(7, watermark);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long loadExistingWatermark(Connection c, String ownerLogin, String peerLogin) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT last_read_receipt_time_ms
|
||||||
|
FROM dm_dialog_state
|
||||||
|
WHERE LOWER(owner_login) = LOWER(?)
|
||||||
|
AND LOWER(peer_login) = LOWER(?)
|
||||||
|
LIMIT 1
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, peerLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return 0L;
|
||||||
|
long value = rs.getLong(1);
|
||||||
|
return rs.wasNull() ? 0L : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalize(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normKey(String value) {
|
||||||
|
return normalize(value).toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRelationFlag(String value) {
|
||||||
|
String clean = normalize(value).toLowerCase(Locale.ROOT);
|
||||||
|
if ("close_friend".equals(clean) || "friend".equals(clean) || "contact".equals(clean)) return clean;
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DialogSummary(
|
||||||
|
String ownerLogin,
|
||||||
|
String peerLogin,
|
||||||
|
String relationFlag,
|
||||||
|
String lastMessageBlobB64,
|
||||||
|
long lastMessageTimeMs,
|
||||||
|
int unreadCount,
|
||||||
|
long lastReadReceiptTimeMs,
|
||||||
|
long updatedAtMs,
|
||||||
|
boolean hasDialog
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private record PairItem(String ownerLogin, String peerLogin) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.entities.DmSyncOutboxEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Outbox событий DM для единственного второго access-сервера пользователя. */
|
||||||
|
public final class DmSyncOutboxDAO {
|
||||||
|
private static volatile DmSyncOutboxDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private DmSyncOutboxDAO() {}
|
||||||
|
|
||||||
|
public static DmSyncOutboxDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (DmSyncOutboxDAO.class) {
|
||||||
|
if (instance == null) instance = new DmSyncOutboxDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void upsert(
|
||||||
|
String ownerLogin,
|
||||||
|
String primaryMessageKey,
|
||||||
|
String eventId,
|
||||||
|
String secondaryMessageKey,
|
||||||
|
boolean synced,
|
||||||
|
long createdAtMs
|
||||||
|
) throws SQLException {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO dm_sync_outbox (
|
||||||
|
owner_login, primary_message_key, event_id, secondary_message_key,
|
||||||
|
synced, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (LOWER(?), ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (owner_login, primary_message_key) DO UPDATE SET
|
||||||
|
event_id = EXCLUDED.event_id,
|
||||||
|
secondary_message_key = EXCLUDED.secondary_message_key,
|
||||||
|
synced = CASE
|
||||||
|
WHEN dm_sync_outbox.event_id = EXCLUDED.event_id
|
||||||
|
THEN dm_sync_outbox.synced OR EXCLUDED.synced
|
||||||
|
ELSE EXCLUDED.synced
|
||||||
|
END,
|
||||||
|
created_at_ms = CASE
|
||||||
|
WHEN dm_sync_outbox.event_id = EXCLUDED.event_id
|
||||||
|
THEN dm_sync_outbox.created_at_ms
|
||||||
|
ELSE EXCLUDED.created_at_ms
|
||||||
|
END,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, primaryMessageKey);
|
||||||
|
ps.setString(3, eventId);
|
||||||
|
if (secondaryMessageKey == null || secondaryMessageKey.isBlank()) ps.setNull(4, Types.VARCHAR);
|
||||||
|
else ps.setString(4, secondaryMessageKey.trim());
|
||||||
|
ps.setBoolean(5, synced);
|
||||||
|
ps.setLong(6, createdAtMs);
|
||||||
|
ps.setLong(7, now);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DmSyncOutboxEntry> listUnsynced(String ownerLogin, int limit) throws SQLException {
|
||||||
|
return listUnsynced(ownerLogin, 0L, "", limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DmSyncOutboxEntry> listUnsynced(
|
||||||
|
String ownerLogin, long afterCreatedAtMs, String afterPrimaryMessageKey, int limit
|
||||||
|
) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT owner_login, primary_message_key, event_id, secondary_message_key,
|
||||||
|
synced, created_at_ms, updated_at_ms
|
||||||
|
FROM dm_sync_outbox
|
||||||
|
WHERE LOWER(owner_login) = LOWER(?) AND synced = FALSE
|
||||||
|
AND (created_at_ms > ? OR (created_at_ms = ? AND primary_message_key > ?))
|
||||||
|
ORDER BY created_at_ms ASC, primary_message_key ASC
|
||||||
|
LIMIT ?
|
||||||
|
""";
|
||||||
|
List<DmSyncOutboxEntry> out = new ArrayList<>();
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setLong(2, Math.max(0L, afterCreatedAtMs));
|
||||||
|
ps.setLong(3, Math.max(0L, afterCreatedAtMs));
|
||||||
|
ps.setString(4, afterPrimaryMessageKey == null ? "" : afterPrimaryMessageKey);
|
||||||
|
ps.setInt(5, Math.max(1, limit));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasUnsyncedForServer(String serverLogin) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT 1
|
||||||
|
FROM dm_sync_outbox o
|
||||||
|
JOIN user_access_servers_current r
|
||||||
|
ON LOWER(r.user_login) = LOWER(o.owner_login)
|
||||||
|
WHERE o.synced = FALSE AND LOWER(r.server_login) = LOWER(?)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_access_servers_current peer
|
||||||
|
WHERE LOWER(peer.user_login) = LOWER(o.owner_login)
|
||||||
|
AND LOWER(peer.server_login) <> LOWER(?)
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, serverLogin);
|
||||||
|
ps.setString(2, serverLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> listUnsyncedOwnersForServer(String serverLogin) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT DISTINCT o.owner_login
|
||||||
|
FROM dm_sync_outbox o
|
||||||
|
JOIN user_access_servers_current r
|
||||||
|
ON LOWER(r.user_login) = LOWER(o.owner_login)
|
||||||
|
WHERE o.synced = FALSE AND LOWER(r.server_login) = LOWER(?)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_access_servers_current peer
|
||||||
|
WHERE LOWER(peer.user_login) = LOWER(o.owner_login)
|
||||||
|
AND LOWER(peer.server_login) <> LOWER(?)
|
||||||
|
)
|
||||||
|
ORDER BY o.owner_login
|
||||||
|
""";
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, serverLogin);
|
||||||
|
ps.setString(2, serverLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) result.add(rs.getString("owner_login"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int markSynced(String ownerLogin, String eventId) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
UPDATE dm_sync_outbox
|
||||||
|
SET synced = TRUE, updated_at_ms = ?
|
||||||
|
WHERE LOWER(owner_login) = LOWER(?) AND event_id = ?
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setLong(1, System.currentTimeMillis());
|
||||||
|
ps.setString(2, ownerLogin);
|
||||||
|
ps.setString(3, eventId);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int markSynced(String ownerLogin, List<String> eventIds) throws SQLException {
|
||||||
|
if (eventIds == null || eventIds.isEmpty()) return 0;
|
||||||
|
int total = 0;
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE dm_sync_outbox
|
||||||
|
SET synced = TRUE, updated_at_ms = ?
|
||||||
|
WHERE LOWER(owner_login) = LOWER(?) AND event_id = ?
|
||||||
|
""")) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
for (String eventId : eventIds) {
|
||||||
|
if (eventId == null || eventId.isBlank()) continue;
|
||||||
|
ps.setLong(1, now);
|
||||||
|
ps.setString(2, ownerLogin);
|
||||||
|
ps.setString(3, eventId.trim());
|
||||||
|
ps.addBatch();
|
||||||
|
}
|
||||||
|
for (int changed : ps.executeBatch()) if (changed > 0) total += changed;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int markAllUnsynced(String ownerLogin) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
UPDATE dm_sync_outbox
|
||||||
|
SET synced = FALSE, updated_at_ms = ?
|
||||||
|
WHERE LOWER(owner_login) = LOWER(?)
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setLong(1, System.currentTimeMillis());
|
||||||
|
ps.setString(2, ownerLogin);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int markAllUnsynced() throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("UPDATE dm_sync_outbox SET synced = FALSE, updated_at_ms = ?")) {
|
||||||
|
ps.setLong(1, System.currentTimeMillis());
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int removeMissingMessages() throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
DELETE FROM dm_sync_outbox o
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM signed_messages m WHERE m.message_key = o.primary_message_key
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DmSyncOutboxEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
DmSyncOutboxEntry e = new DmSyncOutboxEntry();
|
||||||
|
e.setOwnerLogin(rs.getString("owner_login"));
|
||||||
|
e.setPrimaryMessageKey(rs.getString("primary_message_key"));
|
||||||
|
e.setEventId(rs.getString("event_id"));
|
||||||
|
e.setSecondaryMessageKey(rs.getString("secondary_message_key"));
|
||||||
|
e.setSynced(rs.getBoolean("synced"));
|
||||||
|
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||||
|
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.entities.DmSyncPeerStateEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
public final class DmSyncPeerStateDAO {
|
||||||
|
private static volatile DmSyncPeerStateDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private DmSyncPeerStateDAO() {}
|
||||||
|
|
||||||
|
public static DmSyncPeerStateDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (DmSyncPeerStateDAO.class) {
|
||||||
|
if (instance == null) instance = new DmSyncPeerStateDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DmSyncPeerStateEntry getOrCreate(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||||
|
DmSyncPeerStateEntry existing = get(ownerLogin, remoteServerLogin);
|
||||||
|
if (existing != null) {
|
||||||
|
if (remoteServerUrl != null && !remoteServerUrl.isBlank()
|
||||||
|
&& !remoteServerUrl.equals(existing.getRemoteServerUrl())) {
|
||||||
|
touchRemoteUrl(ownerLogin, remoteServerLogin, remoteServerUrl);
|
||||||
|
existing.setRemoteServerUrl(remoteServerUrl);
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
long nowMs = System.currentTimeMillis();
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
INSERT INTO dm_sync_peer_state (
|
||||||
|
owner_login, remote_server_login, remote_server_url,
|
||||||
|
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||||
|
last_sync_at_ms, last_error, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, remoteServerLogin);
|
||||||
|
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
|
||||||
|
ps.setLong(4, nowMs);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
existing = get(ownerLogin, remoteServerLogin);
|
||||||
|
if (existing == null) {
|
||||||
|
throw new SQLException("Failed to create dm_sync_peer_state row");
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateSuccess(
|
||||||
|
String ownerLogin,
|
||||||
|
String remoteServerLogin,
|
||||||
|
String remoteServerUrl,
|
||||||
|
long cursorStoredAtMs,
|
||||||
|
String cursorMessageKey,
|
||||||
|
boolean bootstrapCompleted
|
||||||
|
) throws SQLException {
|
||||||
|
long nowMs = System.currentTimeMillis();
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
INSERT INTO dm_sync_peer_state (
|
||||||
|
owner_login, remote_server_login, remote_server_url,
|
||||||
|
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||||
|
last_sync_at_ms, last_error, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
|
||||||
|
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||||
|
remote_server_url = EXCLUDED.remote_server_url,
|
||||||
|
cursor_stored_at_ms = EXCLUDED.cursor_stored_at_ms,
|
||||||
|
cursor_message_key = EXCLUDED.cursor_message_key,
|
||||||
|
bootstrap_completed = EXCLUDED.bootstrap_completed,
|
||||||
|
last_sync_at_ms = EXCLUDED.last_sync_at_ms,
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, remoteServerLogin);
|
||||||
|
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
|
||||||
|
ps.setLong(4, Math.max(0L, cursorStoredAtMs));
|
||||||
|
ps.setString(5, cursorMessageKey == null ? "" : cursorMessageKey);
|
||||||
|
ps.setBoolean(6, bootstrapCompleted);
|
||||||
|
ps.setLong(7, nowMs);
|
||||||
|
ps.setLong(8, nowMs);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException {
|
||||||
|
long nowMs = System.currentTimeMillis();
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
INSERT INTO dm_sync_peer_state (
|
||||||
|
owner_login, remote_server_login, remote_server_url,
|
||||||
|
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||||
|
last_sync_at_ms, last_error, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?)
|
||||||
|
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||||
|
remote_server_url = EXCLUDED.remote_server_url,
|
||||||
|
last_error = EXCLUDED.last_error,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, remoteServerLogin);
|
||||||
|
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
|
||||||
|
ps.setString(4, truncate(error));
|
||||||
|
ps.setLong(5, nowMs);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DmSyncPeerStateEntry get(String ownerLogin, String remoteServerLogin) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT owner_login, remote_server_login, remote_server_url,
|
||||||
|
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||||
|
last_sync_at_ms, last_error, updated_at_ms
|
||||||
|
FROM dm_sync_peer_state
|
||||||
|
WHERE LOWER(owner_login) = LOWER(?)
|
||||||
|
AND LOWER(remote_server_login) = LOWER(?)
|
||||||
|
LIMIT 1
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, remoteServerLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
DmSyncPeerStateEntry e = new DmSyncPeerStateEntry();
|
||||||
|
e.setOwnerLogin(rs.getString("owner_login"));
|
||||||
|
e.setRemoteServerLogin(rs.getString("remote_server_login"));
|
||||||
|
e.setRemoteServerUrl(rs.getString("remote_server_url"));
|
||||||
|
e.setCursorStoredAtMs(rs.getLong("cursor_stored_at_ms"));
|
||||||
|
e.setCursorMessageKey(rs.getString("cursor_message_key"));
|
||||||
|
e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed"));
|
||||||
|
long lastSyncAt = rs.getLong("last_sync_at_ms");
|
||||||
|
e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAt);
|
||||||
|
e.setLastError(rs.getString("last_error"));
|
||||||
|
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void touchRemoteUrl(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE dm_sync_peer_state
|
||||||
|
SET remote_server_url = ?, updated_at_ms = ?
|
||||||
|
WHERE LOWER(owner_login) = LOWER(?)
|
||||||
|
AND LOWER(remote_server_login) = LOWER(?)
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, remoteServerUrl);
|
||||||
|
ps.setLong(2, System.currentTimeMillis());
|
||||||
|
ps.setString(3, ownerLogin);
|
||||||
|
ps.setString(4, remoteServerLogin);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String clean = value.trim();
|
||||||
|
if (clean.length() <= 500) return clean;
|
||||||
|
return clean.substring(0, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
import shine.db.entities.EspPairingRequestEntry;
|
import shine.db.entities.EspPairingRequestEntry;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
@@ -13,7 +13,7 @@ import java.util.List;
|
|||||||
public final class EspPairingRequestsDAO {
|
public final class EspPairingRequestsDAO {
|
||||||
|
|
||||||
private static volatile EspPairingRequestsDAO instance;
|
private static volatile EspPairingRequestsDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private EspPairingRequestsDAO() { }
|
private EspPairingRequestsDAO() { }
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ public final class EspPairingRequestsDAO {
|
|||||||
payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason,
|
payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason,
|
||||||
approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver
|
approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver
|
||||||
FROM esp_pairing_requests
|
FROM esp_pairing_requests
|
||||||
WHERE login = ? COLLATE NOCASE
|
WHERE LOWER(login) = LOWER(?)
|
||||||
AND expires_at_ms > ?
|
AND expires_at_ms > ?
|
||||||
AND status = 'created'
|
AND status = 'created'
|
||||||
ORDER BY created_at_ms DESC
|
ORDER BY created_at_ms DESC
|
||||||
@@ -131,7 +131,7 @@ public final class EspPairingRequestsDAO {
|
|||||||
StringBuilder sql = new StringBuilder("""
|
StringBuilder sql = new StringBuilder("""
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM esp_pairing_requests
|
FROM esp_pairing_requests
|
||||||
WHERE login = ? COLLATE NOCASE
|
WHERE LOWER(login) = LOWER(?)
|
||||||
AND created_at_ms >= ?
|
AND created_at_ms >= ?
|
||||||
AND status IN (
|
AND status IN (
|
||||||
""");
|
""");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
import shine.db.entities.EspPairingSettingsEntry;
|
import shine.db.entities.EspPairingSettingsEntry;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
@@ -11,7 +11,7 @@ import java.sql.SQLException;
|
|||||||
public final class EspPairingSettingsDAO {
|
public final class EspPairingSettingsDAO {
|
||||||
|
|
||||||
private static volatile EspPairingSettingsDAO instance;
|
private static volatile EspPairingSettingsDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private EspPairingSettingsDAO() { }
|
private EspPairingSettingsDAO() { }
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ public final class EspPairingSettingsDAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT login, enabled, password_hash, ttl_seconds, failed_attempts, first_failed_at_ms, blocked_until_ms, updated_at_ms
|
SELECT login, enabled, password_hash, ttl_seconds, failed_attempts, first_failed_at_ms, blocked_until_ms, updated_at_ms
|
||||||
FROM esp_pairing_settings
|
FROM esp_pairing_settings
|
||||||
WHERE login = ? COLLATE NOCASE
|
WHERE LOWER(login) = LOWER(?)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
import shine.db.entities.IpGeoCacheEntry;
|
import shine.db.entities.IpGeoCacheEntry;
|
||||||
|
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
@@ -20,7 +20,7 @@ import java.sql.*;
|
|||||||
public final class IpGeoCacheDAO {
|
public final class IpGeoCacheDAO {
|
||||||
|
|
||||||
private static volatile IpGeoCacheDAO instance;
|
private static volatile IpGeoCacheDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private IpGeoCacheDAO() { }
|
private IpGeoCacheDAO() { }
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
import shine.db.entities.PushTokenEntry;
|
import shine.db.entities.PushTokenEntry;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
@@ -11,7 +11,7 @@ import java.util.List;
|
|||||||
|
|
||||||
public final class PushTokensDAO {
|
public final class PushTokensDAO {
|
||||||
private static volatile PushTokensDAO instance;
|
private static volatile PushTokensDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private PushTokensDAO() {}
|
private PushTokensDAO() {}
|
||||||
|
|
||||||
|
|||||||
-47
@@ -1,47 +0,0 @@
|
|||||||
package shine.db.dao;
|
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
|
||||||
import shine.db.entities.SignedDirectMessageHistoryEntry;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.PreparedStatement;
|
|
||||||
|
|
||||||
public final class SignedDirectMessagesHistoryDAO {
|
|
||||||
private static volatile SignedDirectMessagesHistoryDAO instance;
|
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
|
||||||
|
|
||||||
private SignedDirectMessagesHistoryDAO() {}
|
|
||||||
|
|
||||||
public static SignedDirectMessagesHistoryDAO getInstance() {
|
|
||||||
if (instance == null) {
|
|
||||||
synchronized (SignedDirectMessagesHistoryDAO.class) {
|
|
||||||
if (instance == null) instance = new SignedDirectMessagesHistoryDAO();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void insert(SignedDirectMessageHistoryEntry e) throws Exception {
|
|
||||||
try (Connection c = db.getConnection()) {
|
|
||||||
String sql = """
|
|
||||||
INSERT INTO signed_direct_messages_history (
|
|
||||||
message_id, from_login, to_login, target_mode, target_session_id,
|
|
||||||
message_type, time_ms, nonce, raw_packet, created_at_ms
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""";
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, e.getMessageId());
|
|
||||||
ps.setString(2, e.getFromLogin());
|
|
||||||
ps.setString(3, e.getToLogin());
|
|
||||||
ps.setInt(4, e.getTargetMode());
|
|
||||||
ps.setString(5, e.getTargetSessionId());
|
|
||||||
ps.setInt(6, e.getMessageType());
|
|
||||||
ps.setLong(7, e.getTimeMs());
|
|
||||||
ps.setLong(8, e.getNonce());
|
|
||||||
ps.setBytes(9, e.getRawPacket());
|
|
||||||
ps.setLong(10, e.getCreatedAtMs());
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package shine.db.dao;
|
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.PreparedStatement;
|
|
||||||
|
|
||||||
public final class SignedDmReplayDAO {
|
|
||||||
private static volatile SignedDmReplayDAO instance;
|
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
|
||||||
|
|
||||||
private SignedDmReplayDAO() {}
|
|
||||||
|
|
||||||
public static SignedDmReplayDAO getInstance() {
|
|
||||||
if (instance == null) {
|
|
||||||
synchronized (SignedDmReplayDAO.class) {
|
|
||||||
if (instance == null) instance = new SignedDmReplayDAO();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean registerUnique(String fromLogin, long timeMs, long nonce, long nowMs) throws Exception {
|
|
||||||
cleanupExpired(nowMs - 15L * 60L * 1000L);
|
|
||||||
try (Connection c = db.getConnection()) {
|
|
||||||
String sql = """
|
|
||||||
INSERT OR IGNORE INTO signed_direct_message_replay (
|
|
||||||
from_login, time_ms, nonce, created_at_ms
|
|
||||||
) VALUES (?, ?, ?, ?)
|
|
||||||
""";
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
ps.setString(1, fromLogin);
|
|
||||||
ps.setLong(2, timeMs);
|
|
||||||
ps.setLong(3, nonce);
|
|
||||||
ps.setLong(4, nowMs);
|
|
||||||
return ps.executeUpdate() > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void cleanupExpired(long minCreatedAtMs) throws Exception {
|
|
||||||
try (Connection c = db.getConnection()) {
|
|
||||||
String sql = "DELETE FROM signed_direct_message_replay WHERE created_at_ms < ?";
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
ps.setLong(1, minCreatedAtMs);
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+161
-117
@@ -1,7 +1,7 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
import shine.db.entities.SignedMessageV2Entry;
|
import shine.db.entities.SignedMessageEntry;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
@@ -11,9 +11,7 @@ import java.sql.Statement;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public final class SignedMessagesV2DAO {
|
public final class SignedMessagesDAO {
|
||||||
private static final int SQLITE_BUSY_MAX_RETRIES = 6;
|
|
||||||
private static final long SQLITE_BUSY_RETRY_BASE_DELAY_MS = 40L;
|
|
||||||
|
|
||||||
public enum ApplyStatus {
|
public enum ApplyStatus {
|
||||||
APPLIED,
|
APPLIED,
|
||||||
@@ -26,39 +24,41 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static volatile SignedMessagesV2DAO instance;
|
private static volatile SignedMessagesDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private SignedMessagesV2DAO() {}
|
private SignedMessagesDAO() {}
|
||||||
|
|
||||||
public static SignedMessagesV2DAO getInstance() {
|
public static SignedMessagesDAO getInstance() {
|
||||||
if (instance == null) {
|
if (instance == null) {
|
||||||
synchronized (SignedMessagesV2DAO.class) {
|
synchronized (SignedMessagesDAO.class) {
|
||||||
if (instance == null) instance = new SignedMessagesV2DAO();
|
if (instance == null) instance = new SignedMessagesDAO();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception {
|
public ApplyStatus insertIfAbsent(SignedMessageEntry e) throws Exception {
|
||||||
return withBusyRetry(() -> {
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) {
|
if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) {
|
||||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||||
}
|
}
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT OR IGNORE INTO signed_messages_v2 (
|
INSERT INTO signed_messages (
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
bindSignedMessage(ps, e);
|
bindSignedMessage(ps, e);
|
||||||
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
if (status.applied()) {
|
if (status.applied()) {
|
||||||
markMessageReadByReceipt(c, e);
|
markMessageReadByReceipt(c, e);
|
||||||
|
DmDialogStateDAO.getInstance().refreshFromEntry(c, e);
|
||||||
}
|
}
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
public boolean insertPairBothOrNothing(SignedMessageEntry first, SignedMessageEntry second) throws Exception {
|
||||||
return withBusyRetry(() -> {
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
@@ -77,6 +77,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
if (insertedFirst == 1 && insertedSecond == 1) {
|
if (insertedFirst == 1 && insertedSecond == 1) {
|
||||||
markMessageReadByReceipt(c, first);
|
markMessageReadByReceipt(c, first);
|
||||||
markMessageReadByReceipt(c, second);
|
markMessageReadByReceipt(c, second);
|
||||||
|
DmDialogStateDAO.getInstance().refreshConversationPair(c, first.getFromLogin(), first.getToLogin());
|
||||||
c.commit();
|
c.commit();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -95,7 +96,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
|
public ApplyStatus upsertContentPair(SignedMessageEntry incoming, SignedMessageEntry outgoing) throws Exception {
|
||||||
return withBusyRetry(() -> {
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
@@ -123,6 +124,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
markMessageReadByReceipt(c, outgoing);
|
markMessageReadByReceipt(c, outgoing);
|
||||||
resetDeliveryRows(c, incoming.getMessageKey());
|
resetDeliveryRows(c, incoming.getMessageKey());
|
||||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||||
|
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
return ApplyStatus.APPLIED;
|
return ApplyStatus.APPLIED;
|
||||||
@@ -136,7 +138,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception {
|
public ApplyStatus upsertIncomingCopy(SignedMessageEntry incoming) throws Exception {
|
||||||
return withBusyRetry(() -> {
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
@@ -161,6 +163,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
upsertMessage(c, incoming);
|
upsertMessage(c, incoming);
|
||||||
markMessageReadByReceipt(c, incoming);
|
markMessageReadByReceipt(c, incoming);
|
||||||
resetDeliveryRows(c, incoming.getMessageKey());
|
resetDeliveryRows(c, incoming.getMessageKey());
|
||||||
|
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
|
||||||
c.commit();
|
c.commit();
|
||||||
return ApplyStatus.APPLIED;
|
return ApplyStatus.APPLIED;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
@@ -173,7 +176,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception {
|
public ApplyStatus applyDeleteMessage(SignedMessageEntry tombstone) throws Exception {
|
||||||
return withBusyRetry(() -> {
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
@@ -191,6 +194,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
||||||
upsertMessage(c, tombstone);
|
upsertMessage(c, tombstone);
|
||||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||||
|
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
return ApplyStatus.APPLIED;
|
return ApplyStatus.APPLIED;
|
||||||
@@ -204,7 +208,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception {
|
public ApplyStatus applyDeleteConversation(SignedMessageEntry tombstone) throws Exception {
|
||||||
return withBusyRetry(() -> {
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
@@ -219,6 +223,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
||||||
upsertMessage(c, tombstone);
|
upsertMessage(c, tombstone);
|
||||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||||
|
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
return ApplyStatus.APPLIED;
|
return ApplyStatus.APPLIED;
|
||||||
@@ -232,7 +237,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
public SignedMessageEntry getByMessageKey(String messageKey) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
@@ -240,9 +245,9 @@ public final class SignedMessagesV2DAO {
|
|||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
FROM signed_messages_v2
|
FROM %s
|
||||||
WHERE message_key = ?
|
WHERE message_key = ?
|
||||||
""";
|
""".formatted(messagesTable());
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, messageKey);
|
ps.setString(1, messageKey);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
@@ -253,7 +258,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public SignedMessageV2Entry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception {
|
public SignedMessageEntry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
return getLatestConversationDelete(c, fromLogin, toLogin);
|
return getLatestConversationDelete(c, fromLogin, toLogin);
|
||||||
}
|
}
|
||||||
@@ -268,9 +273,10 @@ public final class SignedMessagesV2DAO {
|
|||||||
withBusyRetry(() -> {
|
withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
INSERT INTO signed_message_session_delivery (
|
||||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||||
) VALUES (?, ?, 0, NULL, ?)
|
) VALUES (?, ?, 0, NULL, ?)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
for (String sessionId : sessionIds) {
|
for (String sessionId : sessionIds) {
|
||||||
@@ -314,22 +320,23 @@ public final class SignedMessagesV2DAO {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId) throws Exception {
|
public List<SignedMessageEntry> listPendingForSession(String login, String sessionId) throws Exception {
|
||||||
return withBusyRetry(() -> {
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
String fillSql = """
|
String fillSql = """
|
||||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
INSERT INTO signed_message_session_delivery (
|
||||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||||
)
|
)
|
||||||
SELECT m.message_key, ?, 0, NULL, ?
|
SELECT m.message_key, ?, 0, NULL, ?
|
||||||
FROM signed_messages_v2 m
|
FROM %s m
|
||||||
WHERE (
|
WHERE (
|
||||||
(m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE)
|
(m.message_type IN (1, 3) AND LOWER(m.to_login) = LOWER(?))
|
||||||
OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE)
|
OR (m.message_type IN (2, 4) AND LOWER(m.from_login) = LOWER(?))
|
||||||
OR (m.message_type IN (5, 6, 7, 8)
|
OR (m.message_type IN (5, 6, 7, 8)
|
||||||
AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE))
|
AND (LOWER(m.from_login) = LOWER(?) OR LOWER(m.to_login) = LOWER(?)))
|
||||||
)
|
)
|
||||||
""";
|
ON CONFLICT DO NOTHING
|
||||||
|
""".formatted(messagesTable());
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
||||||
ps.setString(1, sessionId);
|
ps.setString(1, sessionId);
|
||||||
@@ -347,13 +354,13 @@ public final class SignedMessagesV2DAO {
|
|||||||
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
|
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
|
||||||
m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id,
|
m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id,
|
||||||
m.receipt_ref_base_key, m.receipt_ref_type, m.read_at_ms
|
m.receipt_ref_base_key, m.receipt_ref_type, m.read_at_ms
|
||||||
FROM signed_messages_v2 m
|
FROM %s m
|
||||||
JOIN signed_message_session_delivery d
|
JOIN signed_message_session_delivery d
|
||||||
ON d.message_key = m.message_key
|
ON d.message_key = m.message_key
|
||||||
WHERE d.session_id = ? AND d.delivered = 0
|
WHERE d.session_id = ? AND d.delivered = 0
|
||||||
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC
|
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC
|
||||||
""";
|
""".formatted(messagesTable());
|
||||||
List<SignedMessageV2Entry> out = new ArrayList<>();
|
List<SignedMessageEntry> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, sessionId);
|
ps.setString(1, sessionId);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
@@ -365,7 +372,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<SignedMessageV2Entry> listConversationPage(
|
public List<SignedMessageEntry> listConversationPage(
|
||||||
String login,
|
String login,
|
||||||
String peerLogin,
|
String peerLogin,
|
||||||
long beforeTimeMs,
|
long beforeTimeMs,
|
||||||
@@ -379,12 +386,12 @@ public final class SignedMessagesV2DAO {
|
|||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
FROM signed_messages_v2
|
FROM %s
|
||||||
WHERE target_login = ? COLLATE NOCASE
|
WHERE LOWER(target_login) = LOWER(?)
|
||||||
AND message_type IN (1, 2)
|
AND message_type IN (1, 2)
|
||||||
AND (
|
AND (
|
||||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
)
|
)
|
||||||
AND (
|
AND (
|
||||||
? <= 0
|
? <= 0
|
||||||
@@ -393,8 +400,8 @@ public final class SignedMessagesV2DAO {
|
|||||||
)
|
)
|
||||||
ORDER BY time_ms DESC, message_key DESC
|
ORDER BY time_ms DESC, message_key DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""";
|
""".formatted(messagesTable());
|
||||||
List<SignedMessageV2Entry> out = new ArrayList<>();
|
List<SignedMessageEntry> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, login);
|
ps.setString(1, login);
|
||||||
ps.setString(2, login);
|
ps.setString(2, login);
|
||||||
@@ -415,9 +422,75 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException {
|
public SyncBatch listSyncBatch(
|
||||||
|
String ownerLogin,
|
||||||
|
long afterStoredAtMs,
|
||||||
|
String afterMessageKey,
|
||||||
|
int limit,
|
||||||
|
int maxBytes
|
||||||
|
) throws Exception {
|
||||||
|
int safeLimit = Math.max(1, Math.min(limit, 500));
|
||||||
|
int sqlLimit = safeLimit + 1;
|
||||||
|
String safeAfterMessageKey = afterMessageKey == null ? "" : afterMessageKey;
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO signed_messages_v2 (
|
SELECT
|
||||||
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
|
FROM %s
|
||||||
|
WHERE (
|
||||||
|
LOWER(target_login) = LOWER(?)
|
||||||
|
OR (
|
||||||
|
message_type IN (5, 6, 7, 8)
|
||||||
|
AND (LOWER(from_login) = LOWER(?) OR LOWER(to_login) = LOWER(?))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
created_at_ms > ?
|
||||||
|
OR (created_at_ms = ? AND (? = '' OR message_key > ?))
|
||||||
|
)
|
||||||
|
ORDER BY created_at_ms ASC, message_key ASC
|
||||||
|
LIMIT ?
|
||||||
|
""".formatted(messagesTable());
|
||||||
|
List<SignedMessageEntry> out = new ArrayList<>();
|
||||||
|
boolean hasMore = false;
|
||||||
|
int usedBytes = 0;
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, ownerLogin);
|
||||||
|
ps.setString(3, ownerLogin);
|
||||||
|
ps.setLong(4, Math.max(0L, afterStoredAtMs));
|
||||||
|
ps.setLong(5, Math.max(0L, afterStoredAtMs));
|
||||||
|
ps.setString(6, safeAfterMessageKey);
|
||||||
|
ps.setString(7, safeAfterMessageKey);
|
||||||
|
ps.setInt(8, sqlLimit);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
SignedMessageEntry row = mapRow(rs);
|
||||||
|
byte[] raw = row.getRawBlock();
|
||||||
|
int rowBytes = raw == null ? 0 : raw.length;
|
||||||
|
if (out.size() >= safeLimit) {
|
||||||
|
hasMore = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!out.isEmpty() && maxBytes > 0 && usedBytes + rowBytes > maxBytes) {
|
||||||
|
hasMore = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.add(row);
|
||||||
|
usedBytes += rowBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new SyncBatch(out, hasMore, usedBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void upsertMessage(Connection c, SignedMessageEntry e) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO %s (
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
@@ -439,15 +512,15 @@ public final class SignedMessagesV2DAO {
|
|||||||
origin_session_id = excluded.origin_session_id,
|
origin_session_id = excluded.origin_session_id,
|
||||||
receipt_ref_base_key = excluded.receipt_ref_base_key,
|
receipt_ref_base_key = excluded.receipt_ref_base_key,
|
||||||
receipt_ref_type = excluded.receipt_ref_type,
|
receipt_ref_type = excluded.receipt_ref_type,
|
||||||
read_at_ms = COALESCE(signed_messages_v2.read_at_ms, excluded.read_at_ms)
|
read_at_ms = COALESCE(%s.read_at_ms, excluded.read_at_ms)
|
||||||
""";
|
""".formatted(messagesTable(), messagesTable());
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
bindSignedMessage(ps, e);
|
bindSignedMessage(ps, e);
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void markMessageReadByReceipt(Connection c, SignedMessageV2Entry entry) throws SQLException {
|
private void markMessageReadByReceipt(Connection c, SignedMessageEntry entry) throws SQLException {
|
||||||
if (entry == null) return;
|
if (entry == null) return;
|
||||||
int messageType = entry.getMessageType();
|
int messageType = entry.getMessageType();
|
||||||
if (messageType != 3 && messageType != 4) return;
|
if (messageType != 3 && messageType != 4) return;
|
||||||
@@ -456,7 +529,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
long readAtMs = entry.getTimeMs();
|
long readAtMs = entry.getTimeMs();
|
||||||
if (readAtMs <= 0) return;
|
if (readAtMs <= 0) return;
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
UPDATE signed_messages_v2
|
UPDATE %s
|
||||||
SET read_at_ms = CASE
|
SET read_at_ms = CASE
|
||||||
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
|
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
|
||||||
WHEN read_at_ms > ? THEN ?
|
WHEN read_at_ms > ? THEN ?
|
||||||
@@ -464,7 +537,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
END
|
END
|
||||||
WHERE base_key = ?
|
WHERE base_key = ?
|
||||||
AND message_type IN (1, 2)
|
AND message_type IN (1, 2)
|
||||||
""")) {
|
""".formatted(messagesTable()))) {
|
||||||
ps.setLong(1, readAtMs);
|
ps.setLong(1, readAtMs);
|
||||||
ps.setLong(2, readAtMs);
|
ps.setLong(2, readAtMs);
|
||||||
ps.setLong(3, readAtMs);
|
ps.setLong(3, readAtMs);
|
||||||
@@ -476,10 +549,10 @@ public final class SignedMessagesV2DAO {
|
|||||||
private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException {
|
private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT revision_time_ms, reencrypted_at_ms
|
SELECT revision_time_ms, reencrypted_at_ms
|
||||||
FROM signed_messages_v2
|
FROM %s
|
||||||
WHERE message_key = ?
|
WHERE message_key = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""".formatted(messagesTable());
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, messageKey);
|
ps.setString(1, messageKey);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
@@ -492,12 +565,12 @@ public final class SignedMessagesV2DAO {
|
|||||||
private RevisionMarker getCurrentContentMarker(Connection c, String baseKey) throws SQLException {
|
private RevisionMarker getCurrentContentMarker(Connection c, String baseKey) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT revision_time_ms, reencrypted_at_ms
|
SELECT revision_time_ms, reencrypted_at_ms
|
||||||
FROM signed_messages_v2
|
FROM %s
|
||||||
WHERE base_key = ?
|
WHERE base_key = ?
|
||||||
AND message_type IN (1, 2)
|
AND message_type IN (1, 2)
|
||||||
ORDER BY revision_time_ms DESC, reencrypted_at_ms DESC
|
ORDER BY revision_time_ms DESC, reencrypted_at_ms DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""".formatted(messagesTable());
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, baseKey);
|
ps.setString(1, baseKey);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
@@ -510,11 +583,11 @@ public final class SignedMessagesV2DAO {
|
|||||||
private boolean hasMessageDeleteTombstone(Connection c, String baseKey) throws SQLException {
|
private boolean hasMessageDeleteTombstone(Connection c, String baseKey) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM signed_messages_v2
|
FROM %s
|
||||||
WHERE base_key = ?
|
WHERE base_key = ?
|
||||||
AND message_type IN (5, 6)
|
AND message_type IN (5, 6)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""".formatted(messagesTable());
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, baseKey);
|
ps.setString(1, baseKey);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
@@ -531,13 +604,13 @@ public final class SignedMessagesV2DAO {
|
|||||||
private Long getLatestConversationDeleteBoundary(Connection c, String fromLogin, String toLogin) throws SQLException {
|
private Long getLatestConversationDeleteBoundary(Connection c, String fromLogin, String toLogin) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT MAX(time_ms)
|
SELECT MAX(time_ms)
|
||||||
FROM signed_messages_v2
|
FROM %s
|
||||||
WHERE message_type IN (7, 8)
|
WHERE message_type IN (7, 8)
|
||||||
AND (
|
AND (
|
||||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
)
|
)
|
||||||
""";
|
""".formatted(messagesTable());
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, fromLogin);
|
ps.setString(1, fromLogin);
|
||||||
ps.setString(2, toLogin);
|
ps.setString(2, toLogin);
|
||||||
@@ -551,22 +624,22 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private SignedMessageV2Entry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception {
|
private SignedMessageEntry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
receipt_ref_base_key, receipt_ref_type
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
FROM signed_messages_v2
|
FROM %s
|
||||||
WHERE message_type IN (7, 8)
|
WHERE message_type IN (7, 8)
|
||||||
AND (
|
AND (
|
||||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
)
|
)
|
||||||
ORDER BY time_ms DESC
|
ORDER BY time_ms DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""".formatted(messagesTable());
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, fromLogin);
|
ps.setString(1, fromLogin);
|
||||||
ps.setString(2, toLogin);
|
ps.setString(2, toLogin);
|
||||||
@@ -582,16 +655,16 @@ public final class SignedMessagesV2DAO {
|
|||||||
private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException {
|
private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException {
|
||||||
deleteDeliveryRowsByMessageSelection(c, """
|
deleteDeliveryRowsByMessageSelection(c, """
|
||||||
SELECT message_key
|
SELECT message_key
|
||||||
FROM signed_messages_v2
|
FROM %s
|
||||||
WHERE (base_key = ? AND message_type IN (1, 2))
|
WHERE (base_key = ? AND message_type IN (1, 2))
|
||||||
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
|
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
|
||||||
""", baseKey, baseKey);
|
""".formatted(messagesTable()), baseKey, baseKey);
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
DELETE FROM signed_messages_v2
|
DELETE FROM %s
|
||||||
WHERE (base_key = ? AND message_type IN (1, 2))
|
WHERE (base_key = ? AND message_type IN (1, 2))
|
||||||
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
|
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
|
||||||
""")) {
|
""".formatted(messagesTable()))) {
|
||||||
ps.setString(1, baseKey);
|
ps.setString(1, baseKey);
|
||||||
ps.setString(2, baseKey);
|
ps.setString(2, baseKey);
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
@@ -601,22 +674,22 @@ public final class SignedMessagesV2DAO {
|
|||||||
private void deleteConversationHistoryBefore(Connection c, String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
|
private void deleteConversationHistoryBefore(Connection c, String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
|
||||||
deleteDeliveryRowsByMessageSelection(c, """
|
deleteDeliveryRowsByMessageSelection(c, """
|
||||||
SELECT message_key
|
SELECT message_key
|
||||||
FROM signed_messages_v2
|
FROM %s
|
||||||
WHERE time_ms < ?
|
WHERE time_ms < ?
|
||||||
AND (
|
AND (
|
||||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
)
|
)
|
||||||
""", boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin);
|
""".formatted(messagesTable()), boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin);
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
DELETE FROM signed_messages_v2
|
DELETE FROM %s
|
||||||
WHERE time_ms < ?
|
WHERE time_ms < ?
|
||||||
AND (
|
AND (
|
||||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
|
||||||
)
|
)
|
||||||
""")) {
|
""".formatted(messagesTable()))) {
|
||||||
ps.setLong(1, boundaryTimeMs);
|
ps.setLong(1, boundaryTimeMs);
|
||||||
ps.setString(2, fromLogin);
|
ps.setString(2, fromLogin);
|
||||||
ps.setString(3, toLogin);
|
ps.setString(3, toLogin);
|
||||||
@@ -645,22 +718,22 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException {
|
private int insertStrict(Connection c, SignedMessageEntry e) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO signed_messages_v2 (
|
INSERT INTO %s (
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""".formatted(messagesTable());
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
bindSignedMessage(ps, e);
|
bindSignedMessage(ps, e);
|
||||||
return ps.executeUpdate();
|
return ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void bindSignedMessage(PreparedStatement ps, SignedMessageV2Entry e) throws SQLException {
|
private void bindSignedMessage(PreparedStatement ps, SignedMessageEntry e) throws SQLException {
|
||||||
ps.setString(1, e.getMessageKey());
|
ps.setString(1, e.getMessageKey());
|
||||||
ps.setString(2, e.getBaseKey());
|
ps.setString(2, e.getBaseKey());
|
||||||
ps.setString(3, e.getTargetLogin());
|
ps.setString(3, e.getTargetLogin());
|
||||||
@@ -703,43 +776,8 @@ public final class SignedMessagesV2DAO {
|
|||||||
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isBusyLock(SQLException ex) {
|
|
||||||
Throwable current = ex;
|
|
||||||
while (current != null) {
|
|
||||||
String msg = String.valueOf(current.getMessage()).toLowerCase();
|
|
||||||
if (msg.contains("sqlite_busy") || msg.contains("database is locked") || msg.contains("database table is locked")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
current = current.getCause();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void sleepBeforeBusyRetry(int attempt) throws SQLException {
|
|
||||||
long delayMs = SQLITE_BUSY_RETRY_BASE_DELAY_MS * (1L << Math.min(attempt, 4));
|
|
||||||
try {
|
|
||||||
Thread.sleep(delayMs);
|
|
||||||
} catch (InterruptedException ie) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
SQLException sqlEx = new SQLException("Interrupted while retrying SQLite busy lock", ie);
|
|
||||||
throw sqlEx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private <T> T withBusyRetry(SqlWork<T> work) throws Exception {
|
private <T> T withBusyRetry(SqlWork<T> work) throws Exception {
|
||||||
SQLException lastBusy = null;
|
|
||||||
for (int attempt = 0; attempt < SQLITE_BUSY_MAX_RETRIES; attempt++) {
|
|
||||||
try {
|
|
||||||
return work.run();
|
return work.run();
|
||||||
} catch (SQLException ex) {
|
|
||||||
if (!isBusyLock(ex) || attempt >= SQLITE_BUSY_MAX_RETRIES - 1) {
|
|
||||||
throw ex;
|
|
||||||
}
|
|
||||||
lastBusy = ex;
|
|
||||||
sleepBeforeBusyRetry(attempt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastBusy == null ? new SQLException("SQLite busy retry failed") : lastBusy;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private int compareMarkers(RevisionMarker left, RevisionMarker right) {
|
private int compareMarkers(RevisionMarker left, RevisionMarker right) {
|
||||||
@@ -748,8 +786,12 @@ public final class SignedMessagesV2DAO {
|
|||||||
return Long.compare(left.reencryptedAtMs, right.reencryptedAtMs);
|
return Long.compare(left.reencryptedAtMs, right.reencryptedAtMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception {
|
private String messagesTable() {
|
||||||
SignedMessageV2Entry e = new SignedMessageV2Entry();
|
return "signed_messages";
|
||||||
|
}
|
||||||
|
|
||||||
|
private SignedMessageEntry mapRow(ResultSet rs) throws Exception {
|
||||||
|
SignedMessageEntry e = new SignedMessageEntry();
|
||||||
e.setMessageKey(rs.getString("message_key"));
|
e.setMessageKey(rs.getString("message_key"));
|
||||||
e.setBaseKey(rs.getString("base_key"));
|
e.setBaseKey(rs.getString("base_key"));
|
||||||
e.setTargetLogin(rs.getString("target_login"));
|
e.setTargetLogin(rs.getString("target_login"));
|
||||||
@@ -773,11 +815,13 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private record RevisionMarker(long revisionTimeMs, long reencryptedAtMs) {
|
private record RevisionMarker(long revisionTimeMs, long reencryptedAtMs) {
|
||||||
private static RevisionMarker of(SignedMessageV2Entry entry) {
|
private static RevisionMarker of(SignedMessageEntry entry) {
|
||||||
return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs());
|
return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record SyncBatch(List<SignedMessageEntry> items, boolean hasMore, int rawBytes) {}
|
||||||
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
private interface SqlWork<T> {
|
private interface SqlWork<T> {
|
||||||
T run() throws Exception;
|
T run() throws Exception;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.KeyEncodingUtil;
|
||||||
|
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем.
|
||||||
|
*/
|
||||||
|
public final class SolanaUserPdaCurrentDAO {
|
||||||
|
|
||||||
|
private static volatile SolanaUserPdaCurrentDAO instance;
|
||||||
|
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private SolanaUserPdaCurrentDAO() {}
|
||||||
|
|
||||||
|
public static SolanaUserPdaCurrentDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (SolanaUserPdaCurrentDAO.class) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new SolanaUserPdaCurrentDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SolanaUserPdaCurrentEntry getByBlockchainName(String blockchainName) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return getByBlockchainName(c, blockchainName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
login,
|
||||||
|
blockchain_name,
|
||||||
|
blockchain_key,
|
||||||
|
paid_limit_bytes
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
WHERE blockchain_name = ?
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, blockchainName);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return mapRow(rs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a current user 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 = Base64.getEncoder().encodeToString(owner32);
|
||||||
|
String sql = """
|
||||||
|
SELECT login, blockchain_name, blockchain_key, paid_limit_bytes
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql); ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
SolanaUserPdaCurrentEntry e = mapRow(rs);
|
||||||
|
if (wanted.equals(e.getBlockchainKey())) return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry();
|
||||||
|
entry.setLogin(rs.getString("login"));
|
||||||
|
entry.setBlockchainName(rs.getString("blockchain_name"));
|
||||||
|
entry.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key")));
|
||||||
|
entry.setPaidLimitBytes(rs.getLong("paid_limit_bytes"));
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.MsgSubType;
|
import shine.db.MsgSubType;
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
|
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -13,7 +13,7 @@ import java.util.List;
|
|||||||
* Возвращает по каждой активной подписке (FOLLOW) + "сам на себя":
|
* Возвращает по каждой активной подписке (FOLLOW) + "сам на себя":
|
||||||
* - login цели (channelLogin)
|
* - login цели (channelLogin)
|
||||||
* - blockchainName цели (channelBchName)
|
* - blockchainName цели (channelBchName)
|
||||||
* - count публикаций (TEXT_POST)
|
* - count публикаций (видимые line-based TEXT-сообщения канала)
|
||||||
* - last publication: bytes оригинального блока (для timestamp)
|
* - last publication: bytes оригинального блока (для timestamp)
|
||||||
* - last publication: bytes актуального блока (edit или orig) — для текста превью
|
* - last publication: bytes актуального блока (edit или orig) — для текста превью
|
||||||
*
|
*
|
||||||
@@ -25,7 +25,7 @@ import java.util.List;
|
|||||||
public final class SubscriptionsDAO {
|
public final class SubscriptionsDAO {
|
||||||
|
|
||||||
private static volatile SubscriptionsDAO instance;
|
private static volatile SubscriptionsDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private SubscriptionsDAO() {}
|
private SubscriptionsDAO() {}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ public final class SubscriptionsDAO {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой:
|
* Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой:
|
||||||
* - count публикаций (TEXT_POST)
|
* - count публикаций (видимые line-based TEXT-сообщения канала)
|
||||||
* - последнюю публикацию (orig bytes) + её edit (если есть)
|
* - последнюю публикацию (orig bytes) + её edit (если есть)
|
||||||
*
|
*
|
||||||
* Поведение при 0 публикаций:
|
* Поведение при 0 публикаций:
|
||||||
@@ -131,7 +131,7 @@ public final class SubscriptionsDAO {
|
|||||||
ON s.channel_login = b.login
|
ON s.channel_login = b.login
|
||||||
AND s.channel_bch_name = b.bch_name
|
AND s.channel_bch_name = b.bch_name
|
||||||
WHERE b.msg_type = ?
|
WHERE b.msg_type = ?
|
||||||
AND b.msg_sub_type IN (?, ?)
|
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
|
||||||
GROUP BY b.login, b.bch_name
|
GROUP BY b.login, b.bch_name
|
||||||
),
|
),
|
||||||
last_pub AS (
|
last_pub AS (
|
||||||
@@ -144,7 +144,7 @@ public final class SubscriptionsDAO {
|
|||||||
ON s.channel_login = b.login
|
ON s.channel_login = b.login
|
||||||
AND s.channel_bch_name = b.bch_name
|
AND s.channel_bch_name = b.bch_name
|
||||||
WHERE b.msg_type = ?
|
WHERE b.msg_type = ?
|
||||||
AND b.msg_sub_type IN (?, ?)
|
AND b.msg_sub_type IN (?, ?, ?, ?, ?, ?)
|
||||||
GROUP BY b.login, b.bch_name
|
GROUP BY b.login, b.bch_name
|
||||||
),
|
),
|
||||||
last_pub_block AS (
|
last_pub_block AS (
|
||||||
@@ -209,11 +209,19 @@ public final class SubscriptionsDAO {
|
|||||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||||
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_COURSE);
|
||||||
|
|
||||||
// last_pub
|
// last_pub
|
||||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||||
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_ENTRYPOINT);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_EXERCISE);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_SERVICE);
|
||||||
|
ps.setInt(i++, (int) MsgSubType.TEXT_COURSE);
|
||||||
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
import shine.db.entities.SyncServerEntry;
|
import shine.db.entities.SyncServerEntry;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
@@ -17,7 +17,7 @@ import java.util.List;
|
|||||||
public final class SyncServersDAO {
|
public final class SyncServersDAO {
|
||||||
|
|
||||||
private static volatile SyncServersDAO instance;
|
private static volatile SyncServersDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private SyncServersDAO() {}
|
private SyncServersDAO() {}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
import shine.db.entities.TestFreeAvatarUploadEntry;
|
import shine.db.entities.TestFreeAvatarUploadEntry;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
@@ -11,7 +11,7 @@ import java.sql.SQLException;
|
|||||||
public final class TestFreeAvatarUploadsDAO {
|
public final class TestFreeAvatarUploadsDAO {
|
||||||
|
|
||||||
private static volatile TestFreeAvatarUploadsDAO instance;
|
private static volatile TestFreeAvatarUploadsDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private TestFreeAvatarUploadsDAO() {
|
private TestFreeAvatarUploadsDAO() {
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ public final class TestFreeAvatarUploadsDAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT login, used_count, updated_at_ms, last_tx_id
|
SELECT login, used_count, updated_at_ms, last_tx_id
|
||||||
FROM test_free_avatar_uploads
|
FROM test_free_avatar_uploads
|
||||||
WHERE login = ? COLLATE NOCASE
|
WHERE LOWER(login) = LOWER(?)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
|||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.KeyEncodingUtil;
|
||||||
|
import shine.db.entities.UserAccessServerRouteEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DAO локальной routing-проекции access servers пользователей.
|
||||||
|
*/
|
||||||
|
public final class UserAccessServersCurrentDAO {
|
||||||
|
|
||||||
|
private static volatile UserAccessServersCurrentDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private UserAccessServersCurrentDAO() {}
|
||||||
|
|
||||||
|
public static UserAccessServersCurrentDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (UserAccessServersCurrentDAO.class) {
|
||||||
|
if (instance == null) instance = new UserAccessServersCurrentDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<UserAccessServerRouteEntry> listByUserLogin(String userLogin) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return listByUserLogin(c, userLogin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает первый access server пользователя ровно в порядке
|
||||||
|
* solana_user_pda_current.access_servers_json (access_servers[0]).
|
||||||
|
*
|
||||||
|
* В user_access_servers_current порядок массива намеренно не хранится,
|
||||||
|
* поэтому для auth-routing первичный сервер читается напрямую из PDA snapshot.
|
||||||
|
*/
|
||||||
|
public UserAccessServerRouteEntry findPrimaryByUserLogin(String userLogin) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
u.login AS user_login,
|
||||||
|
s.login AS server_login,
|
||||||
|
s.server_address AS server_url,
|
||||||
|
s.client_key AS server_client_key
|
||||||
|
FROM solana_user_pda_current u
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT BTRIM(a.login_value) AS server_login
|
||||||
|
FROM jsonb_array_elements_text(
|
||||||
|
CASE
|
||||||
|
WHEN BTRIM(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||||
|
ELSE u.access_servers_json::jsonb
|
||||||
|
END
|
||||||
|
) WITH ORDINALITY AS a(login_value, ord)
|
||||||
|
WHERE a.ord = 1
|
||||||
|
) primary_access
|
||||||
|
JOIN solana_user_pda_current s
|
||||||
|
ON s.normalized_login = LOWER(primary_access.server_login)
|
||||||
|
AND s.is_server = TRUE
|
||||||
|
AND BTRIM(COALESCE(s.server_address, '')) <> ''
|
||||||
|
WHERE u.normalized_login = LOWER(BTRIM(?))
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, userLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
return mapRow(rs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<UserAccessServerRouteEntry> listByUserLogin(Connection c, String userLogin) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT user_login, server_login, server_url, server_client_key
|
||||||
|
FROM user_access_servers_current
|
||||||
|
WHERE LOWER(user_login) = LOWER(?)
|
||||||
|
ORDER BY server_login
|
||||||
|
""";
|
||||||
|
List<UserAccessServerRouteEntry> result = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, userLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
result.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> listUserLoginsByServerLogin(String serverLogin) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT user_login
|
||||||
|
FROM user_access_servers_current
|
||||||
|
WHERE LOWER(server_login) = LOWER(?)
|
||||||
|
ORDER BY user_login
|
||||||
|
""";
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, serverLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
result.add(rs.getString("user_login"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает уникальные физические серверы из текущей routing-проекции.
|
||||||
|
* Используется транспортным пулом: одно WSS-соединение держится на сервер,
|
||||||
|
* а не на каждого пользователя этого сервера.
|
||||||
|
*/
|
||||||
|
public List<UserAccessServerRouteEntry> listDistinctServers() throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT DISTINCT ON (LOWER(server_login))
|
||||||
|
server_login, server_url
|
||||||
|
FROM user_access_servers_current
|
||||||
|
WHERE server_login IS NOT NULL AND BTRIM(server_login) <> ''
|
||||||
|
AND server_url IS NOT NULL AND BTRIM(server_url) <> ''
|
||||||
|
ORDER BY LOWER(server_login), server_login, server_url
|
||||||
|
""";
|
||||||
|
List<UserAccessServerRouteEntry> result = new ArrayList<>();
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement(sql);
|
||||||
|
ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
|
||||||
|
entry.setUserLogin("");
|
||||||
|
entry.setServerLogin(rs.getString("server_login"));
|
||||||
|
entry.setServerUrl(rs.getString("server_url"));
|
||||||
|
result.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
|
||||||
|
entry.setUserLogin(rs.getString("user_login"));
|
||||||
|
entry.setServerLogin(rs.getString("server_login"));
|
||||||
|
entry.setServerUrl(rs.getString("server_url"));
|
||||||
|
entry.setServerClientKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("server_client_key")));
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package shine.db.dao;
|
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
|
||||||
import shine.db.entities.SolanaUserEntry;
|
|
||||||
|
|
||||||
import java.sql.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UserCreateDAO — атомарное добавление пользователя:
|
|
||||||
* - solana_users (login, blockchain_name, solana_key, blockchain_key, client_key)
|
|
||||||
* - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...)
|
|
||||||
*
|
|
||||||
* ВАЖНО:
|
|
||||||
* - только INSERT (без перезаписи существующих записей)
|
|
||||||
* - если login или blockchainName заняты — возвращаем false (пользователь уже есть/занято)
|
|
||||||
*/
|
|
||||||
public final class UserCreateDAO {
|
|
||||||
|
|
||||||
private static volatile UserCreateDAO instance;
|
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
|
||||||
private final SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance();
|
|
||||||
|
|
||||||
private UserCreateDAO() {}
|
|
||||||
|
|
||||||
public static UserCreateDAO getInstance() {
|
|
||||||
if (instance == null) {
|
|
||||||
synchronized (UserCreateDAO.class) {
|
|
||||||
if (instance == null) instance = new UserCreateDAO();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return true если добавили; false если занято (login уже есть или blockchainName уже существует).
|
|
||||||
*/
|
|
||||||
public boolean insertUserWithBlockchain(
|
|
||||||
String login,
|
|
||||||
String blockchainName,
|
|
||||||
String solanaKey,
|
|
||||||
String blockchainKey,
|
|
||||||
String clientKey,
|
|
||||||
long sizeLimit,
|
|
||||||
long nowMs
|
|
||||||
) throws SQLException {
|
|
||||||
|
|
||||||
try (Connection c = db.getConnection()) {
|
|
||||||
boolean oldAuto = c.getAutoCommit();
|
|
||||||
c.setAutoCommit(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1) solana_users
|
|
||||||
SolanaUserEntry u = new SolanaUserEntry();
|
|
||||||
u.setLogin(login);
|
|
||||||
u.setBlockchainName(blockchainName);
|
|
||||||
u.setSolanaKey(solanaKey);
|
|
||||||
u.setBlockchainKey(blockchainKey);
|
|
||||||
u.setClientKey(clientKey);
|
|
||||||
|
|
||||||
usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint
|
|
||||||
|
|
||||||
// 2) blockchain_state — строго INSERT, без UPSERT (иначе можно перезаписать существующую цепочку)
|
|
||||||
insertBlockchainStateStrict(
|
|
||||||
c,
|
|
||||||
blockchainName,
|
|
||||||
login,
|
|
||||||
blockchainKey,
|
|
||||||
sizeLimit,
|
|
||||||
nowMs
|
|
||||||
);
|
|
||||||
|
|
||||||
c.commit();
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (SQLException e) {
|
|
||||||
c.rollback();
|
|
||||||
|
|
||||||
String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase();
|
|
||||||
if (msg.contains("constraint")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
c.setAutoCommit(oldAuto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void insertBlockchainStateStrict(
|
|
||||||
Connection c,
|
|
||||||
String blockchainName,
|
|
||||||
String login,
|
|
||||||
String blockchainKey,
|
|
||||||
long sizeLimit,
|
|
||||||
long nowMs
|
|
||||||
) throws SQLException {
|
|
||||||
|
|
||||||
String sql = """
|
|
||||||
INSERT INTO blockchain_state (
|
|
||||||
blockchain_name,
|
|
||||||
login,
|
|
||||||
blockchain_key,
|
|
||||||
size_limit,
|
|
||||||
file_size_bytes,
|
|
||||||
last_block_number,
|
|
||||||
last_block_hash,
|
|
||||||
updated_at_ms
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""";
|
|
||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
int i = 1;
|
|
||||||
ps.setString(i++, blockchainName);
|
|
||||||
ps.setString(i++, login);
|
|
||||||
ps.setString(i++, blockchainKey);
|
|
||||||
|
|
||||||
ps.setLong(i++, sizeLimit);
|
|
||||||
ps.setLong(i++, 0L);
|
|
||||||
|
|
||||||
ps.setInt(i++, -1);
|
|
||||||
ps.setNull(i++, Types.BLOB); // старт: блоков ещё нет
|
|
||||||
ps.setLong(i++, nowMs);
|
|
||||||
|
|
||||||
ps.executeUpdate(); // если blockchainName занят -> constraint (PK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.entities.UserNotificationEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class UserNotificationsStateDAO {
|
||||||
|
private static volatile UserNotificationsStateDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private UserNotificationsStateDAO() {}
|
||||||
|
|
||||||
|
public static UserNotificationsStateDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (UserNotificationsStateDAO.class) {
|
||||||
|
if (instance == null) instance = new UserNotificationsStateDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int upsert(Connection c, UserNotificationEntry e) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO user_notifications_state (
|
||||||
|
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
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) DO UPDATE SET
|
||||||
|
created_at_ms = EXCLUDED.created_at_ms,
|
||||||
|
source_login = EXCLUDED.source_login,
|
||||||
|
target_login = EXCLUDED.target_login,
|
||||||
|
target_bch_name = EXCLUDED.target_bch_name,
|
||||||
|
target_block_number = EXCLUDED.target_block_number,
|
||||||
|
target_block_hash = EXCLUDED.target_block_hash,
|
||||||
|
source_msg_sub_type = EXCLUDED.source_msg_sub_type,
|
||||||
|
source_text = EXCLUDED.source_text
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
int i = 1;
|
||||||
|
ps.setString(i++, e.getOwnerLogin());
|
||||||
|
ps.setString(i++, e.getNotificationKind());
|
||||||
|
ps.setLong(i++, e.getCreatedAtMs());
|
||||||
|
ps.setString(i++, e.getSourceLogin());
|
||||||
|
ps.setString(i++, e.getSourceBchName());
|
||||||
|
ps.setInt(i++, e.getSourceBlockNumber());
|
||||||
|
ps.setBytes(i++, e.getSourceBlockHash());
|
||||||
|
if (e.getTargetLogin() != null) ps.setString(i++, e.getTargetLogin()); else ps.setNull(i++, Types.VARCHAR);
|
||||||
|
if (e.getTargetBchName() != null) ps.setString(i++, e.getTargetBchName()); else ps.setNull(i++, Types.VARCHAR);
|
||||||
|
if (e.getTargetBlockNumber() != null) ps.setInt(i++, e.getTargetBlockNumber()); else ps.setNull(i++, Types.INTEGER);
|
||||||
|
if (e.getTargetBlockHash() != null) ps.setBytes(i++, e.getTargetBlockHash()); else ps.setNull(i++, Types.BINARY);
|
||||||
|
ps.setInt(i++, e.getSourceMsgSubType());
|
||||||
|
ps.setString(i++, e.getSourceText() == null ? "" : e.getSourceText());
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<UserNotificationEntry> listByOwnerAndKind(Connection c, String ownerLogin, String kind, int limit) 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 = ?
|
||||||
|
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||||
|
LIMIT ?
|
||||||
|
""";
|
||||||
|
List<UserNotificationEntry> out = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, kind);
|
||||||
|
ps.setInt(3, Math.max(1, limit));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
UserNotificationEntry e = new UserNotificationEntry();
|
||||||
|
e.setOwnerLogin(rs.getString("owner_login"));
|
||||||
|
e.setNotificationKind(rs.getString("notification_kind"));
|
||||||
|
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||||
|
e.setSourceLogin(rs.getString("source_login"));
|
||||||
|
e.setSourceBchName(rs.getString("source_bch_name"));
|
||||||
|
e.setSourceBlockNumber(rs.getInt("source_block_number"));
|
||||||
|
e.setSourceBlockHash(rs.getBytes("source_block_hash"));
|
||||||
|
e.setTargetLogin(rs.getString("target_login"));
|
||||||
|
e.setTargetBchName(rs.getString("target_bch_name"));
|
||||||
|
Object targetBlockNumber = rs.getObject("target_block_number");
|
||||||
|
e.setTargetBlockNumber(targetBlockNumber == null ? null : ((Number) targetBlockNumber).intValue());
|
||||||
|
e.setTargetBlockHash(rs.getBytes("target_block_hash"));
|
||||||
|
e.setSourceMsgSubType(rs.getInt("source_msg_sub_type"));
|
||||||
|
e.setSourceText(rs.getString("source_text"));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package shine.db.dao;
|
package shine.db.dao;
|
||||||
|
|
||||||
import shine.db.SqliteDbController;
|
import shine.db.DbController;
|
||||||
import shine.db.entities.UserParamEntry;
|
import shine.db.entities.UserParamEntry;
|
||||||
|
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
@@ -21,7 +21,7 @@ import java.util.List;
|
|||||||
public final class UserParamsDAO {
|
public final class UserParamsDAO {
|
||||||
|
|
||||||
private static volatile UserParamsDAO instance;
|
private static volatile UserParamsDAO instance;
|
||||||
private final SqliteDbController db = SqliteDbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
private UserParamsDAO() { }
|
private UserParamsDAO() { }
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ public final class UserParamsDAO {
|
|||||||
WHERE users_params.time_ms < excluded.time_ms
|
WHERE users_params.time_ms < excluded.time_ms
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
int changed;
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, e.getLogin());
|
ps.setString(1, e.getLogin());
|
||||||
ps.setString(2, e.getParam());
|
ps.setString(2, e.getParam());
|
||||||
@@ -67,8 +68,12 @@ public final class UserParamsDAO {
|
|||||||
if (e.getSignature() != null) ps.setString(6, e.getSignature());
|
if (e.getSignature() != null) ps.setString(6, e.getSignature());
|
||||||
else ps.setNull(6, Types.VARCHAR);
|
else ps.setNull(6, Types.VARCHAR);
|
||||||
|
|
||||||
return ps.executeUpdate();
|
changed = ps.executeUpdate();
|
||||||
}
|
}
|
||||||
|
if (changed > 0 && affectsFastProfile(e.getParam())) {
|
||||||
|
refreshFastProfileAndDerivedState(c, e.getLogin(), e.getParam());
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int upsertIfNewer(UserParamEntry e) throws SQLException {
|
public int upsertIfNewer(UserParamEntry e) throws SQLException {
|
||||||
@@ -77,6 +82,32 @@ public final class UserParamsDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static boolean affectsFastProfile(String param) {
|
||||||
|
if (param == null) return false;
|
||||||
|
return switch (param) {
|
||||||
|
case "first_name", "last_name", "ava", "account_role", "shine" -> true;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void refreshFastProfileAndDerivedState(Connection c, String login, String param) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(?)")) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
ps.execute();
|
||||||
|
}
|
||||||
|
if ("account_role".equals(param) || "shine".equals(param)) {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_related_user_stats(?)")) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
ps.execute();
|
||||||
|
}
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_message_qualified_likes_for_actor(?)")) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
ps.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------- SELECT --------------------
|
// -------------------- SELECT --------------------
|
||||||
|
|
||||||
public UserParamEntry getByLoginAndParam(Connection c, String login, String param) throws SQLException {
|
public UserParamEntry getByLoginAndParam(Connection c, String login, String param) throws SQLException {
|
||||||
@@ -89,7 +120,7 @@ public final class UserParamsDAO {
|
|||||||
client_key,
|
client_key,
|
||||||
signature
|
signature
|
||||||
FROM users_params
|
FROM users_params
|
||||||
WHERE login = ? COLLATE NOCASE AND param = ?
|
WHERE LOWER(login) = LOWER(?) AND param = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -120,7 +151,7 @@ public final class UserParamsDAO {
|
|||||||
client_key,
|
client_key,
|
||||||
signature
|
signature
|
||||||
FROM users_params
|
FROM users_params
|
||||||
WHERE login = ? COLLATE NOCASE
|
WHERE LOWER(login) = LOWER(?)
|
||||||
ORDER BY time_ms DESC
|
ORDER BY time_ms DESC
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
import shine.db.MsgSubType;
|
||||||
|
|
||||||
|
/** Fast denormalized projection for user cards shown in dialogs/relations/profile lists. */
|
||||||
|
public final class UserProfileStateDAO {
|
||||||
|
private static volatile UserProfileStateDAO instance;
|
||||||
|
private UserProfileStateDAO() {}
|
||||||
|
public static UserProfileStateDAO getInstance() {
|
||||||
|
if (instance == null) synchronized (UserProfileStateDAO.class) {
|
||||||
|
if (instance == null) instance = new UserProfileStateDAO();
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProfileCard get(Connection c, String login) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT login, first_name, last_name, ava_ar, account_role, shine_status
|
||||||
|
FROM user_profile_state WHERE LOWER(login)=LOWER(?) LIMIT 1
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return new ProfileCard(login, "", "", "", null, null);
|
||||||
|
return map(rs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 100 : limit, 500));
|
||||||
|
int safeOffset = Math.max(0, offset);
|
||||||
|
String where = switch (String.valueOf(listType)) {
|
||||||
|
case "friends" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=14 AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(cs.login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)";
|
||||||
|
case "close_friends" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=10";
|
||||||
|
case "primary_received" -> "LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)";
|
||||||
|
case "primary_given" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(cs.to_login)";
|
||||||
|
case "shine_received" -> "LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(cs.to_login)";
|
||||||
|
case "shine_given" -> "LOWER(cs.login)=LOWER(?) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(cs.to_login)";
|
||||||
|
default -> throw new IllegalArgumentException("Unsupported listType: " + listType);
|
||||||
|
};
|
||||||
|
boolean received = "primary_received".equals(listType) || "shine_received".equals(listType);
|
||||||
|
String personExpr = received ? "cs.login" : "cs.to_login";
|
||||||
|
String sql = """
|
||||||
|
SELECT DISTINCT ON (LOWER(%s))
|
||||||
|
%s AS person_login,
|
||||||
|
COALESCE(up.first_name,'') AS first_name,
|
||||||
|
COALESCE(up.last_name,'') AS last_name,
|
||||||
|
COALESCE(up.ava_ar,'') AS ava_ar,
|
||||||
|
up.account_role, up.shine_status,
|
||||||
|
EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=80
|
||||||
|
AND shine_is_primary(x.login) AND shine_target_accepts_primary_confirmation(x.to_login)) AS primary_confirmed,
|
||||||
|
EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=70
|
||||||
|
AND shine_is_primary(x.login) AND shine_is_shining(x.login) AND shine_target_accepts_shine_confirmation(x.to_login)) AS shine_confirmed,
|
||||||
|
CASE
|
||||||
|
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=10) THEN 'close_friend'
|
||||||
|
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=14) THEN 'friend'
|
||||||
|
WHEN EXISTS(SELECT 1 FROM connections_state x WHERE LOWER(x.login)=LOWER(?) AND LOWER(x.to_login)=LOWER(%s) AND x.rel_type=20) THEN 'contact'
|
||||||
|
ELSE 'none' END AS relation_type,
|
||||||
|
COALESCE(us.primary_confirmations_received_count,0) AS primary_confirmations_count,
|
||||||
|
COALESCE(us.shine_confirmations_received_count,0) AS shine_confirmations_count
|
||||||
|
FROM connections_state cs
|
||||||
|
LEFT JOIN user_profile_state up ON LOWER(up.login)=LOWER(%s)
|
||||||
|
LEFT JOIN user_stats_state us ON LOWER(us.login)=LOWER(%s)
|
||||||
|
WHERE %s
|
||||||
|
ORDER BY LOWER(%s), %s
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""".formatted(personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, personExpr, where, personExpr, personExpr);
|
||||||
|
List<RelationCard> out = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
int i=1;
|
||||||
|
ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin); ps.setString(i++, ownerLogin);
|
||||||
|
ps.setString(i++, ownerLogin);
|
||||||
|
ps.setInt(i++, safeLimit); ps.setInt(i, safeOffset);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(new RelationCard(
|
||||||
|
rs.getString("person_login"), rs.getString("first_name"), rs.getString("last_name"), rs.getString("ava_ar"),
|
||||||
|
rs.getString("account_role"), rs.getString("shine_status"), rs.getBoolean("primary_confirmed"), rs.getBoolean("shine_confirmed"),
|
||||||
|
rs.getString("relation_type"), rs.getInt("primary_confirmations_count"), rs.getInt("shine_confirmations_count")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ChannelCard> listPublicChannels(Connection c, String login, String mode, int limit, int offset) throws SQLException {
|
||||||
|
int safeLimit=Math.max(1,Math.min(limit<=0?100:limit,500)), safeOffset=Math.max(0,offset);
|
||||||
|
String sql;
|
||||||
|
if ("owned".equals(mode)) {
|
||||||
|
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
|
||||||
|
FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(?) AND cn.channel_type_code=1
|
||||||
|
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
|
||||||
|
""";
|
||||||
|
} else if ("following".equals(mode)) {
|
||||||
|
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
|
||||||
|
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
|
||||||
|
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
|
||||||
|
""";
|
||||||
|
} else throw new IllegalArgumentException("Unsupported channel mode: "+mode);
|
||||||
|
List<ChannelCard> out=new ArrayList<>();
|
||||||
|
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,login);ps.setInt(2,safeLimit);ps.setInt(3,safeOffset);
|
||||||
|
try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(new ChannelCard(rs.getString("owner_login"),rs.getString("slug"),rs.getString("display_name"),rs.getString("ava_ar"),rs.getString("owner_bch_name"),rs.getInt("channel_root_block_number"),rs.getString("root_hash")));}}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProfileCard map(ResultSet rs)throws SQLException{return new ProfileCard(rs.getString("login"),rs.getString("first_name"),rs.getString("last_name"),rs.getString("ava_ar"),rs.getString("account_role"),rs.getString("shine_status"));}
|
||||||
|
public record ProfileCard(String login,String firstName,String lastName,String avatarAr,String accountRole,String shineStatus){}
|
||||||
|
public record RelationCard(String login,String firstName,String lastName,String avatarAr,String accountRole,String shineStatus,boolean primaryConfirmed,boolean shineConfirmed,String relationType,int primaryConfirmationsCount,int shineConfirmationsCount){}
|
||||||
|
public record ChannelCard(String ownerLogin,String slug,String displayName,String avatarAr,String ownerBlockchainName,int rootBlockNumber,String rootBlockHashHex){}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.entities.UserSettingEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локальное хранение подписанных пользовательских настроек.
|
||||||
|
* Запись уникальна по login + setting_type + setting_key и обновляется только
|
||||||
|
* более новым time_ms. Межсерверной репликации настроек нет.
|
||||||
|
*/
|
||||||
|
public final class UserSettingsDAO {
|
||||||
|
private static volatile UserSettingsDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private UserSettingsDAO() {}
|
||||||
|
|
||||||
|
public static UserSettingsDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (UserSettingsDAO.class) {
|
||||||
|
if (instance == null) instance = new UserSettingsDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int upsertIfNewer(Connection c, UserSettingEntry e) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO user_settings (
|
||||||
|
login, setting_type, setting_key, time_ms,
|
||||||
|
value_text, value_num, client_key, signature
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (login, setting_type, setting_key)
|
||||||
|
DO UPDATE SET
|
||||||
|
time_ms = EXCLUDED.time_ms,
|
||||||
|
value_text = EXCLUDED.value_text,
|
||||||
|
value_num = EXCLUDED.value_num,
|
||||||
|
client_key = EXCLUDED.client_key,
|
||||||
|
signature = EXCLUDED.signature
|
||||||
|
WHERE user_settings.time_ms < EXCLUDED.time_ms
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, e.getLogin());
|
||||||
|
ps.setInt(2, e.getSettingType());
|
||||||
|
ps.setString(3, e.getSettingKey());
|
||||||
|
ps.setLong(4, e.getTimeMs());
|
||||||
|
ps.setString(5, e.getValueText() == null ? "" : e.getValueText());
|
||||||
|
ps.setLong(6, e.getValueNum());
|
||||||
|
if (e.getClientKey() == null || e.getClientKey().isBlank()) ps.setNull(7, Types.VARCHAR);
|
||||||
|
else ps.setString(7, e.getClientKey());
|
||||||
|
if (e.getSignature() == null || e.getSignature().isBlank()) ps.setNull(8, Types.VARCHAR);
|
||||||
|
else ps.setString(8, e.getSignature());
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserSettingEntry getByLoginTypeKey(
|
||||||
|
Connection c, String login, int settingType, String settingKey
|
||||||
|
) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT login, setting_type, setting_key, time_ms,
|
||||||
|
value_text, value_num, client_key, signature
|
||||||
|
FROM user_settings
|
||||||
|
WHERE LOWER(login) = LOWER(?) AND setting_type = ? AND setting_key = ?
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
ps.setInt(2, settingType);
|
||||||
|
ps.setString(3, settingKey);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? mapRow(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<UserSettingEntry> getByLogin(Connection c, String login) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT login, setting_type, setting_key, time_ms,
|
||||||
|
value_text, value_num, client_key, signature
|
||||||
|
FROM user_settings
|
||||||
|
WHERE LOWER(login) = LOWER(?)
|
||||||
|
ORDER BY time_ms DESC, setting_type ASC, setting_key ASC
|
||||||
|
""";
|
||||||
|
List<UserSettingEntry> out = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<UserSettingEntry> getByLogin(String login) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return getByLogin(c, login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserSettingEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
UserSettingEntry e = new UserSettingEntry();
|
||||||
|
e.setLogin(rs.getString("login"));
|
||||||
|
e.setSettingType(rs.getInt("setting_type"));
|
||||||
|
e.setSettingKey(rs.getString("setting_key"));
|
||||||
|
e.setTimeMs(rs.getLong("time_ms"));
|
||||||
|
e.setValueText(rs.getString("value_text"));
|
||||||
|
e.setValueNum(rs.getLong("value_num"));
|
||||||
|
String clientKey = rs.getString("client_key");
|
||||||
|
if (rs.wasNull()) clientKey = null;
|
||||||
|
e.setClientKey(clientKey);
|
||||||
|
String signature = rs.getString("signature");
|
||||||
|
if (rs.wasNull()) signature = null;
|
||||||
|
e.setSignature(signature);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.entities.UserSettingsSyncPeerStateEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
public final class UserSettingsSyncPeerStateDAO {
|
||||||
|
|
||||||
|
private static volatile UserSettingsSyncPeerStateDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private UserSettingsSyncPeerStateDAO() {}
|
||||||
|
|
||||||
|
public static UserSettingsSyncPeerStateDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (UserSettingsSyncPeerStateDAO.class) {
|
||||||
|
if (instance == null) instance = new UserSettingsSyncPeerStateDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserSettingsSyncPeerStateEntry getOrCreate(Connection c, String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||||
|
UserSettingsSyncPeerStateEntry existing = get(c, ownerLogin, remoteServerLogin);
|
||||||
|
if (existing != null) return existing;
|
||||||
|
long nowMs = System.currentTimeMillis();
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO user_settings_sync_peer_state (
|
||||||
|
owner_login, remote_server_login, remote_server_url,
|
||||||
|
cursor_time_ms, cursor_setting_key, bootstrap_completed,
|
||||||
|
last_sync_at_ms, last_error, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?)
|
||||||
|
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||||
|
remote_server_url = EXCLUDED.remote_server_url,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, remoteServerLogin);
|
||||||
|
ps.setString(3, remoteServerUrl);
|
||||||
|
ps.setLong(4, nowMs);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
return get(c, ownerLogin, remoteServerLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserSettingsSyncPeerStateEntry get(Connection c, String ownerLogin, String remoteServerLogin) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT owner_login, remote_server_login, remote_server_url, cursor_time_ms, cursor_setting_key,
|
||||||
|
bootstrap_completed, last_sync_at_ms, last_error, updated_at_ms
|
||||||
|
FROM user_settings_sync_peer_state
|
||||||
|
WHERE LOWER(owner_login) = LOWER(?) AND LOWER(remote_server_login) = LOWER(?)
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, remoteServerLogin);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? mapRow(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int updateSuccess(String ownerLogin, String remoteServerLogin, String remoteServerUrl, long cursorTimeMs, String cursorSettingKey, boolean bootstrapCompleted) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO user_settings_sync_peer_state (
|
||||||
|
owner_login, remote_server_login, remote_server_url,
|
||||||
|
cursor_time_ms, cursor_setting_key, bootstrap_completed,
|
||||||
|
last_sync_at_ms, last_error, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
|
||||||
|
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||||
|
remote_server_url = EXCLUDED.remote_server_url,
|
||||||
|
cursor_time_ms = EXCLUDED.cursor_time_ms,
|
||||||
|
cursor_setting_key = EXCLUDED.cursor_setting_key,
|
||||||
|
bootstrap_completed = EXCLUDED.bootstrap_completed,
|
||||||
|
last_sync_at_ms = EXCLUDED.last_sync_at_ms,
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
long nowMs = System.currentTimeMillis();
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, remoteServerLogin);
|
||||||
|
ps.setString(3, remoteServerUrl);
|
||||||
|
ps.setLong(4, Math.max(0L, cursorTimeMs));
|
||||||
|
ps.setString(5, cursorSettingKey == null ? "" : cursorSettingKey);
|
||||||
|
ps.setBoolean(6, bootstrapCompleted);
|
||||||
|
ps.setLong(7, nowMs);
|
||||||
|
ps.setLong(8, nowMs);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO user_settings_sync_peer_state (
|
||||||
|
owner_login, remote_server_login, remote_server_url,
|
||||||
|
cursor_time_ms, cursor_setting_key, bootstrap_completed,
|
||||||
|
last_sync_at_ms, last_error, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?)
|
||||||
|
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||||
|
remote_server_url = EXCLUDED.remote_server_url,
|
||||||
|
last_error = EXCLUDED.last_error,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
long nowMs = System.currentTimeMillis();
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
ps.setString(2, remoteServerLogin);
|
||||||
|
ps.setString(3, remoteServerUrl);
|
||||||
|
ps.setString(4, error);
|
||||||
|
ps.setLong(5, nowMs);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int clearBootstrap(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||||
|
return updateSuccess(ownerLogin, remoteServerLogin, remoteServerUrl, 0L, "", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int deleteAllForOwner(String ownerLogin) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("DELETE FROM user_settings_sync_peer_state WHERE LOWER(owner_login) = LOWER(?)")) {
|
||||||
|
ps.setString(1, ownerLogin);
|
||||||
|
return ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserSettingsSyncPeerStateEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
UserSettingsSyncPeerStateEntry e = new UserSettingsSyncPeerStateEntry();
|
||||||
|
e.setOwnerLogin(rs.getString("owner_login"));
|
||||||
|
e.setRemoteServerLogin(rs.getString("remote_server_login"));
|
||||||
|
e.setRemoteServerUrl(rs.getString("remote_server_url"));
|
||||||
|
e.setCursorTimeMs(rs.getLong("cursor_time_ms"));
|
||||||
|
e.setCursorSettingKey(rs.getString("cursor_setting_key"));
|
||||||
|
e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed"));
|
||||||
|
long lastSyncAtMs = rs.getLong("last_sync_at_ms");
|
||||||
|
e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAtMs);
|
||||||
|
e.setLastError(rs.getString("last_error"));
|
||||||
|
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@ 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 String arweaveRootTxId;
|
||||||
|
|
||||||
private Integer editedByBlockNumber;
|
private Integer editedByBlockNumber;
|
||||||
|
|
||||||
@@ -85,6 +89,15 @@ 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 String getArweaveRootTxId() { return arweaveRootTxId; }
|
||||||
|
public void setArweaveRootTxId(String arweaveRootTxId) { this.arweaveRootTxId = arweaveRootTxId; }
|
||||||
|
|
||||||
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; }
|
||||||
|
|
||||||
|
|||||||
+36
@@ -13,6 +13,10 @@ public class ChannelNameStateEntry {
|
|||||||
private int channelRootBlockNumber;
|
private int channelRootBlockNumber;
|
||||||
private byte[] channelRootBlockHash;
|
private byte[] channelRootBlockHash;
|
||||||
private long createdAtMs;
|
private long createdAtMs;
|
||||||
|
private String avaAr;
|
||||||
|
private String avaSha256;
|
||||||
|
private long avaSize;
|
||||||
|
private long metaUpdatedAtMs;
|
||||||
|
|
||||||
public String getSlug() {
|
public String getSlug() {
|
||||||
return slug;
|
return slug;
|
||||||
@@ -93,4 +97,36 @@ public class ChannelNameStateEntry {
|
|||||||
public void setCreatedAtMs(long createdAtMs) {
|
public void setCreatedAtMs(long createdAtMs) {
|
||||||
this.createdAtMs = createdAtMs;
|
this.createdAtMs = createdAtMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getAvaAr() {
|
||||||
|
return avaAr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvaAr(String avaAr) {
|
||||||
|
this.avaAr = avaAr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvaSha256() {
|
||||||
|
return avaSha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvaSha256(String avaSha256) {
|
||||||
|
this.avaSha256 = avaSha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getAvaSize() {
|
||||||
|
return avaSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvaSize(long avaSize) {
|
||||||
|
this.avaSize = avaSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getMetaUpdatedAtMs() {
|
||||||
|
return metaUpdatedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMetaUpdatedAtMs(long metaUpdatedAtMs) {
|
||||||
|
this.metaUpdatedAtMs = metaUpdatedAtMs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-6
@@ -3,18 +3,19 @@ package shine.db.entities;
|
|||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SolanaUserEntry — локальная запись пользователя из Solana.
|
* CurrentUserEntry — локальная runtime-проекция текущего состояния пользователя.
|
||||||
*
|
*
|
||||||
* Таблица: solana_users
|
* Источник:
|
||||||
|
* - solana_user_pda_current
|
||||||
*
|
*
|
||||||
* Поля:
|
* Поля:
|
||||||
* - login — PRIMARY KEY (TEXT) (case-insensitive на уровне COLLATE NOCASE)
|
* - login — PRIMARY KEY (TEXT)
|
||||||
* - blockchain_name — TEXT NOT NULL
|
* - blockchain_name — TEXT NOT NULL
|
||||||
* - solana_key — TEXT NOT NULL
|
* - solana_key — TEXT NOT NULL
|
||||||
* - blockchain_key — TEXT NOT NULL
|
* - blockchain_key — TEXT NOT NULL
|
||||||
* - client_key — TEXT NOT NULL
|
* - client_key — TEXT NOT NULL
|
||||||
*/
|
*/
|
||||||
public class SolanaUserEntry {
|
public class CurrentUserEntry {
|
||||||
|
|
||||||
private String login;
|
private String login;
|
||||||
|
|
||||||
@@ -29,9 +30,9 @@ public class SolanaUserEntry {
|
|||||||
/** Ключ устройства (публичный ключ устройства) */
|
/** Ключ устройства (публичный ключ устройства) */
|
||||||
private String clientKey;
|
private String clientKey;
|
||||||
|
|
||||||
public SolanaUserEntry() {}
|
public CurrentUserEntry() {}
|
||||||
|
|
||||||
public SolanaUserEntry(String login,
|
public CurrentUserEntry(String login,
|
||||||
String blockchainName,
|
String blockchainName,
|
||||||
String solanaKey,
|
String solanaKey,
|
||||||
String blockchainKey,
|
String blockchainKey,
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package shine.db.entities;
|
|
||||||
|
|
||||||
public class DirectMessageEntry {
|
|
||||||
private String messageId;
|
|
||||||
private String fromLogin;
|
|
||||||
private String toLogin;
|
|
||||||
private String text;
|
|
||||||
private long createdAtMs;
|
|
||||||
|
|
||||||
public String getMessageId() { return messageId; }
|
|
||||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
|
||||||
|
|
||||||
public String getFromLogin() { return fromLogin; }
|
|
||||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
|
||||||
|
|
||||||
public String getToLogin() { return toLogin; }
|
|
||||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
|
||||||
|
|
||||||
public String getText() { return text; }
|
|
||||||
public void setText(String text) { this.text = text; }
|
|
||||||
|
|
||||||
public long getCreatedAtMs() { return createdAtMs; }
|
|
||||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
|
||||||
}
|
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
public class DmDeliveryStateEntry {
|
||||||
|
public static final int ACCEPTED = 0;
|
||||||
|
public static final int DELIVERED = 1;
|
||||||
|
public static final int FAILED = 3;
|
||||||
|
|
||||||
|
// Совместимость со строками БД, созданными ранней экспериментальной миграцией.
|
||||||
|
public static final int PENDING_NONE = ACCEPTED;
|
||||||
|
public static final int DELIVERED_ONE = DELIVERED;
|
||||||
|
public static final int DELIVERED_ALL = 2;
|
||||||
|
public static final int FAILED_FINAL = FAILED;
|
||||||
|
|
||||||
|
private String outgoingMessageKey;
|
||||||
|
private String eventId;
|
||||||
|
private String baseKey;
|
||||||
|
private String fromLogin;
|
||||||
|
private String toLogin;
|
||||||
|
private String incomingMessageKey;
|
||||||
|
private long createdAtMs;
|
||||||
|
private long deliveryExpiresAtMs;
|
||||||
|
private int deliveryState;
|
||||||
|
private String deliveredServerLogin;
|
||||||
|
private String recipientRoutesHash;
|
||||||
|
private int attemptIndex;
|
||||||
|
private Long nextAttemptAtMs;
|
||||||
|
private Long lastAttemptAtMs;
|
||||||
|
private String lastError;
|
||||||
|
private long updatedAtMs;
|
||||||
|
|
||||||
|
public String getOutgoingMessageKey() { return outgoingMessageKey; }
|
||||||
|
public void setOutgoingMessageKey(String value) { this.outgoingMessageKey = value; }
|
||||||
|
public String getEventId() { return eventId; }
|
||||||
|
public void setEventId(String value) { this.eventId = value; }
|
||||||
|
public String getBaseKey() { return baseKey; }
|
||||||
|
public void setBaseKey(String value) { this.baseKey = value; }
|
||||||
|
public String getFromLogin() { return fromLogin; }
|
||||||
|
public void setFromLogin(String value) { this.fromLogin = value; }
|
||||||
|
public String getToLogin() { return toLogin; }
|
||||||
|
public void setToLogin(String value) { this.toLogin = value; }
|
||||||
|
public String getIncomingMessageKey() { return incomingMessageKey; }
|
||||||
|
public void setIncomingMessageKey(String value) { this.incomingMessageKey = value; }
|
||||||
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
public void setCreatedAtMs(long value) { this.createdAtMs = value; }
|
||||||
|
public long getDeliveryExpiresAtMs() { return deliveryExpiresAtMs; }
|
||||||
|
public void setDeliveryExpiresAtMs(long value) { this.deliveryExpiresAtMs = value; }
|
||||||
|
public int getDeliveryState() { return deliveryState; }
|
||||||
|
public void setDeliveryState(int value) { this.deliveryState = value; }
|
||||||
|
public String getDeliveredServerLogin() { return deliveredServerLogin; }
|
||||||
|
public void setDeliveredServerLogin(String value) { this.deliveredServerLogin = value; }
|
||||||
|
public String getRecipientRoutesHash() { return recipientRoutesHash; }
|
||||||
|
public void setRecipientRoutesHash(String value) { this.recipientRoutesHash = value; }
|
||||||
|
public int getAttemptIndex() { return attemptIndex; }
|
||||||
|
public void setAttemptIndex(int value) { this.attemptIndex = value; }
|
||||||
|
public Long getNextAttemptAtMs() { return nextAttemptAtMs; }
|
||||||
|
public void setNextAttemptAtMs(Long value) { this.nextAttemptAtMs = value; }
|
||||||
|
public Long getLastAttemptAtMs() { return lastAttemptAtMs; }
|
||||||
|
public void setLastAttemptAtMs(Long value) { this.lastAttemptAtMs = value; }
|
||||||
|
public String getLastError() { return lastError; }
|
||||||
|
public void setLastError(String value) { this.lastError = value; }
|
||||||
|
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||||
|
public void setUpdatedAtMs(long value) { this.updatedAtMs = value; }
|
||||||
|
|
||||||
|
public String deliveryStateCode() {
|
||||||
|
return switch (deliveryState) {
|
||||||
|
case DELIVERED_ONE, DELIVERED_ALL -> "delivered";
|
||||||
|
case FAILED_FINAL -> "failed";
|
||||||
|
default -> "accepted";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int parseStateCode(String value) {
|
||||||
|
if (value == null) return ACCEPTED;
|
||||||
|
return switch (value.trim().toLowerCase()) {
|
||||||
|
case "delivered", "delivered_one", "delivered_all" -> DELIVERED;
|
||||||
|
case "failed", "failed_final" -> FAILED;
|
||||||
|
default -> ACCEPTED;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDelivered() {
|
||||||
|
return deliveryState == DELIVERED_ONE || deliveryState == DELIVERED_ALL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
public class DmSyncOutboxEntry {
|
||||||
|
private String ownerLogin;
|
||||||
|
private String primaryMessageKey;
|
||||||
|
private String eventId;
|
||||||
|
private String secondaryMessageKey;
|
||||||
|
private boolean synced;
|
||||||
|
private long createdAtMs;
|
||||||
|
private long updatedAtMs;
|
||||||
|
|
||||||
|
public String getOwnerLogin() { return ownerLogin; }
|
||||||
|
public void setOwnerLogin(String value) { this.ownerLogin = value; }
|
||||||
|
public String getPrimaryMessageKey() { return primaryMessageKey; }
|
||||||
|
public void setPrimaryMessageKey(String value) { this.primaryMessageKey = value; }
|
||||||
|
public String getEventId() { return eventId; }
|
||||||
|
public void setEventId(String value) { this.eventId = value; }
|
||||||
|
public String getSecondaryMessageKey() { return secondaryMessageKey; }
|
||||||
|
public void setSecondaryMessageKey(String value) { this.secondaryMessageKey = value; }
|
||||||
|
public boolean isSynced() { return synced; }
|
||||||
|
public void setSynced(boolean value) { this.synced = value; }
|
||||||
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
public void setCreatedAtMs(long value) { this.createdAtMs = value; }
|
||||||
|
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||||
|
public void setUpdatedAtMs(long value) { this.updatedAtMs = value; }
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
public class DmSyncPeerStateEntry {
|
||||||
|
private String ownerLogin;
|
||||||
|
private String remoteServerLogin;
|
||||||
|
private String remoteServerUrl;
|
||||||
|
private long cursorStoredAtMs;
|
||||||
|
private String cursorMessageKey;
|
||||||
|
private boolean bootstrapCompleted;
|
||||||
|
private Long lastSyncAtMs;
|
||||||
|
private String lastError;
|
||||||
|
private long updatedAtMs;
|
||||||
|
|
||||||
|
public String getOwnerLogin() { return ownerLogin; }
|
||||||
|
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||||
|
public String getRemoteServerLogin() { return remoteServerLogin; }
|
||||||
|
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
|
||||||
|
public String getRemoteServerUrl() { return remoteServerUrl; }
|
||||||
|
public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; }
|
||||||
|
public long getCursorStoredAtMs() { return cursorStoredAtMs; }
|
||||||
|
public void setCursorStoredAtMs(long cursorStoredAtMs) { this.cursorStoredAtMs = cursorStoredAtMs; }
|
||||||
|
public String getCursorMessageKey() { return cursorMessageKey; }
|
||||||
|
public void setCursorMessageKey(String cursorMessageKey) { this.cursorMessageKey = cursorMessageKey; }
|
||||||
|
public boolean isBootstrapCompleted() { return bootstrapCompleted; }
|
||||||
|
public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; }
|
||||||
|
public Long getLastSyncAtMs() { return lastSyncAtMs; }
|
||||||
|
public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; }
|
||||||
|
public String getLastError() { return lastError; }
|
||||||
|
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||||
|
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||||
|
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||||
|
}
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
package shine.db.entities;
|
|
||||||
|
|
||||||
public class SignedDirectMessageHistoryEntry {
|
|
||||||
private String messageId;
|
|
||||||
private String fromLogin;
|
|
||||||
private String toLogin;
|
|
||||||
private int targetMode;
|
|
||||||
private String targetSessionId;
|
|
||||||
private int messageType;
|
|
||||||
private long timeMs;
|
|
||||||
private long nonce;
|
|
||||||
private byte[] rawPacket;
|
|
||||||
private long createdAtMs;
|
|
||||||
|
|
||||||
public String getMessageId() { return messageId; }
|
|
||||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
|
||||||
public String getFromLogin() { return fromLogin; }
|
|
||||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
|
||||||
public String getToLogin() { return toLogin; }
|
|
||||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
|
||||||
public int getTargetMode() { return targetMode; }
|
|
||||||
public void setTargetMode(int targetMode) { this.targetMode = targetMode; }
|
|
||||||
public String getTargetSessionId() { return targetSessionId; }
|
|
||||||
public void setTargetSessionId(String targetSessionId) { this.targetSessionId = targetSessionId; }
|
|
||||||
public int getMessageType() { return messageType; }
|
|
||||||
public void setMessageType(int messageType) { this.messageType = messageType; }
|
|
||||||
public long getTimeMs() { return timeMs; }
|
|
||||||
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
|
||||||
public long getNonce() { return nonce; }
|
|
||||||
public void setNonce(long nonce) { this.nonce = nonce; }
|
|
||||||
public byte[] getRawPacket() { return rawPacket; }
|
|
||||||
public void setRawPacket(byte[] rawPacket) { this.rawPacket = rawPacket; }
|
|
||||||
public long getCreatedAtMs() { return createdAtMs; }
|
|
||||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
package shine.db.entities;
|
package shine.db.entities;
|
||||||
|
|
||||||
public class SignedMessageV2Entry {
|
public class SignedMessageEntry {
|
||||||
private String messageKey;
|
private String messageKey;
|
||||||
private String baseKey;
|
private String baseKey;
|
||||||
private String targetLogin;
|
private String targetLogin;
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный срез текущей записи пользователя из PostgreSQL sync-таблицы.
|
||||||
|
*
|
||||||
|
* Источник: solana_user_pda_current
|
||||||
|
*/
|
||||||
|
public final class SolanaUserPdaCurrentEntry {
|
||||||
|
|
||||||
|
private String login;
|
||||||
|
private String blockchainName;
|
||||||
|
private String blockchainKey;
|
||||||
|
private long paidLimitBytes;
|
||||||
|
|
||||||
|
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 String getBlockchainKey() {
|
||||||
|
return blockchainKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBlockchainKey(String blockchainKey) {
|
||||||
|
this.blockchainKey = blockchainKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getPaidLimitBytes() {
|
||||||
|
return paidLimitBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPaidLimitBytes(long paidLimitBytes) {
|
||||||
|
this.paidLimitBytes = paidLimitBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локальная routing-проекция access server пользователя.
|
||||||
|
*
|
||||||
|
* Источник:
|
||||||
|
* - user_access_servers_current
|
||||||
|
*/
|
||||||
|
public class UserAccessServerRouteEntry {
|
||||||
|
|
||||||
|
private String userLogin;
|
||||||
|
private String serverLogin;
|
||||||
|
private String serverUrl;
|
||||||
|
private String serverClientKey;
|
||||||
|
|
||||||
|
public String getUserLogin() { return userLogin; }
|
||||||
|
public void setUserLogin(String userLogin) { this.userLogin = userLogin; }
|
||||||
|
|
||||||
|
public String getServerLogin() { return serverLogin; }
|
||||||
|
public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; }
|
||||||
|
|
||||||
|
public String getServerUrl() { return serverUrl; }
|
||||||
|
public void setServerUrl(String serverUrl) { this.serverUrl = serverUrl; }
|
||||||
|
|
||||||
|
public String getServerClientKey() { return serverClientKey; }
|
||||||
|
public void setServerClientKey(String serverClientKey) { this.serverClientKey = serverClientKey; }
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
public class UserNotificationEntry {
|
||||||
|
private String ownerLogin;
|
||||||
|
private String notificationKind;
|
||||||
|
private long createdAtMs;
|
||||||
|
private String sourceLogin;
|
||||||
|
private String sourceBchName;
|
||||||
|
private int sourceBlockNumber;
|
||||||
|
private byte[] sourceBlockHash;
|
||||||
|
private String targetLogin;
|
||||||
|
private String targetBchName;
|
||||||
|
private Integer targetBlockNumber;
|
||||||
|
private byte[] targetBlockHash;
|
||||||
|
private int sourceMsgSubType;
|
||||||
|
private String sourceText;
|
||||||
|
|
||||||
|
public String getOwnerLogin() { return ownerLogin; }
|
||||||
|
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||||
|
public String getNotificationKind() { return notificationKind; }
|
||||||
|
public void setNotificationKind(String notificationKind) { this.notificationKind = notificationKind; }
|
||||||
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||||
|
public String getSourceLogin() { return sourceLogin; }
|
||||||
|
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
|
||||||
|
public String getSourceBchName() { return sourceBchName; }
|
||||||
|
public void setSourceBchName(String sourceBchName) { this.sourceBchName = sourceBchName; }
|
||||||
|
public int getSourceBlockNumber() { return sourceBlockNumber; }
|
||||||
|
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
|
||||||
|
public byte[] getSourceBlockHash() { return sourceBlockHash; }
|
||||||
|
public void setSourceBlockHash(byte[] sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
|
||||||
|
public String getTargetLogin() { return targetLogin; }
|
||||||
|
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||||
|
public String getTargetBchName() { return targetBchName; }
|
||||||
|
public void setTargetBchName(String targetBchName) { this.targetBchName = targetBchName; }
|
||||||
|
public Integer getTargetBlockNumber() { return targetBlockNumber; }
|
||||||
|
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
|
||||||
|
public byte[] getTargetBlockHash() { return targetBlockHash; }
|
||||||
|
public void setTargetBlockHash(byte[] targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||||
|
public int getSourceMsgSubType() { return sourceMsgSubType; }
|
||||||
|
public void setSourceMsgSubType(int sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
|
||||||
|
public String getSourceText() { return sourceText; }
|
||||||
|
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
/** Одна локально сохранённая подписанная пользовательская настройка. */
|
||||||
|
public class UserSettingEntry {
|
||||||
|
private String login;
|
||||||
|
private int settingType;
|
||||||
|
private String settingKey;
|
||||||
|
private long timeMs;
|
||||||
|
private String valueText;
|
||||||
|
private long valueNum;
|
||||||
|
private String clientKey;
|
||||||
|
private String signature;
|
||||||
|
|
||||||
|
public UserSettingEntry() {}
|
||||||
|
|
||||||
|
public UserSettingEntry(
|
||||||
|
String login,
|
||||||
|
int settingType,
|
||||||
|
String settingKey,
|
||||||
|
long timeMs,
|
||||||
|
String valueText,
|
||||||
|
long valueNum,
|
||||||
|
String clientKey,
|
||||||
|
String signature
|
||||||
|
) {
|
||||||
|
this.login = login;
|
||||||
|
this.settingType = settingType;
|
||||||
|
this.settingKey = settingKey;
|
||||||
|
this.timeMs = timeMs;
|
||||||
|
this.valueText = valueText;
|
||||||
|
this.valueNum = valueNum;
|
||||||
|
this.clientKey = clientKey;
|
||||||
|
this.signature = signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public int getSettingType() { return settingType; }
|
||||||
|
public void setSettingType(int settingType) { this.settingType = settingType; }
|
||||||
|
public String getSettingKey() { return settingKey; }
|
||||||
|
public void setSettingKey(String settingKey) { this.settingKey = settingKey; }
|
||||||
|
public long getTimeMs() { return timeMs; }
|
||||||
|
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||||
|
public String getValueText() { return valueText; }
|
||||||
|
public void setValueText(String valueText) { this.valueText = valueText; }
|
||||||
|
public long getValueNum() { return valueNum; }
|
||||||
|
public void setValueNum(long valueNum) { this.valueNum = valueNum; }
|
||||||
|
public String getClientKey() { return clientKey; }
|
||||||
|
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
|
||||||
|
public String getSignature() { return signature; }
|
||||||
|
public void setSignature(String signature) { this.signature = signature; }
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
public class UserSettingsSyncPeerStateEntry {
|
||||||
|
private String ownerLogin;
|
||||||
|
private String remoteServerLogin;
|
||||||
|
private String remoteServerUrl;
|
||||||
|
private long cursorTimeMs;
|
||||||
|
private String cursorSettingKey;
|
||||||
|
private boolean bootstrapCompleted;
|
||||||
|
private Long lastSyncAtMs;
|
||||||
|
private String lastError;
|
||||||
|
private long updatedAtMs;
|
||||||
|
|
||||||
|
public String getOwnerLogin() { return ownerLogin; }
|
||||||
|
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||||
|
|
||||||
|
public String getRemoteServerLogin() { return remoteServerLogin; }
|
||||||
|
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
|
||||||
|
|
||||||
|
public String getRemoteServerUrl() { return remoteServerUrl; }
|
||||||
|
public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; }
|
||||||
|
|
||||||
|
public long getCursorTimeMs() { return cursorTimeMs; }
|
||||||
|
public void setCursorTimeMs(long cursorTimeMs) { this.cursorTimeMs = cursorTimeMs; }
|
||||||
|
|
||||||
|
public String getCursorSettingKey() { return cursorSettingKey; }
|
||||||
|
public void setCursorSettingKey(String cursorSettingKey) { this.cursorSettingKey = cursorSettingKey; }
|
||||||
|
|
||||||
|
public boolean isBootstrapCompleted() { return bootstrapCompleted; }
|
||||||
|
public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; }
|
||||||
|
|
||||||
|
public Long getLastSyncAtMs() { return lastSyncAtMs; }
|
||||||
|
public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; }
|
||||||
|
|
||||||
|
public String getLastError() { return lastError; }
|
||||||
|
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||||
|
|
||||||
|
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||||
|
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package shine.db.sql;
|
||||||
|
|
||||||
|
public final class CurrentUsersSql {
|
||||||
|
|
||||||
|
private CurrentUsersSql() {}
|
||||||
|
|
||||||
|
public static String usersSubquery(String alias) {
|
||||||
|
if (alias == null || alias.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("alias is blank");
|
||||||
|
}
|
||||||
|
return """
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
current_users.login AS login,
|
||||||
|
current_users.normalized_login AS normalized_login,
|
||||||
|
current_users.blockchain_name AS blockchain_name,
|
||||||
|
current_users.client_key AS solana_key,
|
||||||
|
current_users.blockchain_key AS blockchain_key,
|
||||||
|
current_users.client_key AS client_key,
|
||||||
|
current_users.is_server AS is_server
|
||||||
|
FROM solana_user_pda_current current_users
|
||||||
|
) %s
|
||||||
|
""".formatted(alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
||||||
|
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||||
|
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
source_login TEXT NOT NULL,
|
||||||
|
source_bch_name TEXT NOT NULL,
|
||||||
|
source_block_number INTEGER NOT NULL CHECK (source_block_number >= 0),
|
||||||
|
source_block_hash BYTEA NOT NULL,
|
||||||
|
target_login TEXT,
|
||||||
|
target_bch_name TEXT,
|
||||||
|
target_block_number INTEGER,
|
||||||
|
target_block_hash BYTEA,
|
||||||
|
source_msg_sub_type INTEGER NOT NULL,
|
||||||
|
source_text TEXT NOT NULL DEFAULT '',
|
||||||
|
UNIQUE (owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_kind_time
|
||||||
|
ON user_notifications_state(owner_login, notification_kind, created_at_ms DESC, source_block_number DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_time
|
||||||
|
ON user_notifications_state(owner_login, created_at_ms DESC);
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 10, 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;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_dialog_state (
|
||||||
|
owner_login TEXT NOT NULL,
|
||||||
|
peer_login TEXT NOT NULL,
|
||||||
|
relation_flag TEXT NOT NULL DEFAULT 'none',
|
||||||
|
last_message_blob_b64 TEXT NOT NULL DEFAULT '',
|
||||||
|
last_message_key TEXT NOT NULL DEFAULT '',
|
||||||
|
last_message_type INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_message_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_message_revision_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_read_receipt_time_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_login, peer_login)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS dm_dialog_state
|
||||||
|
ADD COLUMN IF NOT EXISTS last_message_blob_b64 TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_dialog_state_owner_last_time
|
||||||
|
ON dm_dialog_state(owner_login, last_message_time_ms DESC, peer_login);
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 11, 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;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_delivery_state (
|
||||||
|
outgoing_message_key TEXT PRIMARY KEY,
|
||||||
|
event_id TEXT NOT NULL UNIQUE,
|
||||||
|
base_key TEXT NOT NULL,
|
||||||
|
from_login TEXT NOT NULL,
|
||||||
|
to_login TEXT NOT NULL,
|
||||||
|
incoming_message_key TEXT NOT NULL,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
delivery_expires_at_ms BIGINT NOT NULL,
|
||||||
|
delivery_state INTEGER NOT NULL DEFAULT 0 CHECK (delivery_state IN (0, 1, 2, 3)),
|
||||||
|
delivered_server_login TEXT,
|
||||||
|
recipient_routes_hash TEXT,
|
||||||
|
attempt_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at_ms BIGINT,
|
||||||
|
last_attempt_at_ms BIGINT,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at_ms BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_due
|
||||||
|
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
|
||||||
|
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_base
|
||||||
|
ON dm_delivery_state(base_key, from_login);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_sync_outbox (
|
||||||
|
owner_login TEXT NOT NULL,
|
||||||
|
primary_message_key TEXT NOT NULL,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
secondary_message_key TEXT,
|
||||||
|
synced BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_login, primary_message_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_sync_outbox_unsynced
|
||||||
|
ON dm_sync_outbox(owner_login, created_at_ms, primary_message_key)
|
||||||
|
WHERE synced = FALSE;
|
||||||
|
|
||||||
|
-- До v12 факт межсерверной доставки не сохранялся. Старые исходящие пары
|
||||||
|
-- считаем уже доставленными, чтобы обновление не вызвало повторную рассылку.
|
||||||
|
INSERT INTO dm_delivery_state (
|
||||||
|
outgoing_message_key, event_id, base_key, from_login, to_login,
|
||||||
|
incoming_message_key, created_at_ms, delivery_expires_at_ms,
|
||||||
|
delivery_state, delivered_server_login, recipient_routes_hash,
|
||||||
|
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
outgoing.message_key,
|
||||||
|
outgoing.message_key || ':' || outgoing.revision_time_ms || ':' || outgoing.reencrypted_at_ms,
|
||||||
|
outgoing.base_key,
|
||||||
|
outgoing.from_login,
|
||||||
|
outgoing.to_login,
|
||||||
|
incoming.message_key,
|
||||||
|
outgoing.created_at_ms,
|
||||||
|
outgoing.created_at_ms + 3600000,
|
||||||
|
2,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
9,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
'MIGRATED_AS_DELIVERED',
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
FROM signed_messages outgoing
|
||||||
|
JOIN signed_messages incoming
|
||||||
|
ON incoming.base_key = outgoing.base_key
|
||||||
|
AND incoming.message_type = outgoing.message_type - 1
|
||||||
|
WHERE outgoing.message_type IN (2, 4)
|
||||||
|
ON CONFLICT (outgoing_message_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Историю до v12 считаем подтверждённой, иначе само обновление вызовет
|
||||||
|
-- массовую повторную передачу. При замене сервера общий reset вернёт FALSE.
|
||||||
|
INSERT INTO dm_sync_outbox (
|
||||||
|
owner_login, primary_message_key, event_id, secondary_message_key,
|
||||||
|
synced, created_at_ms, updated_at_ms
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
LOWER(outgoing.from_login),
|
||||||
|
outgoing.message_key,
|
||||||
|
outgoing.message_key || ':' || outgoing.revision_time_ms || ':' || outgoing.reencrypted_at_ms,
|
||||||
|
incoming.message_key,
|
||||||
|
TRUE,
|
||||||
|
outgoing.created_at_ms,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
FROM signed_messages outgoing
|
||||||
|
JOIN signed_messages incoming
|
||||||
|
ON incoming.base_key = outgoing.base_key
|
||||||
|
AND incoming.message_type = outgoing.message_type - 1
|
||||||
|
WHERE outgoing.message_type IN (2, 4)
|
||||||
|
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Входящая копия синхронизируется отдельно между серверами получателя.
|
||||||
|
INSERT INTO dm_sync_outbox (
|
||||||
|
owner_login, primary_message_key, event_id, secondary_message_key,
|
||||||
|
synced, created_at_ms, updated_at_ms
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
LOWER(incoming.to_login),
|
||||||
|
incoming.message_key,
|
||||||
|
incoming.message_key || ':' || incoming.revision_time_ms || ':' || incoming.reencrypted_at_ms,
|
||||||
|
NULL,
|
||||||
|
TRUE,
|
||||||
|
incoming.created_at_ms,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
FROM signed_messages incoming
|
||||||
|
WHERE incoming.message_type IN (1, 3)
|
||||||
|
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Tombstone принадлежит обоим участникам и должен попасть на второй сервер
|
||||||
|
-- каждого из них. Для self-DM конфликт безопасно схлопывается.
|
||||||
|
INSERT INTO dm_sync_outbox (
|
||||||
|
owner_login, primary_message_key, event_id, secondary_message_key,
|
||||||
|
synced, created_at_ms, updated_at_ms
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
LOWER(owner_login),
|
||||||
|
tombstone.message_key,
|
||||||
|
tombstone.message_key || ':' || tombstone.revision_time_ms || ':' || tombstone.reencrypted_at_ms,
|
||||||
|
NULL,
|
||||||
|
TRUE,
|
||||||
|
tombstone.created_at_ms,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
FROM signed_messages tombstone
|
||||||
|
CROSS JOIN LATERAL (VALUES (tombstone.from_login), (tombstone.to_login)) owners(owner_login)
|
||||||
|
WHERE tombstone.message_type IN (5, 6, 7, 8)
|
||||||
|
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 12, 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;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Нормализация экспериментальной схемы доставки v12:
|
||||||
|
-- 0=accepted, 1=delivered, 3=failed. Старое delivered_all (2)
|
||||||
|
-- объединяется с delivered, поскольку ACK одного сервера теперь достаточен.
|
||||||
|
UPDATE dm_delivery_state
|
||||||
|
SET delivery_state = 1,
|
||||||
|
delivered_server_login = NULL,
|
||||||
|
next_attempt_at_ms = NULL,
|
||||||
|
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
WHERE delivery_state = 2;
|
||||||
|
|
||||||
|
-- Для незавершённых записей перестраиваем только будущую очередь. Повторная
|
||||||
|
-- передача безопасна благодаря messageKey и идемпотентному приёму.
|
||||||
|
UPDATE dm_delivery_state
|
||||||
|
SET delivery_expires_at_ms = created_at_ms + 3600000,
|
||||||
|
attempt_index = CASE
|
||||||
|
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 3600000 THEN 4
|
||||||
|
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 1500000 THEN 3
|
||||||
|
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 300000 THEN 2
|
||||||
|
ELSE 1
|
||||||
|
END,
|
||||||
|
next_attempt_at_ms = CASE
|
||||||
|
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 3600000
|
||||||
|
THEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 1500000
|
||||||
|
THEN created_at_ms + 3600000
|
||||||
|
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 300000
|
||||||
|
THEN created_at_ms + 1500000
|
||||||
|
ELSE created_at_ms + 30000
|
||||||
|
END,
|
||||||
|
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
WHERE delivery_state = 0;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_dm_delivery_state_due;
|
||||||
|
CREATE INDEX idx_dm_delivery_state_due
|
||||||
|
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
|
||||||
|
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 13, 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;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_stats_state (
|
||||||
|
login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||||
|
owned_public_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (owned_public_channels_count >= 0),
|
||||||
|
following_users_count INTEGER NOT NULL DEFAULT 0 CHECK (following_users_count >= 0),
|
||||||
|
following_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (following_channels_count >= 0),
|
||||||
|
close_friends_count INTEGER NOT NULL DEFAULT 0 CHECK (close_friends_count >= 0),
|
||||||
|
updated_at_ms BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_stats_state_following_users_count
|
||||||
|
ON user_stats_state (following_users_count);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_stats_state (
|
||||||
|
owner_bch_name TEXT NOT NULL,
|
||||||
|
channel_root_block_number INTEGER NOT NULL CHECK (channel_root_block_number >= 0),
|
||||||
|
channel_root_block_hash BYTEA NOT NULL,
|
||||||
|
owner_login TEXT NOT NULL,
|
||||||
|
channel_type_code INTEGER NOT NULL DEFAULT 1,
|
||||||
|
subscribers_count INTEGER NOT NULL DEFAULT 0 CHECK (subscribers_count >= 0),
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_bch_name, channel_root_block_number, channel_root_block_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channel_stats_state_owner_login
|
||||||
|
ON channel_stats_state (owner_login);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_user_stats_state_ai()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
now_ms BIGINT;
|
||||||
|
BEGIN
|
||||||
|
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||||
|
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
now_ms
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO NOTHING;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
now_ms BIGINT;
|
||||||
|
public_subscribers_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||||
|
|
||||||
|
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.channel_type_code = 1 THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.owner_login,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
now_ms
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
|
||||||
|
SELECT COUNT(*)::INTEGER
|
||||||
|
INTO public_subscribers_count
|
||||||
|
FROM connections_state cs
|
||||||
|
JOIN channel_names_state cn
|
||||||
|
ON cn.owner_bch_name = cs.to_bch_name
|
||||||
|
AND cn.channel_root_block_number = cs.to_block_number
|
||||||
|
AND cn.channel_root_block_hash = cs.to_block_hash
|
||||||
|
WHERE cs.rel_type = 30
|
||||||
|
AND cn.owner_bch_name = NEW.owner_bch_name
|
||||||
|
AND cn.channel_root_block_number = NEW.channel_root_block_number
|
||||||
|
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
|
||||||
|
AND cn.channel_type_code = 1;
|
||||||
|
|
||||||
|
INSERT INTO channel_stats_state (
|
||||||
|
owner_bch_name,
|
||||||
|
channel_root_block_number,
|
||||||
|
channel_root_block_hash,
|
||||||
|
owner_login,
|
||||||
|
channel_type_code,
|
||||||
|
subscribers_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.owner_bch_name,
|
||||||
|
NEW.channel_root_block_number,
|
||||||
|
NEW.channel_root_block_hash,
|
||||||
|
NEW.owner_login,
|
||||||
|
NEW.channel_type_code,
|
||||||
|
COALESCE(public_subscribers_count, 0),
|
||||||
|
now_ms
|
||||||
|
)
|
||||||
|
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||||
|
owner_login = EXCLUDED.owner_login,
|
||||||
|
channel_type_code = EXCLUDED.channel_type_code,
|
||||||
|
subscribers_count = EXCLUDED.subscribers_count,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSE
|
||||||
|
WITH pending AS (
|
||||||
|
SELECT cs.login, COUNT(*)::INTEGER AS cnt
|
||||||
|
FROM connections_state cs
|
||||||
|
WHERE cs.rel_type = 30
|
||||||
|
AND cs.to_bch_name = NEW.owner_bch_name
|
||||||
|
AND cs.to_block_number = NEW.channel_root_block_number
|
||||||
|
AND cs.to_block_hash = NEW.channel_root_block_hash
|
||||||
|
GROUP BY cs.login
|
||||||
|
)
|
||||||
|
UPDATE user_stats_state us
|
||||||
|
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
|
||||||
|
updated_at_ms = now_ms
|
||||||
|
FROM pending
|
||||||
|
WHERE us.login = pending.login;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
resolved_login TEXT;
|
||||||
|
positive_rel_type INTEGER;
|
||||||
|
existed_before BOOLEAN;
|
||||||
|
target_channel_type INTEGER;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.msg_type <> 3 THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
|
||||||
|
|
||||||
|
IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN
|
||||||
|
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM connections_state
|
||||||
|
WHERE login = NEW.login
|
||||||
|
AND rel_type = NEW.msg_sub_type
|
||||||
|
AND to_login = resolved_login
|
||||||
|
AND to_bch_name = NEW.to_bch_name
|
||||||
|
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||||
|
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||||
|
)
|
||||||
|
INTO existed_before;
|
||||||
|
|
||||||
|
IF NOT existed_before THEN
|
||||||
|
IF NEW.msg_sub_type = 10 THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
close_friends_count = user_stats_state.close_friends_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSIF NEW.msg_sub_type = 30 THEN
|
||||||
|
IF NEW.to_block_number IS NULL THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSIF NEW.to_block_number = 0 THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_users_count = user_stats_state.following_users_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSE
|
||||||
|
SELECT cn.channel_type_code
|
||||||
|
INTO target_channel_type
|
||||||
|
FROM channel_names_state cn
|
||||||
|
WHERE cn.owner_bch_name = NEW.to_bch_name
|
||||||
|
AND cn.channel_root_block_number = NEW.to_block_number
|
||||||
|
AND cn.channel_root_block_hash = NEW.to_block_hash
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF target_channel_type = 1 THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
|
||||||
|
INSERT INTO channel_stats_state (
|
||||||
|
owner_bch_name,
|
||||||
|
channel_root_block_number,
|
||||||
|
channel_root_block_hash,
|
||||||
|
owner_login,
|
||||||
|
channel_type_code,
|
||||||
|
subscribers_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.to_bch_name,
|
||||||
|
NEW.to_block_number,
|
||||||
|
NEW.to_block_hash,
|
||||||
|
resolved_login,
|
||||||
|
target_channel_type,
|
||||||
|
1,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||||
|
owner_login = EXCLUDED.owner_login,
|
||||||
|
channel_type_code = EXCLUDED.channel_type_code,
|
||||||
|
subscribers_count = channel_stats_state.subscribers_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSIF target_channel_type IS NULL THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM connections_state
|
||||||
|
WHERE login = NEW.login
|
||||||
|
AND rel_type = NEW.msg_sub_type
|
||||||
|
AND to_login = resolved_login
|
||||||
|
AND to_bch_name = NEW.to_bch_name
|
||||||
|
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||||
|
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
|
||||||
|
|
||||||
|
INSERT INTO connections_state (
|
||||||
|
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
NEW.msg_sub_type,
|
||||||
|
resolved_login,
|
||||||
|
NEW.to_bch_name,
|
||||||
|
COALESCE(NEW.to_block_number, 0),
|
||||||
|
COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
positive_rel_type := CASE NEW.msg_sub_type
|
||||||
|
WHEN 11 THEN 10
|
||||||
|
WHEN 21 THEN 20
|
||||||
|
WHEN 31 THEN 30
|
||||||
|
WHEN 41 THEN 40
|
||||||
|
WHEN 51 THEN 50
|
||||||
|
WHEN 53 THEN 52
|
||||||
|
WHEN 55 THEN 54
|
||||||
|
WHEN 61 THEN 60
|
||||||
|
WHEN 71 THEN 70
|
||||||
|
WHEN 75 THEN 74
|
||||||
|
ELSE NULL
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF positive_rel_type IS NULL OR resolved_login IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM connections_state
|
||||||
|
WHERE login = NEW.login
|
||||||
|
AND rel_type = positive_rel_type
|
||||||
|
AND to_login = resolved_login
|
||||||
|
AND to_bch_name = NEW.to_bch_name
|
||||||
|
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||||
|
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||||
|
)
|
||||||
|
INTO existed_before;
|
||||||
|
|
||||||
|
IF NOT existed_before THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF positive_rel_type = 10 THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSIF positive_rel_type = 30 THEN
|
||||||
|
IF NEW.to_block_number IS NULL THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSIF NEW.to_block_number = 0 THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSE
|
||||||
|
SELECT cn.channel_type_code
|
||||||
|
INTO target_channel_type
|
||||||
|
FROM channel_names_state cn
|
||||||
|
WHERE cn.owner_bch_name = NEW.to_bch_name
|
||||||
|
AND cn.channel_root_block_number = NEW.to_block_number
|
||||||
|
AND cn.channel_root_block_hash = NEW.to_block_hash
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF target_channel_type = 1 THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
|
||||||
|
INSERT INTO channel_stats_state (
|
||||||
|
owner_bch_name,
|
||||||
|
channel_root_block_number,
|
||||||
|
channel_root_block_hash,
|
||||||
|
owner_login,
|
||||||
|
channel_type_code,
|
||||||
|
subscribers_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.to_bch_name,
|
||||||
|
NEW.to_block_number,
|
||||||
|
NEW.to_block_hash,
|
||||||
|
resolved_login,
|
||||||
|
target_channel_type,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||||
|
owner_login = EXCLUDED.owner_login,
|
||||||
|
channel_type_code = EXCLUDED.channel_type_code,
|
||||||
|
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSIF target_channel_type IS NULL THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM connections_state
|
||||||
|
WHERE login = NEW.login
|
||||||
|
AND rel_type = positive_rel_type
|
||||||
|
AND to_login = resolved_login
|
||||||
|
AND to_bch_name = NEW.to_bch_name
|
||||||
|
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||||
|
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
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;
|
||||||
|
CREATE TRIGGER trg_channel_names_state_stats_ai
|
||||||
|
AFTER INSERT ON channel_names_state
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai ON blocks;
|
||||||
|
CREATE TRIGGER trg_blocks_connection_state_ai
|
||||||
|
AFTER INSERT ON blocks
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION shine_blocks_connection_state_ai();
|
||||||
|
|
||||||
|
TRUNCATE TABLE user_stats_state, channel_stats_state;
|
||||||
|
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
u.login,
|
||||||
|
COALESCE(own.owned_public_channels_count, 0) AS owned_public_channels_count,
|
||||||
|
COALESCE(fu.following_users_count, 0) AS following_users_count,
|
||||||
|
COALESCE(fc.following_channels_count, 0) AS following_channels_count,
|
||||||
|
COALESCE(cf.close_friends_count, 0) AS close_friends_count,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms
|
||||||
|
FROM solana_user_pda_current u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
|
||||||
|
FROM channel_names_state
|
||||||
|
WHERE channel_type_code = 1
|
||||||
|
GROUP BY owner_login
|
||||||
|
) own ON LOWER(own.owner_login) = LOWER(u.login)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT login, COUNT(*)::INTEGER AS following_users_count
|
||||||
|
FROM connections_state
|
||||||
|
WHERE rel_type = 30
|
||||||
|
AND to_block_number = 0
|
||||||
|
GROUP BY login
|
||||||
|
) fu ON LOWER(fu.login) = LOWER(u.login)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
|
||||||
|
FROM connections_state cs
|
||||||
|
JOIN channel_names_state cn
|
||||||
|
ON cn.owner_bch_name = cs.to_bch_name
|
||||||
|
AND cn.channel_root_block_number = cs.to_block_number
|
||||||
|
AND cn.channel_root_block_hash = cs.to_block_hash
|
||||||
|
WHERE cs.rel_type = 30
|
||||||
|
AND cn.channel_type_code = 1
|
||||||
|
GROUP BY cs.login
|
||||||
|
) fc ON LOWER(fc.login) = LOWER(u.login)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT login, COUNT(*)::INTEGER AS close_friends_count
|
||||||
|
FROM connections_state
|
||||||
|
WHERE rel_type = 10
|
||||||
|
GROUP BY login
|
||||||
|
) cf ON LOWER(cf.login) = LOWER(u.login);
|
||||||
|
|
||||||
|
INSERT INTO channel_stats_state (
|
||||||
|
owner_bch_name,
|
||||||
|
channel_root_block_number,
|
||||||
|
channel_root_block_hash,
|
||||||
|
owner_login,
|
||||||
|
channel_type_code,
|
||||||
|
subscribers_count,
|
||||||
|
updated_at_ms
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
cn.owner_bch_name,
|
||||||
|
cn.channel_root_block_number,
|
||||||
|
cn.channel_root_block_hash,
|
||||||
|
cn.owner_login,
|
||||||
|
cn.channel_type_code,
|
||||||
|
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms
|
||||||
|
FROM channel_names_state cn
|
||||||
|
LEFT JOIN connections_state cs
|
||||||
|
ON cs.rel_type = 30
|
||||||
|
AND cs.to_bch_name = cn.owner_bch_name
|
||||||
|
AND cs.to_block_number = cn.channel_root_block_number
|
||||||
|
AND cs.to_block_hash = cn.channel_root_block_hash
|
||||||
|
WHERE cn.channel_type_code = 1
|
||||||
|
GROUP BY
|
||||||
|
cn.owner_bch_name,
|
||||||
|
cn.channel_root_block_number,
|
||||||
|
cn.channel_root_block_hash,
|
||||||
|
cn.owner_login,
|
||||||
|
cn.channel_type_code;
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 14, 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;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
now_ms BIGINT;
|
||||||
|
public_subscribers_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||||
|
|
||||||
|
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.channel_type_code = 1 THEN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM solana_user_pda_current su
|
||||||
|
WHERE su.login = NEW.owner_login
|
||||||
|
) THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.owner_login,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
now_ms
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*)::INTEGER
|
||||||
|
INTO public_subscribers_count
|
||||||
|
FROM connections_state cs
|
||||||
|
JOIN channel_names_state cn
|
||||||
|
ON cn.owner_bch_name = cs.to_bch_name
|
||||||
|
AND cn.channel_root_block_number = cs.to_block_number
|
||||||
|
AND cn.channel_root_block_hash = cs.to_block_hash
|
||||||
|
WHERE cs.rel_type = 30
|
||||||
|
AND cn.owner_bch_name = NEW.owner_bch_name
|
||||||
|
AND cn.channel_root_block_number = NEW.channel_root_block_number
|
||||||
|
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
|
||||||
|
AND cn.channel_type_code = 1;
|
||||||
|
|
||||||
|
INSERT INTO channel_stats_state (
|
||||||
|
owner_bch_name,
|
||||||
|
channel_root_block_number,
|
||||||
|
channel_root_block_hash,
|
||||||
|
owner_login,
|
||||||
|
channel_type_code,
|
||||||
|
subscribers_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.owner_bch_name,
|
||||||
|
NEW.channel_root_block_number,
|
||||||
|
NEW.channel_root_block_hash,
|
||||||
|
NEW.owner_login,
|
||||||
|
NEW.channel_type_code,
|
||||||
|
COALESCE(public_subscribers_count, 0),
|
||||||
|
now_ms
|
||||||
|
)
|
||||||
|
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||||
|
owner_login = EXCLUDED.owner_login,
|
||||||
|
channel_type_code = EXCLUDED.channel_type_code,
|
||||||
|
subscribers_count = EXCLUDED.subscribers_count,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSE
|
||||||
|
WITH pending AS (
|
||||||
|
SELECT cs.login, COUNT(*)::INTEGER AS cnt
|
||||||
|
FROM connections_state cs
|
||||||
|
WHERE cs.rel_type = 30
|
||||||
|
AND cs.to_bch_name = NEW.owner_bch_name
|
||||||
|
AND cs.to_block_number = NEW.channel_root_block_number
|
||||||
|
AND cs.to_block_hash = NEW.channel_root_block_hash
|
||||||
|
GROUP BY cs.login
|
||||||
|
)
|
||||||
|
UPDATE user_stats_state us
|
||||||
|
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
|
||||||
|
updated_at_ms = now_ms
|
||||||
|
FROM pending
|
||||||
|
WHERE us.login = pending.login;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 15, 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;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- У пользователя действует только первый access_server из Solana PDA.
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_user(p_user_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF p_user_login IS NULL OR btrim(p_user_login) = '' THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM user_access_servers_current
|
||||||
|
WHERE LOWER(user_login) = LOWER(p_user_login);
|
||||||
|
|
||||||
|
INSERT INTO user_access_servers_current (
|
||||||
|
user_login,
|
||||||
|
server_login,
|
||||||
|
server_url,
|
||||||
|
server_client_key,
|
||||||
|
user_record_number,
|
||||||
|
user_updated_at_ms,
|
||||||
|
server_record_number,
|
||||||
|
server_updated_at_ms,
|
||||||
|
refreshed_at_ms
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
u.login,
|
||||||
|
s.login,
|
||||||
|
s.server_address,
|
||||||
|
s.client_key,
|
||||||
|
u.record_number,
|
||||||
|
u.updated_at_ms,
|
||||||
|
s.record_number,
|
||||||
|
s.updated_at_ms,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
FROM solana_user_pda_current u
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT login_value
|
||||||
|
FROM jsonb_array_elements_text(
|
||||||
|
CASE
|
||||||
|
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||||
|
ELSE u.access_servers_json::jsonb
|
||||||
|
END
|
||||||
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
|
WHERE ord = 1
|
||||||
|
) AS access_server
|
||||||
|
JOIN solana_user_pda_current s
|
||||||
|
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||||
|
AND s.is_server = TRUE
|
||||||
|
AND btrim(COALESCE(s.server_address, '')) <> ''
|
||||||
|
WHERE LOWER(u.login) = LOWER(p_user_login)
|
||||||
|
ON CONFLICT (user_login, server_login) DO UPDATE SET
|
||||||
|
server_url = EXCLUDED.server_url,
|
||||||
|
server_client_key = EXCLUDED.server_client_key,
|
||||||
|
user_record_number = EXCLUDED.user_record_number,
|
||||||
|
user_updated_at_ms = EXCLUDED.user_updated_at_ms,
|
||||||
|
server_record_number = EXCLUDED.server_record_number,
|
||||||
|
server_updated_at_ms = EXCLUDED.server_updated_at_ms,
|
||||||
|
refreshed_at_ms = EXCLUDED.refreshed_at_ms;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_server(p_server_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
affected_user RECORD;
|
||||||
|
BEGIN
|
||||||
|
IF p_server_login IS NULL OR btrim(p_server_login) = '' THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM user_access_servers_current
|
||||||
|
WHERE LOWER(server_login) = LOWER(p_server_login);
|
||||||
|
|
||||||
|
FOR affected_user IN
|
||||||
|
SELECT u.login
|
||||||
|
FROM solana_user_pda_current u
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT login_value
|
||||||
|
FROM jsonb_array_elements_text(
|
||||||
|
CASE
|
||||||
|
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||||
|
ELSE u.access_servers_json::jsonb
|
||||||
|
END
|
||||||
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
|
WHERE ord = 1
|
||||||
|
) AS access_server
|
||||||
|
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||||
|
LOOP
|
||||||
|
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS dm_sync_outbox;
|
||||||
|
DROP TABLE IF EXISTS dm_sync_peer_state;
|
||||||
|
DROP TABLE IF EXISTS user_settings_sync_peer_state;
|
||||||
|
DROP INDEX IF EXISTS idx_user_settings_synced;
|
||||||
|
ALTER TABLE user_settings DROP COLUMN IF EXISTS synced;
|
||||||
|
|
||||||
|
SELECT shine_refresh_user_access_servers_all();
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 16, 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;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Add active FRIEND 14/15 and OFFICIAL_ACCOUNT confirmation 80/81 to connection state.
|
||||||
|
-- Legacy 60/61 remain accepted at protocol level; this migration does not reinterpret existing rows.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
resolved_login TEXT;
|
||||||
|
positive_rel_type INTEGER;
|
||||||
|
existed_before BOOLEAN;
|
||||||
|
target_channel_type INTEGER;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.msg_type <> 3 THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
|
||||||
|
|
||||||
|
IF NEW.msg_sub_type IN (10, 14, 20, 30, 40, 50, 52, 54, 60, 70, 74, 80) THEN
|
||||||
|
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM connections_state
|
||||||
|
WHERE login = NEW.login
|
||||||
|
AND rel_type = NEW.msg_sub_type
|
||||||
|
AND to_login = resolved_login
|
||||||
|
AND to_bch_name = NEW.to_bch_name
|
||||||
|
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||||
|
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||||
|
)
|
||||||
|
INTO existed_before;
|
||||||
|
|
||||||
|
IF NOT existed_before THEN
|
||||||
|
IF NEW.msg_sub_type = 10 THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
close_friends_count = user_stats_state.close_friends_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSIF NEW.msg_sub_type = 30 THEN
|
||||||
|
IF NEW.to_block_number IS NULL THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSIF NEW.to_block_number = 0 THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_users_count = user_stats_state.following_users_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSE
|
||||||
|
SELECT cn.channel_type_code
|
||||||
|
INTO target_channel_type
|
||||||
|
FROM channel_names_state cn
|
||||||
|
WHERE cn.owner_bch_name = NEW.to_bch_name
|
||||||
|
AND cn.channel_root_block_number = NEW.to_block_number
|
||||||
|
AND cn.channel_root_block_hash = NEW.to_block_hash
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF target_channel_type = 1 THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
|
||||||
|
INSERT INTO channel_stats_state (
|
||||||
|
owner_bch_name,
|
||||||
|
channel_root_block_number,
|
||||||
|
channel_root_block_hash,
|
||||||
|
owner_login,
|
||||||
|
channel_type_code,
|
||||||
|
subscribers_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.to_bch_name,
|
||||||
|
NEW.to_block_number,
|
||||||
|
NEW.to_block_hash,
|
||||||
|
resolved_login,
|
||||||
|
target_channel_type,
|
||||||
|
1,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||||
|
owner_login = EXCLUDED.owner_login,
|
||||||
|
channel_type_code = EXCLUDED.channel_type_code,
|
||||||
|
subscribers_count = channel_stats_state.subscribers_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSIF target_channel_type IS NULL THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_channels_count = user_stats_state.following_channels_count + 1,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM connections_state
|
||||||
|
WHERE login = NEW.login
|
||||||
|
AND rel_type = NEW.msg_sub_type
|
||||||
|
AND to_login = resolved_login
|
||||||
|
AND to_bch_name = NEW.to_bch_name
|
||||||
|
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||||
|
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
|
||||||
|
|
||||||
|
INSERT INTO connections_state (
|
||||||
|
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
NEW.msg_sub_type,
|
||||||
|
resolved_login,
|
||||||
|
NEW.to_bch_name,
|
||||||
|
COALESCE(NEW.to_block_number, 0),
|
||||||
|
COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
positive_rel_type := CASE NEW.msg_sub_type
|
||||||
|
WHEN 11 THEN 10
|
||||||
|
WHEN 15 THEN 14
|
||||||
|
WHEN 21 THEN 20
|
||||||
|
WHEN 31 THEN 30
|
||||||
|
WHEN 41 THEN 40
|
||||||
|
WHEN 51 THEN 50
|
||||||
|
WHEN 53 THEN 52
|
||||||
|
WHEN 55 THEN 54
|
||||||
|
WHEN 61 THEN 60
|
||||||
|
WHEN 71 THEN 70
|
||||||
|
WHEN 75 THEN 74
|
||||||
|
WHEN 81 THEN 80
|
||||||
|
ELSE NULL
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF positive_rel_type IS NULL OR resolved_login IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM connections_state
|
||||||
|
WHERE login = NEW.login
|
||||||
|
AND rel_type = positive_rel_type
|
||||||
|
AND to_login = resolved_login
|
||||||
|
AND to_bch_name = NEW.to_bch_name
|
||||||
|
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||||
|
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
|
||||||
|
)
|
||||||
|
INTO existed_before;
|
||||||
|
|
||||||
|
IF NOT existed_before THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF positive_rel_type = 10 THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSIF positive_rel_type = 30 THEN
|
||||||
|
IF NEW.to_block_number IS NULL THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSIF NEW.to_block_number = 0 THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSE
|
||||||
|
SELECT cn.channel_type_code
|
||||||
|
INTO target_channel_type
|
||||||
|
FROM channel_names_state cn
|
||||||
|
WHERE cn.owner_bch_name = NEW.to_bch_name
|
||||||
|
AND cn.channel_root_block_number = NEW.to_block_number
|
||||||
|
AND cn.channel_root_block_hash = NEW.to_block_hash
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF target_channel_type = 1 THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
|
||||||
|
INSERT INTO channel_stats_state (
|
||||||
|
owner_bch_name,
|
||||||
|
channel_root_block_number,
|
||||||
|
channel_root_block_hash,
|
||||||
|
owner_login,
|
||||||
|
channel_type_code,
|
||||||
|
subscribers_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.to_bch_name,
|
||||||
|
NEW.to_block_number,
|
||||||
|
NEW.to_block_hash,
|
||||||
|
resolved_login,
|
||||||
|
target_channel_type,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
|
||||||
|
owner_login = EXCLUDED.owner_login,
|
||||||
|
channel_type_code = EXCLUDED.channel_type_code,
|
||||||
|
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
ELSIF target_channel_type IS NULL THEN
|
||||||
|
INSERT INTO user_stats_state (
|
||||||
|
login,
|
||||||
|
owned_public_channels_count,
|
||||||
|
following_users_count,
|
||||||
|
following_channels_count,
|
||||||
|
close_friends_count,
|
||||||
|
updated_at_ms
|
||||||
|
) VALUES (
|
||||||
|
NEW.login,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM connections_state
|
||||||
|
WHERE login = NEW.login
|
||||||
|
AND rel_type = positive_rel_type
|
||||||
|
AND to_login = resolved_login
|
||||||
|
AND to_bch_name = NEW.to_bch_name
|
||||||
|
AND to_block_number = COALESCE(NEW.to_block_number, 0)
|
||||||
|
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 17, 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;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- v18: fast profile projection, effective social/voting statistics and qualified likes.
|
||||||
|
-- Source of truth remains users_params / connections_state / reactions_state / channel_names_state.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_profile_state (
|
||||||
|
login TEXT PRIMARY KEY,
|
||||||
|
first_name TEXT NOT NULL DEFAULT '',
|
||||||
|
last_name TEXT NOT NULL DEFAULT '',
|
||||||
|
ava_ar TEXT NOT NULL DEFAULT '',
|
||||||
|
account_role TEXT,
|
||||||
|
shine_status TEXT,
|
||||||
|
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_profile_state_name
|
||||||
|
ON user_profile_state (LOWER(last_name), LOWER(first_name), LOWER(login));
|
||||||
|
|
||||||
|
ALTER TABLE user_stats_state
|
||||||
|
ADD COLUMN IF NOT EXISTS friends_count INTEGER NOT NULL DEFAULT 0 CHECK (friends_count >= 0),
|
||||||
|
ADD COLUMN IF NOT EXISTS primary_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_received_count >= 0),
|
||||||
|
ADD COLUMN IF NOT EXISTS primary_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_confirmations_given_count >= 0),
|
||||||
|
ADD COLUMN IF NOT EXISTS shine_confirmations_received_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_received_count >= 0),
|
||||||
|
ADD COLUMN IF NOT EXISTS shine_confirmations_given_count INTEGER NOT NULL DEFAULT 0 CHECK (shine_confirmations_given_count >= 0);
|
||||||
|
|
||||||
|
ALTER TABLE message_stats
|
||||||
|
ADD COLUMN IF NOT EXISTS primary_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (primary_likes_count >= 0),
|
||||||
|
ADD COLUMN IF NOT EXISTS shining_likes_count INTEGER NOT NULL DEFAULT 0 CHECK (shining_likes_count >= 0);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_user_profile(p_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_first_name TEXT := '';
|
||||||
|
v_last_name TEXT := '';
|
||||||
|
v_ava_ar TEXT := '';
|
||||||
|
v_account_role TEXT := NULL;
|
||||||
|
v_shine TEXT := NULL;
|
||||||
|
v_updated BIGINT := 0;
|
||||||
|
BEGIN
|
||||||
|
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||||
|
|
||||||
|
SELECT COALESCE(MAX(value) FILTER (WHERE param = 'first_name'), ''),
|
||||||
|
COALESCE(MAX(value) FILTER (WHERE param = 'last_name'), ''),
|
||||||
|
COALESCE(MAX(value) FILTER (WHERE param = 'ava'), ''),
|
||||||
|
MAX(value) FILTER (WHERE param = 'account_role'),
|
||||||
|
MAX(value) FILTER (WHERE param = 'shine'),
|
||||||
|
COALESCE(MAX(time_ms), 0)
|
||||||
|
INTO v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated
|
||||||
|
FROM users_params
|
||||||
|
WHERE LOWER(login) = LOWER(p_login);
|
||||||
|
|
||||||
|
v_account_role := LOWER(BTRIM(COALESCE(v_account_role, '')));
|
||||||
|
IF v_account_role NOT IN ('primary', 'non_voting') THEN v_account_role := NULL; END IF;
|
||||||
|
|
||||||
|
v_shine := LOWER(BTRIM(COALESCE(v_shine, '')));
|
||||||
|
IF v_shine = 'yes' THEN v_shine := 'shining'; END IF; -- legacy compatibility
|
||||||
|
IF v_shine NOT IN ('shining', 'unknown', 'not_interested') THEN v_shine := NULL; END IF;
|
||||||
|
|
||||||
|
INSERT INTO user_profile_state(login, first_name, last_name, ava_ar, account_role, shine_status, updated_at_ms)
|
||||||
|
VALUES (p_login, v_first_name, v_last_name, v_ava_ar, v_account_role, v_shine, v_updated)
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
first_name = EXCLUDED.first_name,
|
||||||
|
last_name = EXCLUDED.last_name,
|
||||||
|
ava_ar = EXCLUDED.ava_ar,
|
||||||
|
account_role = EXCLUDED.account_role,
|
||||||
|
shine_status = EXCLUDED.shine_status,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_is_primary(p_login TEXT)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE((SELECT account_role = 'primary' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_is_shining(p_login TEXT)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE((SELECT shine_status = 'shining' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), FALSE)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_target_accepts_primary_confirmation(p_login TEXT)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE((SELECT account_role IS DISTINCT FROM 'non_voting' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_target_accepts_shine_confirmation(p_login TEXT)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE((SELECT shine_status IS DISTINCT FROM 'not_interested' FROM user_profile_state WHERE LOWER(login)=LOWER(p_login) LIMIT 1), TRUE)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||||
|
BEGIN
|
||||||
|
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||||
|
|
||||||
|
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
|
||||||
|
following_channels_count, close_friends_count, friends_count,
|
||||||
|
primary_confirmations_received_count, primary_confirmations_given_count,
|
||||||
|
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
|
||||||
|
SELECT p_login,
|
||||||
|
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
|
||||||
|
0,
|
||||||
|
(SELECT COUNT(*)::INT 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(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
|
||||||
|
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
|
||||||
|
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
|
||||||
|
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
|
||||||
|
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
|
||||||
|
v_now
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
|
||||||
|
following_users_count=0,
|
||||||
|
following_channels_count=EXCLUDED.following_channels_count,
|
||||||
|
close_friends_count=EXCLUDED.close_friends_count,
|
||||||
|
friends_count=EXCLUDED.friends_count,
|
||||||
|
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
|
||||||
|
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
|
||||||
|
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
|
||||||
|
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
|
||||||
|
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE r RECORD;
|
||||||
|
BEGIN
|
||||||
|
PERFORM shine_refresh_user_stats(p_login);
|
||||||
|
FOR r IN
|
||||||
|
SELECT DISTINCT x.login FROM (
|
||||||
|
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||||
|
UNION
|
||||||
|
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||||
|
) x
|
||||||
|
LOOP
|
||||||
|
PERFORM shine_refresh_user_stats(r.login);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_message_qualified_likes_for_actor(p_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE r RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT DISTINCT rs.to_login, rs.to_bch_name, rs.to_block_number, rs.to_block_hash
|
||||||
|
FROM reactions_state rs
|
||||||
|
WHERE LOWER(rs.from_login)=LOWER(p_login) AND rs.reaction_type=1 AND rs.last_sub_type=1
|
||||||
|
LOOP
|
||||||
|
UPDATE message_stats ms SET
|
||||||
|
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
|
||||||
|
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
|
||||||
|
AND shine_is_primary(x.from_login)),
|
||||||
|
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1
|
||||||
|
AND x.to_login=r.to_login AND x.to_bch_name=r.to_bch_name AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash
|
||||||
|
AND shine_is_primary(x.from_login) AND shine_is_shining(x.from_login))
|
||||||
|
WHERE ms.to_login=r.to_login AND ms.to_bch_name=r.to_bch_name AND ms.to_block_number=r.to_block_number AND ms.to_block_hash=r.to_block_hash;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_channel_follow_stats(p_bch_name TEXT, p_block_number INTEGER, p_block_hash BYTEA)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_owner_login TEXT;
|
||||||
|
v_channel_type INTEGER;
|
||||||
|
v_count INTEGER := 0;
|
||||||
|
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||||
|
BEGIN
|
||||||
|
IF p_bch_name IS NULL OR p_block_number IS NULL OR p_block_hash IS NULL OR p_block_number <= 0 THEN RETURN; END IF;
|
||||||
|
|
||||||
|
SELECT cn.owner_login, cn.channel_type_code
|
||||||
|
INTO v_owner_login, v_channel_type
|
||||||
|
FROM channel_names_state cn
|
||||||
|
WHERE cn.owner_bch_name=p_bch_name
|
||||||
|
AND cn.channel_root_block_number=p_block_number
|
||||||
|
AND cn.channel_root_block_hash=p_block_hash
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_channel_type IS NULL THEN RETURN; END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(DISTINCT LOWER(cs.login))::INT
|
||||||
|
INTO v_count
|
||||||
|
FROM connections_state cs
|
||||||
|
WHERE cs.rel_type=30
|
||||||
|
AND cs.to_bch_name=p_bch_name
|
||||||
|
AND cs.to_block_number=p_block_number
|
||||||
|
AND cs.to_block_hash=p_block_hash;
|
||||||
|
|
||||||
|
INSERT INTO channel_stats_state(owner_bch_name,channel_root_block_number,channel_root_block_hash,owner_login,channel_type_code,subscribers_count,updated_at_ms)
|
||||||
|
VALUES(p_bch_name,p_block_number,p_block_hash,COALESCE(v_owner_login,''),v_channel_type,v_count,v_now)
|
||||||
|
ON CONFLICT(owner_bch_name,channel_root_block_number,channel_root_block_hash) DO UPDATE SET
|
||||||
|
owner_login=EXCLUDED.owner_login,
|
||||||
|
channel_type_code=EXCLUDED.channel_type_code,
|
||||||
|
subscribers_count=EXCLUDED.subscribers_count,
|
||||||
|
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Replace incremental channel-name counter with idempotent recomputation.
|
||||||
|
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.channel_type_code = 1 THEN
|
||||||
|
PERFORM shine_refresh_user_stats(NEW.owner_login);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Rebuild connection state without incremental user counters; then recompute affected users.
|
||||||
|
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
resolved_login TEXT;
|
||||||
|
positive_rel_type INTEGER;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.msg_type <> 3 THEN RETURN NEW; END IF;
|
||||||
|
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
|
||||||
|
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN RETURN NEW; END IF;
|
||||||
|
|
||||||
|
IF NEW.msg_sub_type IN (10,14,20,30,40,50,52,54,60,70,74,80) THEN
|
||||||
|
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=NEW.msg_sub_type AND to_login=resolved_login
|
||||||
|
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
|
||||||
|
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
|
||||||
|
INSERT INTO connections_state(login,rel_type,to_login,to_bch_name,to_block_number,to_block_hash)
|
||||||
|
VALUES(NEW.login,NEW.msg_sub_type,resolved_login,NEW.to_bch_name,COALESCE(NEW.to_block_number,0),COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex')));
|
||||||
|
ELSE
|
||||||
|
positive_rel_type := CASE NEW.msg_sub_type WHEN 11 THEN 10 WHEN 15 THEN 14 WHEN 21 THEN 20 WHEN 31 THEN 30 WHEN 41 THEN 40
|
||||||
|
WHEN 51 THEN 50 WHEN 53 THEN 52 WHEN 55 THEN 54 WHEN 61 THEN 60 WHEN 71 THEN 70 WHEN 75 THEN 74 WHEN 81 THEN 80 ELSE NULL END;
|
||||||
|
IF positive_rel_type IS NULL THEN RETURN NEW; END IF;
|
||||||
|
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=positive_rel_type AND to_login=resolved_login
|
||||||
|
AND to_bch_name=NEW.to_bch_name AND to_block_number=COALESCE(NEW.to_block_number,0)
|
||||||
|
AND to_block_hash=COALESCE(NEW.to_block_hash,decode(repeat('00',32),'hex'));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM shine_refresh_user_stats(NEW.login);
|
||||||
|
PERFORM shine_refresh_user_stats(resolved_login);
|
||||||
|
IF NEW.msg_sub_type IN (30,31) AND NEW.to_block_number IS NOT NULL AND NEW.to_block_number > 0 AND NEW.to_block_hash IS NOT NULL THEN
|
||||||
|
PERFORM shine_refresh_channel_follow_stats(NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE previous_sub_type INTEGER;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.msg_type<>2 OR NEW.msg_sub_type NOT IN (1,2) THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
|
||||||
|
|
||||||
|
INSERT INTO message_stats(to_login,to_bch_name,to_block_number,to_block_hash,likes_count,primary_likes_count,shining_likes_count,replies_count,edits_count)
|
||||||
|
VALUES(NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,0,0,0,0,0)
|
||||||
|
ON CONFLICT(to_login,to_bch_name,to_block_number,to_block_hash) DO NOTHING;
|
||||||
|
|
||||||
|
SELECT last_sub_type INTO previous_sub_type FROM reactions_state WHERE from_login=NEW.login AND from_bch_name=NEW.bch_name AND reaction_type=1
|
||||||
|
AND to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash LIMIT 1;
|
||||||
|
|
||||||
|
IF NEW.msg_sub_type=1 AND previous_sub_type IS DISTINCT FROM 1 THEN
|
||||||
|
UPDATE message_stats SET likes_count=likes_count+1,
|
||||||
|
primary_likes_count=primary_likes_count + CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END,
|
||||||
|
shining_likes_count=shining_likes_count + CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END
|
||||||
|
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
|
||||||
|
ELSIF NEW.msg_sub_type=2 AND previous_sub_type=1 THEN
|
||||||
|
UPDATE message_stats SET likes_count=GREATEST(0,likes_count-1),
|
||||||
|
primary_likes_count=GREATEST(0,primary_likes_count - CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END),
|
||||||
|
shining_likes_count=GREATEST(0,shining_likes_count - CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END)
|
||||||
|
WHERE to_login=NEW.to_login AND to_bch_name=NEW.to_bch_name AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO reactions_state(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash,last_sub_type)
|
||||||
|
VALUES(NEW.login,NEW.bch_name,1,NEW.to_login,NEW.to_bch_name,NEW.to_block_number,NEW.to_block_hash,NEW.msg_sub_type)
|
||||||
|
ON CONFLICT(from_login,from_bch_name,reaction_type,to_login,to_bch_name,to_block_number,to_block_hash) DO UPDATE SET last_sub_type=EXCLUDED.last_sub_type;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Initial projection/rebuild.
|
||||||
|
DO $$ DECLARE r RECORD; BEGIN
|
||||||
|
FOR r IN SELECT DISTINCT login FROM users_params LOOP PERFORM shine_refresh_user_profile(r.login); END LOOP;
|
||||||
|
END $$;
|
||||||
|
DO $$ DECLARE r RECORD; BEGIN
|
||||||
|
FOR r IN SELECT login FROM solana_user_pda_current LOOP PERFORM shine_refresh_user_stats(r.login); END LOOP;
|
||||||
|
END $$;
|
||||||
|
DO $$ DECLARE r RECORD; BEGIN
|
||||||
|
FOR r IN SELECT owner_bch_name, channel_root_block_number, channel_root_block_hash FROM channel_names_state WHERE channel_type_code=1 LOOP
|
||||||
|
PERFORM shine_refresh_channel_follow_stats(r.owner_bch_name, r.channel_root_block_number, r.channel_root_block_hash);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
UPDATE message_stats ms SET
|
||||||
|
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||||
|
AND rs.to_login=ms.to_login AND rs.to_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)),
|
||||||
|
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
|
||||||
|
AND rs.to_login=ms.to_login AND rs.to_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));
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||||
|
VALUES(1,18,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;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- v19: skip user_stats refresh for logins that are absent from solana_user_pda_current.
|
||||||
|
-- This keeps bootstrap resilient to legacy rows that are still present in blocks.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_now BIGINT := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
|
||||||
|
BEGIN
|
||||||
|
IF p_login IS NULL OR btrim(p_login) = '' THEN RETURN; END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
WHERE login = p_login
|
||||||
|
LIMIT 1
|
||||||
|
) THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO user_stats_state(login, owned_public_channels_count, following_users_count,
|
||||||
|
following_channels_count, close_friends_count, friends_count,
|
||||||
|
primary_confirmations_received_count, primary_confirmations_given_count,
|
||||||
|
shine_confirmations_received_count, shine_confirmations_given_count, updated_at_ms)
|
||||||
|
SELECT p_login,
|
||||||
|
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
|
||||||
|
0,
|
||||||
|
(SELECT COUNT(*)::INT 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(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80
|
||||||
|
AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80
|
||||||
|
AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70
|
||||||
|
AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
|
||||||
|
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs
|
||||||
|
WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70
|
||||||
|
AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),
|
||||||
|
v_now
|
||||||
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
|
owned_public_channels_count=EXCLUDED.owned_public_channels_count,
|
||||||
|
following_users_count=0,
|
||||||
|
following_channels_count=EXCLUDED.following_channels_count,
|
||||||
|
close_friends_count=EXCLUDED.close_friends_count,
|
||||||
|
friends_count=EXCLUDED.friends_count,
|
||||||
|
primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,
|
||||||
|
primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,
|
||||||
|
shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,
|
||||||
|
shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,
|
||||||
|
updated_at_ms=EXCLUDED.updated_at_ms;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_related_user_stats(p_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE r RECORD;
|
||||||
|
BEGIN
|
||||||
|
PERFORM shine_refresh_user_stats(p_login);
|
||||||
|
FOR r IN
|
||||||
|
SELECT DISTINCT x.login FROM (
|
||||||
|
SELECT cs.login FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||||
|
UNION
|
||||||
|
SELECT cs.to_login AS login FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type IN (70,80)
|
||||||
|
) x
|
||||||
|
LOOP
|
||||||
|
PERFORM shine_refresh_user_stats(r.login);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_access_servers_current (
|
||||||
|
user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||||
|
server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||||
|
server_url TEXT NOT NULL,
|
||||||
|
server_client_key TEXT NOT NULL,
|
||||||
|
user_record_number INTEGER NOT NULL,
|
||||||
|
user_updated_at_ms BIGINT NOT NULL,
|
||||||
|
server_record_number INTEGER NOT NULL,
|
||||||
|
server_updated_at_ms BIGINT NOT NULL,
|
||||||
|
refreshed_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_login, server_login)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_access_servers_user
|
||||||
|
ON user_access_servers_current(user_login);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_access_servers_server
|
||||||
|
ON user_access_servers_current(server_login);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_user(p_user_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF p_user_login IS NULL OR btrim(p_user_login) = '' THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM user_access_servers_current
|
||||||
|
WHERE LOWER(user_login) = LOWER(p_user_login);
|
||||||
|
|
||||||
|
INSERT INTO user_access_servers_current (
|
||||||
|
user_login,
|
||||||
|
server_login,
|
||||||
|
server_url,
|
||||||
|
server_client_key,
|
||||||
|
user_record_number,
|
||||||
|
user_updated_at_ms,
|
||||||
|
server_record_number,
|
||||||
|
server_updated_at_ms,
|
||||||
|
refreshed_at_ms
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
u.login,
|
||||||
|
s.login,
|
||||||
|
s.server_address,
|
||||||
|
s.client_key,
|
||||||
|
u.record_number,
|
||||||
|
u.updated_at_ms,
|
||||||
|
s.record_number,
|
||||||
|
s.updated_at_ms,
|
||||||
|
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
FROM solana_user_pda_current u
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT login_value
|
||||||
|
FROM jsonb_array_elements_text(
|
||||||
|
CASE
|
||||||
|
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||||
|
ELSE u.access_servers_json::jsonb
|
||||||
|
END
|
||||||
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
|
WHERE ord <= 2
|
||||||
|
) AS access_server
|
||||||
|
JOIN solana_user_pda_current s
|
||||||
|
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||||
|
AND s.is_server = TRUE
|
||||||
|
AND btrim(COALESCE(s.server_address, '')) <> ''
|
||||||
|
WHERE LOWER(u.login) = LOWER(p_user_login)
|
||||||
|
ON CONFLICT (user_login, server_login) DO UPDATE SET
|
||||||
|
server_url = EXCLUDED.server_url,
|
||||||
|
server_client_key = EXCLUDED.server_client_key,
|
||||||
|
user_record_number = EXCLUDED.user_record_number,
|
||||||
|
user_updated_at_ms = EXCLUDED.user_updated_at_ms,
|
||||||
|
server_record_number = EXCLUDED.server_record_number,
|
||||||
|
server_updated_at_ms = EXCLUDED.server_updated_at_ms,
|
||||||
|
refreshed_at_ms = EXCLUDED.refreshed_at_ms;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_server(p_server_login TEXT)
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
affected_user RECORD;
|
||||||
|
BEGIN
|
||||||
|
IF p_server_login IS NULL OR btrim(p_server_login) = '' THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM user_access_servers_current
|
||||||
|
WHERE LOWER(server_login) = LOWER(p_server_login);
|
||||||
|
|
||||||
|
FOR affected_user IN
|
||||||
|
SELECT u.login
|
||||||
|
FROM solana_user_pda_current u
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT login_value
|
||||||
|
FROM jsonb_array_elements_text(
|
||||||
|
CASE
|
||||||
|
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
|
||||||
|
ELSE u.access_servers_json::jsonb
|
||||||
|
END
|
||||||
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
|
WHERE ord <= 2
|
||||||
|
) AS access_server
|
||||||
|
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||||
|
LOOP
|
||||||
|
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_all()
|
||||||
|
RETURNS VOID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
affected_user RECORD;
|
||||||
|
BEGIN
|
||||||
|
TRUNCATE TABLE user_access_servers_current;
|
||||||
|
|
||||||
|
FOR affected_user IN
|
||||||
|
SELECT login
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
LOOP
|
||||||
|
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_row()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
PERFORM shine_refresh_user_access_servers_for_user(OLD.login);
|
||||||
|
PERFORM shine_refresh_user_access_servers_for_server(OLD.login);
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF TG_OP = 'UPDATE' AND LOWER(OLD.login) <> LOWER(NEW.login) THEN
|
||||||
|
PERFORM shine_refresh_user_access_servers_for_user(OLD.login);
|
||||||
|
PERFORM shine_refresh_user_access_servers_for_server(OLD.login);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM shine_refresh_user_access_servers_for_user(NEW.login);
|
||||||
|
PERFORM shine_refresh_user_access_servers_for_server(NEW.login);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
TRUNCATE TABLE user_access_servers_current;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_row ON solana_user_pda_current;
|
||||||
|
CREATE TRIGGER trg_refresh_user_access_servers_row
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON solana_user_pda_current
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_row();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_truncate ON solana_user_pda_current;
|
||||||
|
CREATE TRIGGER trg_refresh_user_access_servers_truncate
|
||||||
|
AFTER TRUNCATE ON solana_user_pda_current
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
|
||||||
|
|
||||||
|
SELECT shine_refresh_user_access_servers_all();
|
||||||
|
|
||||||
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
|
VALUES (1, 2, 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;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user