SHA256
Compare commits
3
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
9be4c4d3a5 | ||
|
|
f5698771e4 | ||
|
|
151a2c1754 |
+84
-3
@@ -3,7 +3,9 @@ package server.archive;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import shine.db.dao.BlocksDAO;
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationCandidateBlocksDAO;
|
||||||
import shine.db.entities.BlockEntry;
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -14,6 +16,7 @@ public final class ArweaveBlockPublisherService {
|
|||||||
|
|
||||||
private final ArweaveBlocksConfig cfg;
|
private final ArweaveBlocksConfig cfg;
|
||||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||||
|
private final KeyRotationCandidateBlocksDAO candidateBlocksDAO = KeyRotationCandidateBlocksDAO.getInstance();
|
||||||
private final ArweaveL1Uploader arweaveUploader;
|
private final ArweaveL1Uploader arweaveUploader;
|
||||||
private final TurboDataItemUploader turboUploader;
|
private final TurboDataItemUploader turboUploader;
|
||||||
|
|
||||||
@@ -25,13 +28,57 @@ public final class ArweaveBlockPublisherService {
|
|||||||
|
|
||||||
public int runCycle() throws Exception {
|
public int runCycle() throws Exception {
|
||||||
if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0;
|
if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0;
|
||||||
List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems());
|
|
||||||
if (candidates.isEmpty()) return 0;
|
int maxItems = cfg.publishMaxItems();
|
||||||
return switch (cfg.publishMode()) {
|
int attempted = 0;
|
||||||
|
int published = 0;
|
||||||
|
|
||||||
|
// Ротация приоритетна: пользователь уже заблокирован для обычной записи и ждёт завершения копирования.
|
||||||
|
List<KeyRotationCandidateBlockEntry> rotationCandidates = candidateBlocksDAO.listPendingArweave(maxItems);
|
||||||
|
if (!rotationCandidates.isEmpty()) {
|
||||||
|
attempted += rotationCandidates.size();
|
||||||
|
published += switch (cfg.publishMode()) {
|
||||||
|
case TURBO -> publishTurboRotation(rotationCandidates);
|
||||||
|
case ARWEAVE -> publishDirectArweaveRotation(rotationCandidates);
|
||||||
|
case NONE -> 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
int remaining = Math.max(0, maxItems - attempted);
|
||||||
|
if (remaining == 0) return published;
|
||||||
|
|
||||||
|
List<BlockEntry> candidates = blocksDAO.listPendingArweave(remaining);
|
||||||
|
if (candidates.isEmpty()) return published;
|
||||||
|
published += switch (cfg.publishMode()) {
|
||||||
case TURBO -> publishTurbo(candidates);
|
case TURBO -> publishTurbo(candidates);
|
||||||
case ARWEAVE -> publishDirectArweave(candidates);
|
case ARWEAVE -> publishDirectArweave(candidates);
|
||||||
case NONE -> 0;
|
case NONE -> 0;
|
||||||
};
|
};
|
||||||
|
return published;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int publishTurboRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
|
||||||
|
int published = 0;
|
||||||
|
Exception firstFailure = null;
|
||||||
|
for (KeyRotationCandidateBlockEntry e : candidates) {
|
||||||
|
byte[] raw = e.getBlockBytes();
|
||||||
|
byte[] id = e.getDataItemId();
|
||||||
|
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
|
||||||
|
try {
|
||||||
|
TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id);
|
||||||
|
candidateBlocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis());
|
||||||
|
published++;
|
||||||
|
log.debug("Turbo published key-rotation DataItem {} chain={} block={}",
|
||||||
|
result.dataItemId(), e.getCandidateBlockchainName(), e.getBlockNumber());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
if (firstFailure == null) firstFailure = ex;
|
||||||
|
log.warn("Turbo key-rotation publish failed: chain={} block={} bytes={} error={}",
|
||||||
|
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (published > 0) log.info("Published {} SHiNE key-rotation DataItems through Turbo", published);
|
||||||
|
if (published == 0 && firstFailure != null) throw firstFailure;
|
||||||
|
return published;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int publishTurbo(List<BlockEntry> candidates) throws Exception {
|
private int publishTurbo(List<BlockEntry> candidates) throws Exception {
|
||||||
@@ -57,6 +104,40 @@ public final class ArweaveBlockPublisherService {
|
|||||||
return published;
|
return published;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int publishDirectArweaveRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
|
||||||
|
List<byte[]> items = new ArrayList<>();
|
||||||
|
List<byte[]> ids = new ArrayList<>();
|
||||||
|
long estimated = 32;
|
||||||
|
for (KeyRotationCandidateBlockEntry e : candidates) {
|
||||||
|
byte[] raw = e.getBlockBytes();
|
||||||
|
byte[] id = e.getDataItemId();
|
||||||
|
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
|
||||||
|
long next = estimated + 64L + raw.length;
|
||||||
|
if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break;
|
||||||
|
if (next > cfg.publishMaxBundleBytes()) {
|
||||||
|
log.error("One key-rotation DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}",
|
||||||
|
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.add(raw);
|
||||||
|
ids.add(id);
|
||||||
|
estimated = next;
|
||||||
|
}
|
||||||
|
if (items.isEmpty()) return 0;
|
||||||
|
|
||||||
|
byte[] bundle = Ans104Bundle.write(items);
|
||||||
|
List<ArweaveL1Uploader.Tag> rootTags = List.of(
|
||||||
|
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
|
||||||
|
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
|
||||||
|
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0")
|
||||||
|
);
|
||||||
|
ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags);
|
||||||
|
candidateBlocksDAO.markArweavePublished(ids, System.currentTimeMillis());
|
||||||
|
log.info("Published {} SHiNE key-rotation DataItems in direct Arweave root tx {} (bundle={} bytes)",
|
||||||
|
items.size(), result.txId(), bundle.length);
|
||||||
|
return items.size();
|
||||||
|
}
|
||||||
|
|
||||||
private int publishDirectArweave(List<BlockEntry> candidates) throws Exception {
|
private int publishDirectArweave(List<BlockEntry> candidates) throws Exception {
|
||||||
List<byte[]> items = new ArrayList<>();
|
List<byte[]> items = new ArrayList<>();
|
||||||
List<byte[]> ids = new ArrayList<>();
|
List<byte[]> ids = new ArrayList<>();
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ public final class BodyRecordParser {
|
|||||||
&& (v == (CreateChannelBody.VER & 0xFFFF))) {
|
&& (v == (CreateChannelBody.VER & 0xFFFF))) {
|
||||||
return new CreateChannelBody(subType, version, bodyBytes).check();
|
return new CreateChannelBody(subType, version, bodyBytes).check();
|
||||||
}
|
}
|
||||||
|
if (st == (ForkBody.SUBTYPE & 0xFFFF)
|
||||||
|
&& (v == (ForkBody.VER & 0xFFFF))) {
|
||||||
|
return new ForkBody(subType, version, bodyBytes).check();
|
||||||
|
}
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
|
String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ public final class MsgSubType {
|
|||||||
/** HeaderBody: subType всегда 0 (compat). */
|
/** HeaderBody: subType всегда 0 (compat). */
|
||||||
public static final short HEADER_COMPAT = 0;
|
public static final short HEADER_COMPAT = 0;
|
||||||
public static final short TECH_CREATE_CHANNEL = 1;
|
public static final short TECH_CREATE_CHANNEL = 1;
|
||||||
|
/** Новый fork/ротация ключей: ссылка на родительскую цепочку и точку отката. */
|
||||||
|
public static final short TECH_FORK = 2;
|
||||||
|
|
||||||
/* ===================== TEXT (msg_type=1) ===================== */
|
/* ===================== TEXT (msg_type=1) ===================== */
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ public final class MsgSubType {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* REPLY — ответ на сообщение.
|
* REPLY — ответ на сообщение.
|
||||||
* НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32).
|
* НЕ в линии. Имеет target (toLogin + blockNumber + hash32).
|
||||||
* Может указывать на чужой блокчейн/чужую линию/чужой канал.
|
* Может указывать на чужой блокчейн/чужую линию/чужой канал.
|
||||||
*/
|
*/
|
||||||
public static final short TEXT_REPLY = 20;
|
public static final short TEXT_REPLY = 20;
|
||||||
@@ -69,7 +71,7 @@ public final class MsgSubType {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
|
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
|
||||||
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
* Имеет hasLine + target (toLogin + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
||||||
*/
|
*/
|
||||||
public static final short TEXT_REPOST = 50;
|
public static final short TEXT_REPOST = 50;
|
||||||
|
|
||||||
|
|||||||
+18
-17
@@ -1,31 +1,32 @@
|
|||||||
package blockchain.body;
|
package blockchain.body;
|
||||||
|
|
||||||
import utils.blockchain.BlockchainNameUtil;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля).
|
* BodyHasTarget — дополнительный интерфейс для body, которые ссылаются на логическую цель.
|
||||||
*
|
*
|
||||||
* Новое правило:
|
* Актуальное правило target:
|
||||||
* - toLogin НЕ храним в байтах блока.
|
* - в подписываемых байтах хранится login цели;
|
||||||
* - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN".
|
* - номер fork/blockchainName в target не хранится;
|
||||||
|
* - идентичность цели задаётся как login + blockNumber + blockHash.
|
||||||
*
|
*
|
||||||
* Все методы могут возвращать null.
|
* Это позволяет одной и той же логической записи сохранять ссылки после fork,
|
||||||
|
* если её номер и SHA-256 hash остались прежними.
|
||||||
*/
|
*/
|
||||||
public interface BodyHasTarget {
|
public interface BodyHasTarget {
|
||||||
|
|
||||||
/** login цели (nullable). Вычисляется из toBchName(). */
|
/** login цели. Актуальные runtime-body обязаны переопределять этот метод. */
|
||||||
default String toLogin() {
|
default String toLogin() { return null; }
|
||||||
String bch = toBchName();
|
|
||||||
if (bch == null) return null;
|
|
||||||
return BlockchainNameUtil.loginFromBlockchainName(bch);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** blockchainName цели (nullable). */
|
/**
|
||||||
String toBchName();
|
* Legacy source-level accessor. В новом подписанном target blockchainName не хранится.
|
||||||
|
* Оставлен временно только чтобы старые вспомогательные классы компилировались;
|
||||||
|
* runtime-логика не должна использовать его для новых блоков.
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
|
default String toBchName() { return null; }
|
||||||
|
|
||||||
/** globalNumber цели (nullable). */
|
/** globalNumber цели (nullable только если конкретный subtype не содержит target). */
|
||||||
Integer toBlockGlobalNumber();
|
Integer toBlockGlobalNumber();
|
||||||
|
|
||||||
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
|
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
|
||||||
byte[] toBlockHashBytes();
|
byte[] toBlockHashBytes();
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-158
@@ -1,7 +1,6 @@
|
|||||||
package blockchain.body;
|
package blockchain.body;
|
||||||
|
|
||||||
import blockchain.MsgSubType;
|
import blockchain.MsgSubType;
|
||||||
import utils.blockchain.BlockchainNameUtil;
|
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
@@ -12,96 +11,38 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* ConnectionBody — type=3, ver=1 (в заголовке блока).
|
* ConnectionBody — type=3, ver=1 (в заголовке блока).
|
||||||
*
|
*
|
||||||
* subType (в заголовке блока) как MsgSubType:
|
* bodyBytes (BigEndian):
|
||||||
* FRIEND=10, UNFRIEND=11
|
|
||||||
* CONTACT=20, UNCONTACT=21
|
|
||||||
* FOLLOW=30, UNFOLLOW=31
|
|
||||||
* SPOUSE=40, UNSPOUSE=41
|
|
||||||
* PARENT=50, UNPARENT=51
|
|
||||||
* CHILD=52, UNCHILD=53
|
|
||||||
* SIBLING=54, UNSIBLING=55
|
|
||||||
* FRIEND=14, UNFRIEND=15
|
|
||||||
* KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI)
|
|
||||||
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
|
|
||||||
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
|
|
||||||
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
|
|
||||||
*
|
|
||||||
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
|
||||||
* [4] lineCode
|
* [4] lineCode
|
||||||
* [4] prevLineNumber
|
* [4] prevLineNumber
|
||||||
* [32] prevLineHash32
|
* [32] prevLineHash32
|
||||||
* [4] thisLineNumber
|
* [4] thisLineNumber
|
||||||
*
|
* [1] toLoginLen (uint8)
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [N] toLogin UTF-8
|
||||||
* [N] toBlockchainName UTF-8
|
* [4] toBlockGlobalNumber (int32)
|
||||||
* [4] toBlockGlobalNumber (int32)
|
|
||||||
* [32] toBlockHash32 (raw 32 bytes)
|
* [32] toBlockHash32 (raw 32 bytes)
|
||||||
*
|
*
|
||||||
* toLogin вычисляется автоматически из toBlockchainName:
|
* Номер fork/blockchainName в target не хранится.
|
||||||
* toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* =========================================================================
|
|
||||||
* ПРАВИЛО TARGET/ROOT ДЛЯ КАНАЛОВ И СВЯЗЕЙ (важно для подписок/друзей/контактов)
|
|
||||||
* =========================================================================
|
|
||||||
*
|
|
||||||
* Термины:
|
|
||||||
* - ROOT линии/канала = блок, который "начинает" линию:
|
|
||||||
* * для канала "0" root = HEADER (blockNumber=0)
|
|
||||||
* * для канала "X" root = CREATE_CHANNEL (blockNumber этого блока)
|
|
||||||
*
|
|
||||||
* 1) СВЯЗИ МЕЖДУ ПОЛЬЗОВАТЕЛЯМИ (CONNECTION_*):
|
|
||||||
* FRIEND / CONTACT -> цель ВСЕГДА HEADER пользователя:
|
|
||||||
* toBlockNumber = 0
|
|
||||||
* toBlockHash32 = hash32(HEADER цели)
|
|
||||||
*
|
|
||||||
* 2) ПОДПИСКИ НА КОНТЕНТ (FOLLOW/SUBSCRIBE):
|
|
||||||
* FOLLOW пользователя (в целом) -> цель = ROOT дефолтного канала "0" (то есть HEADER):
|
|
||||||
* toBlockNumber = 0
|
|
||||||
* toBlockHash32 = hash32(HEADER цели)
|
|
||||||
*
|
|
||||||
* FOLLOW/подписка на конкретный канал пользователя ->
|
|
||||||
* цель = ROOT этого канала:
|
|
||||||
* - канал "0": toBlockNumber=0, toBlockHash32=hash32(HEADER)
|
|
||||||
* - канал "X": toBlockNumber=blockNumber(CREATE_CHANNEL),
|
|
||||||
* toBlockHash32=hash32(CREATE_CHANNEL)
|
|
||||||
*
|
|
||||||
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
|
|
||||||
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
|
|
||||||
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
|
|
||||||
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
|
|
||||||
*
|
|
||||||
* Зачем так:
|
|
||||||
* - связи и подписки всегда стабильны и не ломаются при новых постах,
|
|
||||||
* - один понятный инвариант: "подписка всегда указывает на root линии".
|
|
||||||
* =========================================================================
|
|
||||||
*/
|
|
||||||
|
|
||||||
public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {
|
public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {
|
||||||
|
|
||||||
public static final short TYPE = 3;
|
public static final short TYPE = 3;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
|
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
public final short subType; // из header
|
public final short subType;
|
||||||
public final short version; // из header
|
public final short version;
|
||||||
|
|
||||||
// line
|
|
||||||
public final int lineCode;
|
public final int lineCode;
|
||||||
public final int prevLineNumber;
|
public final int prevLineNumber;
|
||||||
public final byte[] prevLineHash32;
|
public final byte[] prevLineHash32;
|
||||||
public final int thisLineNumber;
|
public final int thisLineNumber;
|
||||||
|
|
||||||
// payload
|
public final String toLogin;
|
||||||
public final String toBlockchainName;
|
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
|
|
||||||
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
|
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
|
||||||
@@ -111,34 +52,25 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
if (!isValidSubType(this.subType)) {
|
if (!isValidSubType(this.subType)) {
|
||||||
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
|
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
|
||||||
}
|
}
|
||||||
|
if (bodyBytes.length < 4 + 4 + 32 + 4 + 1 + 1 + 4 + 32) {
|
||||||
// минимум:
|
|
||||||
// lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32]
|
|
||||||
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) {
|
|
||||||
throw new IllegalArgumentException("ConnectionBody too short");
|
throw new IllegalArgumentException("ConnectionBody too short");
|
||||||
}
|
}
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
this.lineCode = bb.getInt();
|
this.lineCode = bb.getInt();
|
||||||
|
|
||||||
this.prevLineNumber = bb.getInt();
|
this.prevLineNumber = bb.getInt();
|
||||||
|
|
||||||
this.prevLineHash32 = new byte[32];
|
this.prevLineHash32 = new byte[32];
|
||||||
bb.get(this.prevLineHash32);
|
bb.get(this.prevLineHash32);
|
||||||
|
|
||||||
this.thisLineNumber = bb.getInt();
|
this.thisLineNumber = bb.getInt();
|
||||||
|
|
||||||
int bchLen = Byte.toUnsignedInt(bb.get());
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||||
if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
|
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
|
||||||
|
|
||||||
byte[] bchBytes = new byte[bchLen];
|
|
||||||
bb.get(bchBytes);
|
|
||||||
this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
|
byte[] loginBytes = new byte[loginLen];
|
||||||
|
bb.get(loginBytes);
|
||||||
|
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
|
|
||||||
this.toBlockHash32 = new byte[32];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(this.toBlockHash32);
|
bb.get(this.toBlockHash32);
|
||||||
|
|
||||||
@@ -150,39 +82,68 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
byte[] prevLineHash32,
|
byte[] prevLineHash32,
|
||||||
int thisLineNumber,
|
int thisLineNumber,
|
||||||
short subType,
|
short subType,
|
||||||
String toBlockchainName,
|
String toLogin,
|
||||||
int toBlockGlobalNumber,
|
int toBlockGlobalNumber,
|
||||||
byte[] toBlockHash32) {
|
byte[] toBlockHash32) {
|
||||||
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
||||||
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
|
||||||
// Железное правило формата: bchName -> login + "-NNN"
|
|
||||||
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) {
|
|
||||||
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.lineCode = lineCode;
|
this.lineCode = lineCode;
|
||||||
|
|
||||||
this.prevLineNumber = prevLineNumber;
|
this.prevLineNumber = prevLineNumber;
|
||||||
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
this.prevLineHash32 = prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32);
|
||||||
this.thisLineNumber = thisLineNumber;
|
this.thisLineNumber = thisLineNumber;
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
|
this.toLogin = toLogin;
|
||||||
this.toBlockchainName = toBlockchainName;
|
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ConnectionBody check() {
|
||||||
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
|
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
||||||
|
|
||||||
|
if (prevLineNumber == -1) {
|
||||||
|
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
|
||||||
|
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
|
||||||
|
} else if (prevLineHash32 == null || prevLineHash32.length != 32) {
|
||||||
|
throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] toBytes() {
|
||||||
|
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (loginBytes.length == 0 || loginBytes.length > 255)
|
||||||
|
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
|
||||||
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
|
throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
|
int cap = 4 + 4 + 32 + 4 + 1 + loginBytes.length + 4 + 32;
|
||||||
|
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.putInt(lineCode);
|
||||||
|
bb.putInt(prevLineNumber);
|
||||||
|
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||||
|
bb.putInt(thisLineNumber);
|
||||||
|
bb.put((byte) loginBytes.length);
|
||||||
|
bb.put(loginBytes);
|
||||||
|
bb.putInt(toBlockGlobalNumber);
|
||||||
|
bb.put(toBlockHash32);
|
||||||
|
return bb.array();
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean isValidSubType(short st) {
|
private static boolean isValidSubType(short st) {
|
||||||
int v = st & 0xFFFF;
|
int v = st & 0xFFFF;
|
||||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||||
@@ -211,76 +172,18 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public ConnectionBody check() {
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
|
||||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
|
||||||
|
|
||||||
// line rule (как было)
|
|
||||||
if (prevLineNumber == -1) {
|
|
||||||
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
|
|
||||||
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
|
|
||||||
} else {
|
|
||||||
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
|
||||||
throw new IllegalArgumentException("toBlockchainName is blank");
|
|
||||||
|
|
||||||
// гарантируем вычислимый toLogin (иначе target “битый” по стандарту)
|
|
||||||
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null)
|
|
||||||
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
|
|
||||||
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public byte[] toBytes() {
|
|
||||||
byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
|
||||||
if (bchBytes.length == 0 || bchBytes.length > 255)
|
|
||||||
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
|
||||||
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
|
||||||
throw new IllegalArgumentException("toBlockHash32 != 32");
|
|
||||||
|
|
||||||
int cap = 4 + (4 + 32 + 4)
|
|
||||||
+ 1 + bchBytes.length
|
|
||||||
+ 4 + 32;
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
|
|
||||||
bb.putInt(lineCode);
|
|
||||||
|
|
||||||
bb.putInt(prevLineNumber);
|
|
||||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
|
||||||
bb.putInt(thisLineNumber);
|
|
||||||
|
|
||||||
bb.put((byte) bchBytes.length);
|
|
||||||
bb.put(bchBytes);
|
|
||||||
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
|
||||||
bb.put(toBlockHash32);
|
|
||||||
|
|
||||||
return bb.array();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isAllZero32(byte[] b) {
|
private static boolean isAllZero32(byte[] b) {
|
||||||
if (b == null || b.length != 32) return true;
|
if (b == null || b.length != 32) return true;
|
||||||
for (int i = 0; i < 32; i++) if (b[i] != 0) return false;
|
for (byte value : b) if (value != 0) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== BodyHasLine ====================== */
|
|
||||||
@Override public int lineCode() { return lineCode; }
|
@Override public int lineCode() { return lineCode; }
|
||||||
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||||
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
|
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
|
||||||
@Override public int lineSeq() { return thisLineNumber; }
|
@Override public int lineSeq() { return thisLineNumber; }
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ===================== */
|
@Override public String toLogin() { return toLogin; }
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package blockchain.body;
|
||||||
|
|
||||||
|
import blockchain.MsgSubType;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TECH_FORK body (type=0, subType=2, version=1).
|
||||||
|
*
|
||||||
|
* Записывается первым новым блоком после точной перепубликации выбранного
|
||||||
|
* префикса старой цепочки новым blockchain key.
|
||||||
|
*
|
||||||
|
* body bytes (BigEndian):
|
||||||
|
* [32] parentBlockchainKey
|
||||||
|
* [4] forkPointBlockNumber
|
||||||
|
* [32] forkPointBlockHash32
|
||||||
|
* [8] forkPointTimestampMs
|
||||||
|
* [4] parentTipBlockNumber
|
||||||
|
* [32] parentTipBlockHash32
|
||||||
|
* [8] parentTipTimestampMs
|
||||||
|
* [4] discardedBlocksCount
|
||||||
|
* [1] reasonCode
|
||||||
|
* [2] commentUtf8Length
|
||||||
|
* [N] comment UTF-8 (0..1024 bytes)
|
||||||
|
*/
|
||||||
|
public final class ForkBody implements BodyRecord {
|
||||||
|
|
||||||
|
public static final short TYPE = 0;
|
||||||
|
public static final short VER = 1;
|
||||||
|
public static final short SUBTYPE = MsgSubType.TECH_FORK;
|
||||||
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
|
public static final int REASON_ROUTINE_ROTATION = 1;
|
||||||
|
public static final int REASON_POSSIBLE_COMPROMISE = 2;
|
||||||
|
public static final int REASON_CONFIRMED_COMPROMISE_ROLLBACK = 3;
|
||||||
|
public static final int REASON_RECOVERY = 4;
|
||||||
|
|
||||||
|
public static final int MAX_COMMENT_UTF8_LEN = 1024;
|
||||||
|
private static final int FIXED_LEN = 32 + 4 + 32 + 8 + 4 + 32 + 8 + 4 + 1 + 2;
|
||||||
|
|
||||||
|
public final short subType;
|
||||||
|
public final short version;
|
||||||
|
public final byte[] parentBlockchainKey32;
|
||||||
|
public final int forkPointBlockNumber;
|
||||||
|
public final byte[] forkPointBlockHash32;
|
||||||
|
public final long forkPointTimestampMs;
|
||||||
|
public final int parentTipBlockNumber;
|
||||||
|
public final byte[] parentTipBlockHash32;
|
||||||
|
public final long parentTipTimestampMs;
|
||||||
|
public final int discardedBlocksCount;
|
||||||
|
public final int reasonCode;
|
||||||
|
public final String comment;
|
||||||
|
|
||||||
|
public ForkBody(short subType, short version, byte[] bodyBytes) {
|
||||||
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
this.subType = subType;
|
||||||
|
this.version = version;
|
||||||
|
|
||||||
|
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)");
|
||||||
|
}
|
||||||
|
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("ForkBody version must be 1");
|
||||||
|
}
|
||||||
|
if (bodyBytes.length < FIXED_LEN) {
|
||||||
|
throw new IllegalArgumentException("ForkBody too short");
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
this.parentBlockchainKey32 = new byte[32];
|
||||||
|
bb.get(this.parentBlockchainKey32);
|
||||||
|
this.forkPointBlockNumber = bb.getInt();
|
||||||
|
this.forkPointBlockHash32 = new byte[32];
|
||||||
|
bb.get(this.forkPointBlockHash32);
|
||||||
|
this.forkPointTimestampMs = bb.getLong();
|
||||||
|
this.parentTipBlockNumber = bb.getInt();
|
||||||
|
this.parentTipBlockHash32 = new byte[32];
|
||||||
|
bb.get(this.parentTipBlockHash32);
|
||||||
|
this.parentTipTimestampMs = bb.getLong();
|
||||||
|
this.discardedBlocksCount = bb.getInt();
|
||||||
|
this.reasonCode = Byte.toUnsignedInt(bb.get());
|
||||||
|
int commentLen = Short.toUnsignedInt(bb.getShort());
|
||||||
|
if (commentLen > MAX_COMMENT_UTF8_LEN) {
|
||||||
|
throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024");
|
||||||
|
}
|
||||||
|
if (bb.remaining() != commentLen) {
|
||||||
|
throw new IllegalArgumentException("ForkBody tail mismatch: remaining=" + bb.remaining() + " commentLen=" + commentLen);
|
||||||
|
}
|
||||||
|
byte[] commentBytes = new byte[commentLen];
|
||||||
|
bb.get(commentBytes);
|
||||||
|
this.comment = new String(commentBytes, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ForkBody(byte[] parentBlockchainKey32,
|
||||||
|
int forkPointBlockNumber,
|
||||||
|
byte[] forkPointBlockHash32,
|
||||||
|
long forkPointTimestampMs,
|
||||||
|
int parentTipBlockNumber,
|
||||||
|
byte[] parentTipBlockHash32,
|
||||||
|
long parentTipTimestampMs,
|
||||||
|
int discardedBlocksCount,
|
||||||
|
int reasonCode,
|
||||||
|
String comment) {
|
||||||
|
this.subType = SUBTYPE;
|
||||||
|
this.version = VER;
|
||||||
|
this.parentBlockchainKey32 = copy32(parentBlockchainKey32, "parentBlockchainKey32");
|
||||||
|
this.forkPointBlockNumber = forkPointBlockNumber;
|
||||||
|
this.forkPointBlockHash32 = copy32(forkPointBlockHash32, "forkPointBlockHash32");
|
||||||
|
this.forkPointTimestampMs = forkPointTimestampMs;
|
||||||
|
this.parentTipBlockNumber = parentTipBlockNumber;
|
||||||
|
this.parentTipBlockHash32 = copy32(parentTipBlockHash32, "parentTipBlockHash32");
|
||||||
|
this.parentTipTimestampMs = parentTipTimestampMs;
|
||||||
|
this.discardedBlocksCount = discardedBlocksCount;
|
||||||
|
this.reasonCode = reasonCode;
|
||||||
|
this.comment = normalizeComment(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ForkBody check() {
|
||||||
|
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)");
|
||||||
|
}
|
||||||
|
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("ForkBody version must be 1");
|
||||||
|
}
|
||||||
|
require32(parentBlockchainKey32, "parentBlockchainKey32");
|
||||||
|
require32(forkPointBlockHash32, "forkPointBlockHash32");
|
||||||
|
require32(parentTipBlockHash32, "parentTipBlockHash32");
|
||||||
|
if (forkPointBlockNumber < 0) throw new IllegalArgumentException("forkPointBlockNumber < 0");
|
||||||
|
if (parentTipBlockNumber < forkPointBlockNumber) {
|
||||||
|
throw new IllegalArgumentException("parentTipBlockNumber < forkPointBlockNumber");
|
||||||
|
}
|
||||||
|
if (forkPointTimestampMs < 0) throw new IllegalArgumentException("forkPointTimestampMs < 0");
|
||||||
|
if (parentTipTimestampMs < 0) throw new IllegalArgumentException("parentTipTimestampMs < 0");
|
||||||
|
int expectedDiscarded = parentTipBlockNumber - forkPointBlockNumber;
|
||||||
|
if (discardedBlocksCount != expectedDiscarded) {
|
||||||
|
throw new IllegalArgumentException("discardedBlocksCount must equal parentTipBlockNumber - forkPointBlockNumber");
|
||||||
|
}
|
||||||
|
if (!isReasonSupported(reasonCode)) {
|
||||||
|
throw new IllegalArgumentException("Unsupported fork reasonCode=" + reasonCode);
|
||||||
|
}
|
||||||
|
byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (commentUtf8.length > MAX_COMMENT_UTF8_LEN) {
|
||||||
|
throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024");
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] toBytes() {
|
||||||
|
check();
|
||||||
|
byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8);
|
||||||
|
ByteBuffer bb = ByteBuffer.allocate(FIXED_LEN + commentUtf8.length).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.put(parentBlockchainKey32);
|
||||||
|
bb.putInt(forkPointBlockNumber);
|
||||||
|
bb.put(forkPointBlockHash32);
|
||||||
|
bb.putLong(forkPointTimestampMs);
|
||||||
|
bb.putInt(parentTipBlockNumber);
|
||||||
|
bb.put(parentTipBlockHash32);
|
||||||
|
bb.putLong(parentTipTimestampMs);
|
||||||
|
bb.putInt(discardedBlocksCount);
|
||||||
|
bb.put((byte) reasonCode);
|
||||||
|
bb.putShort((short) commentUtf8.length);
|
||||||
|
bb.put(commentUtf8);
|
||||||
|
return bb.array();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isReasonSupported(int code) {
|
||||||
|
return code == REASON_ROUTINE_ROTATION
|
||||||
|
|| code == REASON_POSSIBLE_COMPROMISE
|
||||||
|
|| code == REASON_CONFIRMED_COMPROMISE_ROLLBACK
|
||||||
|
|| code == REASON_RECOVERY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] copy32(byte[] value, String name) {
|
||||||
|
require32(value, name);
|
||||||
|
return Arrays.copyOf(value, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void require32(byte[] value, String name) {
|
||||||
|
if (value == null || value.length != 32) {
|
||||||
|
throw new IllegalArgumentException(name + " must be 32 bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeComment(String value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-35
@@ -15,12 +15,13 @@ import java.util.Objects;
|
|||||||
* 1 = LIKE
|
* 1 = LIKE
|
||||||
* 2 = UNLIKE
|
* 2 = UNLIKE
|
||||||
*
|
*
|
||||||
* bodyBytes (BigEndian), новый формат:
|
* bodyBytes (BigEndian):
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [1] toLoginLen (uint8)
|
||||||
* [N] toBlockchainName UTF-8
|
* [N] toLogin UTF-8
|
||||||
* [4] toBlockGlobalNumber (int32)
|
* [4] toBlockGlobalNumber (int32)
|
||||||
* [32] toBlockHash32 (raw 32 bytes)
|
* [32] toBlockHash32 (raw 32 bytes)
|
||||||
*
|
*
|
||||||
|
* Номер fork/blockchainName в target не хранится.
|
||||||
* ЛИНИИ НЕТ.
|
* ЛИНИИ НЕТ.
|
||||||
*/
|
*/
|
||||||
public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
||||||
@@ -30,10 +31,10 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
public final short subType; // из header
|
public final short subType;
|
||||||
public final short version; // из header
|
public final short version;
|
||||||
|
|
||||||
public final String toBlockchainName;
|
public final String toLogin;
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
|
|
||||||
@@ -49,40 +50,37 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
|||||||
if (!isSupportedSubType(this.subType)) {
|
if (!isSupportedSubType(this.subType)) {
|
||||||
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
|
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
|
||||||
}
|
}
|
||||||
|
|
||||||
// минимум: nameLen[1]+name[1]+global[4]+hash[32]
|
|
||||||
if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");
|
if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||||
if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
|
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
byte[] loginBytes = new byte[loginLen];
|
||||||
bb.get(nameBytes);
|
bb.get(loginBytes);
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
|
|
||||||
this.toBlockHash32 = new byte[32];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(this.toBlockHash32);
|
bb.get(this.toBlockHash32);
|
||||||
|
|
||||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
public ReactionBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
|
||||||
this.subType = MsgSubType.REACTION_LIKE;
|
this.subType = MsgSubType.REACTION_LIKE;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
|
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
}
|
}
|
||||||
@@ -91,37 +89,30 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
|||||||
public ReactionBody check() {
|
public ReactionBody check() {
|
||||||
if (!isSupportedSubType(subType))
|
if (!isSupportedSubType(subType))
|
||||||
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
||||||
|
if (toLogin == null || toLogin.isBlank())
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
throw new IllegalArgumentException("toLogin is blank");
|
||||||
throw new IllegalArgumentException("toBlockchainName is blank");
|
|
||||||
if (toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber < 0)
|
||||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (nameBytes.length == 0 || nameBytes.length > 255)
|
if (loginBytes.length == 0 || loginBytes.length > 255)
|
||||||
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
|
||||||
|
|
||||||
int cap = 1 + nameBytes.length + 4 + 32;
|
ByteBuffer bb = ByteBuffer.allocate(1 + loginBytes.length + 4 + 32).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.put((byte) loginBytes.length);
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
bb.put(loginBytes);
|
||||||
bb.put((byte) nameBytes.length);
|
|
||||||
bb.put(nameBytes);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ====================== */
|
@Override public String toLogin() { return toLogin; }
|
||||||
|
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
|
|||||||
+24
-35
@@ -13,11 +13,11 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* StatusActionBody — type=5, ver=1.
|
* StatusActionBody — type=5, ver=1.
|
||||||
*
|
*
|
||||||
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
|
* Все STATUS_ACTION имеют target на конкретный логический блок и опциональный текст-пояснение.
|
||||||
*
|
*
|
||||||
* Формат bodyBytes (BigEndian):
|
* Формат bodyBytes (BigEndian):
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [1] toLoginLen (uint8)
|
||||||
* [N] toBlockchainName UTF-8
|
* [N] toLogin UTF-8
|
||||||
* [4] toBlockGlobalNumber
|
* [4] toBlockGlobalNumber
|
||||||
* [32] toBlockHash32
|
* [32] toBlockHash32
|
||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
@@ -31,7 +31,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
public final short subType;
|
public final short subType;
|
||||||
public final short version;
|
public final short version;
|
||||||
public final String toBlockchainName;
|
public final String toLogin;
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
public final String message;
|
public final String message;
|
||||||
@@ -51,33 +51,32 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
|
||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
|
if (loginLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toLoginLen is 0");
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
ensureMin(bb, loginLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
byte[] loginBytes = new byte[loginLen];
|
||||||
bb.get(nameBytes);
|
bb.get(loginBytes);
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
this.toBlockHash32 = new byte[32];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(this.toBlockHash32);
|
bb.get(this.toBlockHash32);
|
||||||
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
|
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
|
||||||
|
|
||||||
ensureNoTail(bb, "StatusActionBody");
|
ensureNoTail(bb, "StatusActionBody");
|
||||||
}
|
}
|
||||||
|
|
||||||
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
public StatusActionBody(short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
Objects.requireNonNull(message, "message == null");
|
Objects.requireNonNull(message, "message == null");
|
||||||
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
|
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
this.message = message;
|
this.message = message;
|
||||||
@@ -85,16 +84,10 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public StatusActionBody check() {
|
public StatusActionBody check() {
|
||||||
if (!isSupportedSubType(subType)) {
|
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
||||||
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("STATUS_ACTION toLogin is blank");
|
||||||
}
|
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
|
|
||||||
}
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
|
||||||
}
|
|
||||||
if (message == null) throw new IllegalArgumentException("message is null");
|
if (message == null) throw new IllegalArgumentException("message is null");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -104,15 +97,14 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
if (loginUtf8.length == 0 || loginUtf8.length > 255)
|
||||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255");
|
throw new IllegalArgumentException("STATUS_ACTION toLogin utf8 len must be 1..255");
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||||
.order(ByteOrder.BIG_ENDIAN);
|
.order(ByteOrder.BIG_ENDIAN);
|
||||||
bb.put((byte) nameUtf8.length);
|
bb.put((byte) loginUtf8.length);
|
||||||
bb.put(nameUtf8);
|
bb.put(loginUtf8);
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
bb.putShort((short) msgUtf8.length);
|
bb.putShort((short) msgUtf8.length);
|
||||||
@@ -120,7 +112,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
@Override public String toLogin() { return toLogin; }
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
@@ -141,14 +133,11 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) return "";
|
if (len == 0) return "";
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
byte[] bytes = new byte[len];
|
||||||
bb.get(bytes);
|
bb.get(bytes);
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
} catch (CharacterCodingException e) {
|
} catch (CharacterCodingException e) {
|
||||||
|
|||||||
+36
-62
@@ -33,23 +33,13 @@ import java.util.Objects;
|
|||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
* [N] text UTF-8
|
* [N] text UTF-8
|
||||||
*
|
*
|
||||||
* EDIT_POST:
|
* EDIT_POST / REPOST:
|
||||||
* [4] lineCode
|
* [4] lineCode
|
||||||
* [4] prevLineNumber
|
* [4] prevLineNumber
|
||||||
* [32] prevLineHash32
|
* [32] prevLineHash32
|
||||||
* [4] thisLineNumber
|
* [4] thisLineNumber
|
||||||
* [4] toBlockGlobalNumber (int32)
|
* [1] toLoginLen (uint8)
|
||||||
* [32] toBlockHash32
|
* [N] toLogin UTF-8
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [N] text UTF-8
|
|
||||||
*
|
|
||||||
* REPOST:
|
|
||||||
* [4] lineCode
|
|
||||||
* [4] prevLineNumber
|
|
||||||
* [32] prevLineHash32
|
|
||||||
* [4] thisLineNumber
|
|
||||||
* [1] toBlockchainNameLen (uint8)
|
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber (int32)
|
* [4] toBlockGlobalNumber (int32)
|
||||||
* [32] toBlockHash32
|
* [32] toBlockHash32
|
||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
@@ -72,7 +62,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
public final int thisLineNumber;
|
public final int thisLineNumber;
|
||||||
|
|
||||||
// target (для EDIT_POST / REPOST)
|
// target (для EDIT_POST / REPOST)
|
||||||
public final String toBlockchainName; // nullable для POST/EDIT_POST
|
public final String toLogin; // nullable для сообщений без target
|
||||||
public final Integer toBlockGlobalNumber; // nullable для POST
|
public final Integer toBlockGlobalNumber; // nullable для POST
|
||||||
public final byte[] toBlockHash32; // nullable для POST
|
public final byte[] toBlockHash32; // nullable для POST
|
||||||
|
|
||||||
@@ -116,29 +106,19 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
|
|
||||||
this.thisLineNumber = bb.getInt();
|
this.thisLineNumber = bb.getInt();
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
// нужен target
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TEXT target missing");
|
||||||
ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target");
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
int tgtNum = bb.getInt();
|
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||||
byte[] tgtHash = new byte[32];
|
ensureMin(bb, loginLen + 4 + 32 + 2, "TEXT target payload too short");
|
||||||
bb.get(tgtHash);
|
byte[] loginBytes = new byte[loginLen];
|
||||||
|
bb.get(loginBytes);
|
||||||
this.toBlockchainName = null;
|
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
this.toBlockGlobalNumber = tgtNum;
|
|
||||||
this.toBlockHash32 = tgtHash;
|
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
|
||||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPOST missing target");
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("REPOST toBlockchainNameLen is 0");
|
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPOST payload too short");
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
|
||||||
bb.get(nameBytes);
|
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
this.toBlockHash32 = new byte[32];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(this.toBlockHash32);
|
bb.get(this.toBlockHash32);
|
||||||
} else {
|
} else {
|
||||||
this.toBlockchainName = null;
|
this.toLogin = null;
|
||||||
this.toBlockGlobalNumber = null;
|
this.toBlockGlobalNumber = null;
|
||||||
this.toBlockHash32 = null;
|
this.toBlockHash32 = null;
|
||||||
}
|
}
|
||||||
@@ -158,7 +138,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
short subType,
|
short subType,
|
||||||
Integer toBlockGlobalNumber,
|
Integer toBlockGlobalNumber,
|
||||||
byte[] toBlockHash32,
|
byte[] toBlockHash32,
|
||||||
String toBlockchainName,
|
String toLogin,
|
||||||
String message) {
|
String message) {
|
||||||
|
|
||||||
Objects.requireNonNull(message, "message == null");
|
Objects.requireNonNull(message, "message == null");
|
||||||
@@ -189,27 +169,29 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
this.thisLineNumber = thisLineNumber;
|
this.thisLineNumber = thisLineNumber;
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.toBlockchainName = null;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
} else {
|
} else {
|
||||||
this.toBlockchainName = null;
|
this.toLogin = null;
|
||||||
this.toBlockGlobalNumber = null;
|
this.toBlockGlobalNumber = null;
|
||||||
this.toBlockHash32 = null;
|
this.toBlockHash32 = null;
|
||||||
}
|
}
|
||||||
@@ -240,13 +222,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
||||||
if (toBlockchainName != null)
|
if (toLogin == null || toLogin.isBlank())
|
||||||
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName");
|
throw new IllegalArgumentException("EDIT_POST toLogin is blank");
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
if (message == null || message.isBlank())
|
if (message == null || message.isBlank())
|
||||||
throw new IllegalArgumentException("REPOST message is blank");
|
throw new IllegalArgumentException("REPOST message is blank");
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
if (toLogin == null || toLogin.isBlank())
|
||||||
throw new IllegalArgumentException("REPOST toBlockchainName is blank");
|
throw new IllegalArgumentException("REPOST toLogin is blank");
|
||||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||||
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
|
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
@@ -259,7 +241,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
} else if (message == null) {
|
} else if (message == null) {
|
||||||
throw new IllegalArgumentException("Text message is null");
|
throw new IllegalArgumentException("Text message is null");
|
||||||
}
|
}
|
||||||
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
if (toLogin != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||||
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
|
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,19 +266,14 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||||
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
||||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|
||||||
// EDIT_POST
|
|
||||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
|
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
|
|
||||||
cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
|
|
||||||
} else {
|
} else {
|
||||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName");
|
if (toLogin == null) throw new IllegalArgumentException("target missing toLogin");
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
byte[] nameUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||||
throw new IllegalArgumentException("REPOST toBlockchainName utf8 len must be 1..255");
|
throw new IllegalArgumentException("target toLogin utf8 len must be 1..255");
|
||||||
}
|
}
|
||||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPOST missing toBlockGlobalNumber");
|
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("target missing toBlockGlobalNumber");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32");
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("target toBlockHash32 != 32");
|
||||||
cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length;
|
cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,13 +284,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||||
bb.putInt(thisLineNumber);
|
bb.putInt(thisLineNumber);
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
bb.putInt(toBlockGlobalNumber);
|
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
bb.put(toBlockHash32);
|
bb.put((byte) loginUtf8.length);
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
bb.put(loginUtf8);
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
|
||||||
bb.put((byte) nameUtf8.length);
|
|
||||||
bb.put(nameUtf8);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
}
|
}
|
||||||
@@ -331,7 +305,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
@Override public int lineSeq() { return thisLineNumber; }
|
@Override public int lineSeq() { return thisLineNumber; }
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ===================== */
|
/* ====================== BodyHasTarget ===================== */
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
@Override public String toLogin() { return toLogin; }
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
|
|||||||
+25
-38
@@ -11,14 +11,11 @@ import java.util.Arrays;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TextRatingBody — type=1, ver=1.
|
* TextRatingBody — type=1, ver=1, subType=30.
|
||||||
*
|
|
||||||
* subType:
|
|
||||||
* - RATING (30)
|
|
||||||
*
|
*
|
||||||
* Формат bodyBytes (BigEndian):
|
* Формат bodyBytes (BigEndian):
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [1] toLoginLen (uint8)
|
||||||
* [N] toBlockchainName UTF-8
|
* [N] toLogin UTF-8
|
||||||
* [4] toBlockGlobalNumber
|
* [4] toBlockGlobalNumber
|
||||||
* [32] toBlockHash32
|
* [32] toBlockHash32
|
||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
@@ -32,7 +29,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
public final short subType;
|
public final short subType;
|
||||||
public final short version;
|
public final short version;
|
||||||
public final String toBlockchainName;
|
public final String toLogin;
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
public final String message;
|
public final String message;
|
||||||
@@ -52,33 +49,32 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
|
||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
|
if (loginLen <= 0) throw new IllegalArgumentException("RATING toLoginLen is 0");
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
|
ensureMin(bb, loginLen + 4 + 32 + 2, "RATING payload too short");
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
byte[] loginBytes = new byte[loginLen];
|
||||||
bb.get(nameBytes);
|
bb.get(loginBytes);
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
this.toBlockHash32 = new byte[32];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(this.toBlockHash32);
|
bb.get(this.toBlockHash32);
|
||||||
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
|
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
|
||||||
|
|
||||||
ensureNoTail(bb, "TextRatingBody");
|
ensureNoTail(bb, "TextRatingBody");
|
||||||
}
|
}
|
||||||
|
|
||||||
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
public TextRatingBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
Objects.requireNonNull(message, "message == null");
|
Objects.requireNonNull(message, "message == null");
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||||
|
|
||||||
this.subType = MsgSubType.TEXT_RATING;
|
this.subType = MsgSubType.TEXT_RATING;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
this.message = message;
|
this.message = message;
|
||||||
@@ -86,16 +82,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TextRatingBody check() {
|
public TextRatingBody check() {
|
||||||
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF))
|
||||||
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
|
throw new IllegalArgumentException("Bad RATING subType");
|
||||||
}
|
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("RATING toLogin is blank");
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("RATING toBlockchainName is blank");
|
|
||||||
}
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
|
||||||
}
|
|
||||||
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
|
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -106,15 +97,14 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
if (loginUtf8.length == 0 || loginUtf8.length > 255)
|
||||||
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
|
throw new IllegalArgumentException("RATING toLogin utf8 len must be 1..255");
|
||||||
}
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||||
.order(ByteOrder.BIG_ENDIAN);
|
.order(ByteOrder.BIG_ENDIAN);
|
||||||
bb.put((byte) nameUtf8.length);
|
bb.put((byte) loginUtf8.length);
|
||||||
bb.put(nameUtf8);
|
bb.put(loginUtf8);
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
bb.putShort((short) msgUtf8.length);
|
bb.putShort((short) msgUtf8.length);
|
||||||
@@ -122,7 +112,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
@Override public String toLogin() { return toLogin; }
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
@@ -130,14 +120,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
|
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
byte[] bytes = new byte[len];
|
||||||
bb.get(bytes);
|
bb.get(bytes);
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||||
|
|||||||
+36
-108
@@ -17,84 +17,56 @@ import java.util.Objects;
|
|||||||
* - REPLY (20)
|
* - REPLY (20)
|
||||||
* - EDIT_REPLY (21)
|
* - EDIT_REPLY (21)
|
||||||
*
|
*
|
||||||
* Форматы bodyBytes (BigEndian):
|
* Оба subtype используют одинаковый target-формат:
|
||||||
*
|
* [1] toLoginLen (uint8)
|
||||||
* REPLY:
|
* [N] toLogin UTF-8
|
||||||
* [1] toBlockchainNameLen (uint8)
|
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber
|
* [4] toBlockGlobalNumber
|
||||||
* [32] toBlockHash32
|
* [32] toBlockHash32
|
||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
* [M] text UTF-8
|
* [M] text UTF-8
|
||||||
*
|
*
|
||||||
* EDIT_REPLY:
|
* Для EDIT_REPLY text может быть пустым (логическое удаление).
|
||||||
* [4] toBlockGlobalNumber
|
|
||||||
* [32] toBlockHash32
|
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [N] text UTF-8
|
|
||||||
*/
|
*/
|
||||||
public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||||
|
|
||||||
public static final short TYPE = 1;
|
public static final short TYPE = 1;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
|
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
public final short subType; // из header
|
public final short subType;
|
||||||
public final short version; // (=1)
|
public final short version;
|
||||||
|
|
||||||
// target
|
public final String toLogin;
|
||||||
public final String toBlockchainName; // nullable для EDIT_REPLY
|
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32; // 32
|
public final byte[] toBlockHash32;
|
||||||
|
|
||||||
// text
|
|
||||||
public final String message;
|
public final String message;
|
||||||
|
|
||||||
public TextReplyBody(short subType, short version, byte[] bodyBytes) {
|
public TextReplyBody(short subType, short version, byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
|
||||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||||
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
|
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||||
}
|
}
|
||||||
|
|
||||||
int st = this.subType & 0xFFFF;
|
int st = this.subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||||
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
|
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
|
||||||
}
|
}
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TextReplyBody too short");
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
// минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2]
|
if (loginLen <= 0) throw new IllegalArgumentException("TextReplyBody toLoginLen is 0");
|
||||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");
|
ensureMin(bb, loginLen + 4 + 32 + 2, "TextReplyBody payload too short");
|
||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
byte[] loginBytes = new byte[loginLen];
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
|
bb.get(loginBytes);
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");
|
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
byte[] nameBytes = new byte[nameLen];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(nameBytes);
|
bb.get(this.toBlockHash32);
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
|
||||||
|
|
||||||
this.toBlockHash32 = new byte[32];
|
|
||||||
bb.get(this.toBlockHash32);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// EDIT_REPLY: target без имени
|
|
||||||
ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");
|
|
||||||
|
|
||||||
this.toBlockchainName = null;
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
|
||||||
|
|
||||||
this.toBlockHash32 = new byte[32];
|
|
||||||
bb.get(this.toBlockHash32);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
||||||
ensureNoTail(bb, "TextReplyBody");
|
ensureNoTail(bb, "TextReplyBody");
|
||||||
@@ -103,11 +75,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
public TextReplyBody(short subType,
|
public TextReplyBody(short subType,
|
||||||
int toBlockGlobalNumber,
|
int toBlockGlobalNumber,
|
||||||
byte[] toBlockHash32,
|
byte[] toBlockHash32,
|
||||||
String toBlockchainName,
|
String toLogin,
|
||||||
String message) {
|
String message) {
|
||||||
|
|
||||||
Objects.requireNonNull(message, "message == null");
|
Objects.requireNonNull(message, "message == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||||
@@ -116,25 +88,15 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
|
||||||
throw new IllegalArgumentException("message is blank");
|
throw new IllegalArgumentException("message is blank");
|
||||||
}
|
}
|
||||||
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
|
||||||
this.toBlockchainName = toBlockchainName;
|
|
||||||
} else {
|
|
||||||
// EDIT_REPLY: имя не хранить
|
|
||||||
this.toBlockchainName = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
|
|
||||||
this.message = message;
|
this.message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,23 +105,14 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
|
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
|
||||||
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
||||||
|
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||||
if (message == null || message.isBlank())
|
if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank");
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
} else if (message == null) {
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
throw new IllegalArgumentException("EDIT_REPLY message is null");
|
||||||
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
|
|
||||||
} else {
|
|
||||||
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
|
|
||||||
if (toBlockchainName != null)
|
|
||||||
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,47 +120,27 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
|
||||||
throw new IllegalArgumentException("Text payload is empty");
|
throw new IllegalArgumentException("Text payload is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
if (loginUtf8.length == 0 || loginUtf8.length > 255)
|
||||||
|
throw new IllegalArgumentException("TextReplyBody toLogin utf8 len must be 1..255");
|
||||||
|
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255)
|
.order(ByteOrder.BIG_ENDIAN);
|
||||||
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");
|
bb.put((byte) loginUtf8.length);
|
||||||
|
bb.put(loginUtf8);
|
||||||
int cap = 1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length;
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.put((byte) nameUtf8.length);
|
|
||||||
bb.put(nameUtf8);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
|
||||||
bb.put(toBlockHash32);
|
|
||||||
bb.putShort((short) msgUtf8.length);
|
|
||||||
bb.put(msgUtf8);
|
|
||||||
|
|
||||||
return bb.array();
|
|
||||||
}
|
|
||||||
|
|
||||||
// EDIT_REPLY
|
|
||||||
int cap = (4 + 32) + 2 + msgUtf8.length;
|
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
bb.putShort((short) msgUtf8.length);
|
bb.putShort((short) msgUtf8.length);
|
||||||
bb.put(msgUtf8);
|
bb.put(msgUtf8);
|
||||||
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ====================== */
|
@Override public String toLogin() { return toLogin; }
|
||||||
|
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
@@ -215,8 +148,6 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== helpers ====================== */
|
|
||||||
|
|
||||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) {
|
if (len == 0) {
|
||||||
@@ -224,14 +155,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
throw new IllegalArgumentException(fieldName + " is empty");
|
throw new IllegalArgumentException(fieldName + " is empty");
|
||||||
}
|
}
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
byte[] bytes = new byte[len];
|
||||||
bb.get(bytes);
|
bb.get(bytes);
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package blockchain;
|
||||||
|
|
||||||
|
import blockchain.body.ForkBody;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
final class ForkBodyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundTripThroughBodyRecordParser() {
|
||||||
|
byte[] parentKey = filled(32, 0x11);
|
||||||
|
byte[] forkHash = filled(32, 0x22);
|
||||||
|
byte[] tipHash = filled(32, 0x33);
|
||||||
|
|
||||||
|
ForkBody source = new ForkBody(
|
||||||
|
parentKey,
|
||||||
|
120,
|
||||||
|
forkHash,
|
||||||
|
1_700_000_000_000L,
|
||||||
|
137,
|
||||||
|
tipHash,
|
||||||
|
1_700_100_000_000L,
|
||||||
|
17,
|
||||||
|
ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK,
|
||||||
|
"Удаляю неизвестные записи"
|
||||||
|
).check();
|
||||||
|
|
||||||
|
ForkBody parsed = assertInstanceOf(
|
||||||
|
ForkBody.class,
|
||||||
|
BodyRecordParser.parse(ForkBody.TYPE, ForkBody.SUBTYPE, ForkBody.VER, source.toBytes())
|
||||||
|
);
|
||||||
|
|
||||||
|
assertArrayEquals(parentKey, parsed.parentBlockchainKey32);
|
||||||
|
assertEquals(120, parsed.forkPointBlockNumber);
|
||||||
|
assertArrayEquals(forkHash, parsed.forkPointBlockHash32);
|
||||||
|
assertEquals(137, parsed.parentTipBlockNumber);
|
||||||
|
assertArrayEquals(tipHash, parsed.parentTipBlockHash32);
|
||||||
|
assertEquals(17, parsed.discardedBlocksCount);
|
||||||
|
assertEquals(ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK, parsed.reasonCode);
|
||||||
|
assertEquals("Удаляю неизвестные записи", parsed.comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void routineRotationMayDiscardNothing() {
|
||||||
|
ForkBody body = new ForkBody(
|
||||||
|
filled(32, 1),
|
||||||
|
10,
|
||||||
|
filled(32, 2),
|
||||||
|
1000,
|
||||||
|
10,
|
||||||
|
filled(32, 2),
|
||||||
|
1000,
|
||||||
|
0,
|
||||||
|
ForkBody.REASON_ROUTINE_ROTATION,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
assertDoesNotThrow(body::check);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectedWhenDiscardCountDoesNotMatchForkPointAndTip() {
|
||||||
|
ForkBody body = new ForkBody(
|
||||||
|
filled(32, 1),
|
||||||
|
10,
|
||||||
|
filled(32, 2),
|
||||||
|
1000,
|
||||||
|
12,
|
||||||
|
filled(32, 3),
|
||||||
|
2000,
|
||||||
|
99,
|
||||||
|
ForkBody.REASON_POSSIBLE_COMPROMISE,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
assertThrows(IllegalArgumentException.class, body::check);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] filled(int size, int value) {
|
||||||
|
byte[] out = new byte[size];
|
||||||
|
Arrays.fill(out, (byte) value);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package blockchain;
|
||||||
|
|
||||||
|
import blockchain.body.ConnectionBody;
|
||||||
|
import blockchain.body.ReactionBody;
|
||||||
|
import blockchain.body.StatusActionBody;
|
||||||
|
import blockchain.body.TextLineBody;
|
||||||
|
import blockchain.body.TextReplyBody;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
final class TargetLoginFormatTest {
|
||||||
|
|
||||||
|
private static final byte[] HASH = hash(7);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reactionRoundTripStoresLoginNotBlockchainName() {
|
||||||
|
ReactionBody source = new ReactionBody("alice", 42, HASH);
|
||||||
|
ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check();
|
||||||
|
|
||||||
|
assertEquals("alice", parsed.toLogin());
|
||||||
|
assertEquals(42, parsed.toBlockGlobalNumber());
|
||||||
|
assertArrayEquals(HASH, parsed.toBlockHashBytes());
|
||||||
|
assertFalse(new String(source.toBytes()).contains("alice-001"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyAndEditReplyUseSameLoginTargetShape() {
|
||||||
|
TextReplyBody reply = new TextReplyBody(MsgSubType.TEXT_REPLY, 11, HASH, "alice", "reply");
|
||||||
|
TextReplyBody parsedReply = new TextReplyBody(MsgSubType.TEXT_REPLY, TextReplyBody.VER, reply.toBytes()).check();
|
||||||
|
assertEquals("alice", parsedReply.toLogin());
|
||||||
|
|
||||||
|
TextReplyBody edit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, 11, HASH, "alice", "");
|
||||||
|
TextReplyBody parsedEdit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, TextReplyBody.VER, edit.toBytes()).check();
|
||||||
|
assertEquals("alice", parsedEdit.toLogin());
|
||||||
|
assertEquals("", parsedEdit.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editPostAndConnectionCarryLoginTarget() {
|
||||||
|
TextLineBody editPost = new TextLineBody(
|
||||||
|
0, -1, new byte[32], -1,
|
||||||
|
MsgSubType.TEXT_EDIT_POST,
|
||||||
|
5, HASH, "alice", "edited"
|
||||||
|
);
|
||||||
|
TextLineBody parsedEditPost = new TextLineBody(MsgSubType.TEXT_EDIT_POST, TextLineBody.VER, editPost.toBytes()).check();
|
||||||
|
assertEquals("alice", parsedEditPost.toLogin());
|
||||||
|
|
||||||
|
ConnectionBody connection = new ConnectionBody(
|
||||||
|
0, -1, new byte[32], -1,
|
||||||
|
MsgSubType.CONNECTION_FOLLOW,
|
||||||
|
"alice", 0, HASH
|
||||||
|
);
|
||||||
|
ConnectionBody parsedConnection = new ConnectionBody(MsgSubType.CONNECTION_FOLLOW, ConnectionBody.VER, connection.toBytes()).check();
|
||||||
|
assertEquals("alice", parsedConnection.toLogin());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusActionCarriesLoginTarget() {
|
||||||
|
StatusActionBody source = new StatusActionBody(MsgSubType.STATUS_STARTED, "alice", 77, HASH, "");
|
||||||
|
StatusActionBody parsed = new StatusActionBody(MsgSubType.STATUS_STARTED, StatusActionBody.VER, source.toBytes()).check();
|
||||||
|
assertEquals("alice", parsed.toLogin());
|
||||||
|
assertEquals(77, parsed.toBlockGlobalNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] hash(int seed) {
|
||||||
|
byte[] out = new byte[32];
|
||||||
|
Arrays.fill(out, (byte) seed);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ public final class DatabaseInitializer {
|
|||||||
public static final int SCHEMA_VERSION_23 = 23;
|
public static final int SCHEMA_VERSION_23 = 23;
|
||||||
public static final int SCHEMA_VERSION_24 = 24;
|
public static final int SCHEMA_VERSION_24 = 24;
|
||||||
public static final int SCHEMA_VERSION_25 = 25;
|
public static final int SCHEMA_VERSION_25 = 25;
|
||||||
|
public static final int SCHEMA_VERSION_26 = 26;
|
||||||
|
public static final int SCHEMA_VERSION_27 = 27;
|
||||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||||
@@ -67,6 +69,8 @@ public final class DatabaseInitializer {
|
|||||||
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
|
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
|
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql";
|
public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V26_RESOURCE = "postgres/migration_v26.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V27_RESOURCE = "postgres/migration_v27.sql";
|
||||||
|
|
||||||
private DatabaseInitializer() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -236,6 +240,14 @@ public final class DatabaseInitializer {
|
|||||||
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
|
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
|
||||||
currentVersion = SCHEMA_VERSION_25;
|
currentVersion = SCHEMA_VERSION_25;
|
||||||
}
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_26) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V26_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_26;
|
||||||
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_27) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V27_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_27;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+94
@@ -127,6 +127,100 @@ public final class BlockchainResyncCleanupDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* После смены fork обновляет только runtime-кэш физического blockchainName у логических target-ссылок.
|
||||||
|
* Подписанная идентичность target уже задаётся login + blockNumber + blockHash; поэтому смена cache value
|
||||||
|
* не меняет смысл ссылки и не требует переподписывать чужие блоки.
|
||||||
|
*/
|
||||||
|
public void refreshLogicalTargetBlockchainCache(String login, String oldBlockchainName, String newBlockchainName) throws SQLException {
|
||||||
|
if (login == null || login.isBlank() || newBlockchainName == null || newBlockchainName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("login/newBlockchainName are required");
|
||||||
|
}
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
updateTargetCacheByLogin(c, "blocks", login, newBlockchainName);
|
||||||
|
updateTargetCacheByLogin(c, "connections_state", login, newBlockchainName);
|
||||||
|
updateTargetCacheByLogin(c, "reactions_state", login, newBlockchainName);
|
||||||
|
updateTargetCacheByLogin(c, "message_stats", login, newBlockchainName);
|
||||||
|
if (oldBlockchainName != null && !oldBlockchainName.isBlank()) {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("UPDATE message_views_state SET to_bch_name=? WHERE to_bch_name=?")) {
|
||||||
|
ps.setString(1, newBlockchainName);
|
||||||
|
ps.setString(2, oldBlockchainName);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("UPDATE channel_read_state SET owner_bch_name=? WHERE owner_bch_name=?")) {
|
||||||
|
ps.setString(1, newBlockchainName);
|
||||||
|
ps.setString(2, oldBlockchainName);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuildInboundMessageCounters(c, login, newBlockchainName);
|
||||||
|
rebuildStatsState(c);
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
if (e instanceof SQLException sql) throw sql;
|
||||||
|
throw new SQLException("Failed to refresh logical target blockchain cache for login=" + login, e);
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* После replay candidate-цепочки её собственные message_stats уже существуют, но входящие реакции/ответы
|
||||||
|
* других пользователей были созданы раньше и не проходили триггеры повторно. Поэтому пересчитываем
|
||||||
|
* агрегаты из исходных таблиц после перепривязки runtime to_bch_name cache.
|
||||||
|
*/
|
||||||
|
private void rebuildInboundMessageCounters(Connection c, String login, String newBlockchainName) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE message_stats ms
|
||||||
|
SET likes_count = (
|
||||||
|
SELECT COUNT(*)::INTEGER
|
||||||
|
FROM reactions_state rs
|
||||||
|
WHERE rs.reaction_type = ?
|
||||||
|
AND rs.last_sub_type = ?
|
||||||
|
AND LOWER(rs.to_login) = LOWER(ms.to_login)
|
||||||
|
AND rs.to_bch_name = ms.to_bch_name
|
||||||
|
AND rs.to_block_number = ms.to_block_number
|
||||||
|
AND rs.to_block_hash = ms.to_block_hash
|
||||||
|
),
|
||||||
|
replies_count = (
|
||||||
|
SELECT COUNT(*)::INTEGER
|
||||||
|
FROM blocks b
|
||||||
|
WHERE b.msg_type = 1
|
||||||
|
AND b.msg_sub_type = ?
|
||||||
|
AND LOWER(b.to_login) = LOWER(ms.to_login)
|
||||||
|
AND b.to_bch_name = ms.to_bch_name
|
||||||
|
AND b.to_block_number = ms.to_block_number
|
||||||
|
AND b.to_block_hash = ms.to_block_hash
|
||||||
|
)
|
||||||
|
WHERE LOWER(ms.to_login) = LOWER(?)
|
||||||
|
AND ms.to_bch_name = ?
|
||||||
|
""")) {
|
||||||
|
ps.setInt(1, DatabaseInitializer.REACTION_LIKE);
|
||||||
|
ps.setInt(2, DatabaseInitializer.REACTION_LIKE);
|
||||||
|
ps.setInt(3, DatabaseInitializer.TEXT_REPLY);
|
||||||
|
ps.setString(4, login);
|
||||||
|
ps.setString(5, newBlockchainName);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateTargetCacheByLogin(Connection c, String table, String login, String newBlockchainName) throws SQLException {
|
||||||
|
String sql = "UPDATE " + table + " SET to_bch_name=? WHERE LOWER(to_login)=LOWER(?) AND to_bch_name IS DISTINCT FROM ?";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, newBlockchainName);
|
||||||
|
ps.setString(2, login);
|
||||||
|
ps.setString(3, newBlockchainName);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
|
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login
|
SELECT login
|
||||||
|
|||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Хранилище candidate-блоков будущего fork во время ротации ключей. */
|
||||||
|
public final class KeyRotationCandidateBlocksDAO {
|
||||||
|
private static volatile KeyRotationCandidateBlocksDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private KeyRotationCandidateBlocksDAO() { }
|
||||||
|
|
||||||
|
public static KeyRotationCandidateBlocksDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (KeyRotationCandidateBlocksDAO.class) {
|
||||||
|
if (instance == null) instance = new KeyRotationCandidateBlocksDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Идемпотентно сохраняет один candidate-блок.
|
||||||
|
* Повтор того же blockNumber допустим только при полном совпадении hash и DataItem id.
|
||||||
|
*/
|
||||||
|
public KeyRotationCandidateBlockEntry insertOrGet(Connection c, KeyRotationCandidateBlockEntry entry) throws SQLException {
|
||||||
|
KeyRotationCandidateBlockEntry existing = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber());
|
||||||
|
if (existing != null) {
|
||||||
|
if (Arrays.equals(existing.getBlockHash(), entry.getBlockHash())
|
||||||
|
&& Arrays.equals(existing.getDataItemId(), entry.getDataItemId())) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
throw new SQLException("Candidate block conflict at number=" + entry.getBlockNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO key_rotation_candidate_blocks (
|
||||||
|
rotation_session_id, login, candidate_blockchain_name,
|
||||||
|
block_number, block_hash, data_item_id, block_bytes,
|
||||||
|
arweave_publish_pending, arweave_published_at_ms, created_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, NULL, ?)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||||
|
int i = 1;
|
||||||
|
ps.setLong(i++, entry.getRotationSessionId());
|
||||||
|
ps.setString(i++, entry.getLogin());
|
||||||
|
ps.setString(i++, entry.getCandidateBlockchainName());
|
||||||
|
ps.setInt(i++, entry.getBlockNumber());
|
||||||
|
ps.setBytes(i++, entry.getBlockHash());
|
||||||
|
ps.setBytes(i++, entry.getDataItemId());
|
||||||
|
ps.setBytes(i++, entry.getBlockBytes());
|
||||||
|
ps.setLong(i++, entry.getCreatedAtMs());
|
||||||
|
ps.executeUpdate();
|
||||||
|
try (ResultSet keys = ps.getGeneratedKeys()) {
|
||||||
|
if (!keys.next()) throw new SQLException("candidate block insert returned no id");
|
||||||
|
entry.setId(keys.getLong(1));
|
||||||
|
}
|
||||||
|
} catch (SQLException insertFailure) {
|
||||||
|
// Конкурирующий повтор из другой сессии мог успеть вставиться между SELECT и INSERT.
|
||||||
|
KeyRotationCandidateBlockEntry raced = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber());
|
||||||
|
if (raced != null
|
||||||
|
&& Arrays.equals(raced.getBlockHash(), entry.getBlockHash())
|
||||||
|
&& Arrays.equals(raced.getDataItemId(), entry.getDataItemId())) {
|
||||||
|
return raced;
|
||||||
|
}
|
||||||
|
throw insertFailure;
|
||||||
|
}
|
||||||
|
entry.setArweavePublishPending(true);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRotationCandidateBlockEntry getByNumber(Connection c, long sessionId, int blockNumber) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT * FROM key_rotation_candidate_blocks
|
||||||
|
WHERE rotation_session_id = ? AND block_number = ?
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, sessionId);
|
||||||
|
ps.setInt(2, blockNumber);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? map(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countStored(Connection c, long sessionId) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT COUNT(*) FROM key_rotation_candidate_blocks WHERE rotation_session_id = ?
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, sessionId);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getInt(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countPublished(Connection c, long sessionId) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT COUNT(*) FROM key_rotation_candidate_blocks
|
||||||
|
WHERE rotation_session_id = ? AND arweave_publish_pending = FALSE
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, sessionId);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getInt(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все candidate-блоки одной ротации в строгом порядке block_number. */
|
||||||
|
public List<KeyRotationCandidateBlockEntry> listBySession(Connection c, long sessionId) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT * FROM key_rotation_candidate_blocks
|
||||||
|
WHERE rotation_session_id = ?
|
||||||
|
ORDER BY block_number
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, sessionId);
|
||||||
|
List<KeyRotationCandidateBlockEntry> out = new ArrayList<>();
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(map(rs));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Candidate-блоки имеют приоритет перед обычной очередью publisher-а. */
|
||||||
|
public List<KeyRotationCandidateBlockEntry> listPendingArweave(int limit) throws SQLException {
|
||||||
|
if (limit <= 0) return List.of();
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT * FROM key_rotation_candidate_blocks
|
||||||
|
WHERE arweave_publish_pending = TRUE
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT ?
|
||||||
|
""")) {
|
||||||
|
ps.setInt(1, limit);
|
||||||
|
List<KeyRotationCandidateBlockEntry> out = new ArrayList<>();
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(map(rs));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Помечает DataItem опубликованными и синхронизирует progress_current ротации
|
||||||
|
* с реальным количеством DataItem, подтверждённых publisher-ом.
|
||||||
|
*/
|
||||||
|
public void markArweavePublished(List<byte[]> dataItemIds, long publishedAtMs) throws SQLException {
|
||||||
|
if (dataItemIds == null || dataItemIds.isEmpty()) return;
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
java.util.Set<Long> sessions = new java.util.HashSet<>();
|
||||||
|
try (PreparedStatement find = c.prepareStatement("""
|
||||||
|
SELECT rotation_session_id
|
||||||
|
FROM key_rotation_candidate_blocks
|
||||||
|
WHERE data_item_id = ?
|
||||||
|
""")) {
|
||||||
|
for (byte[] id : dataItemIds) {
|
||||||
|
find.setBytes(1, id);
|
||||||
|
try (ResultSet rs = find.executeQuery()) {
|
||||||
|
if (rs.next()) sessions.add(rs.getLong(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_candidate_blocks
|
||||||
|
SET arweave_publish_pending = FALSE,
|
||||||
|
arweave_published_at_ms = COALESCE(arweave_published_at_ms, ?)
|
||||||
|
WHERE data_item_id = ?
|
||||||
|
""")) {
|
||||||
|
for (byte[] id : dataItemIds) {
|
||||||
|
ps.setLong(1, publishedAtMs);
|
||||||
|
ps.setBytes(2, id);
|
||||||
|
ps.addBatch();
|
||||||
|
}
|
||||||
|
ps.executeBatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement progress = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions s
|
||||||
|
SET progress_current = LEAST(s.progress_total, (
|
||||||
|
SELECT COUNT(*)::INTEGER
|
||||||
|
FROM key_rotation_candidate_blocks b
|
||||||
|
WHERE b.rotation_session_id = s.id
|
||||||
|
AND b.arweave_publish_pending = FALSE
|
||||||
|
)),
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE s.id = ? AND s.status = 'COPYING_CHAIN'
|
||||||
|
""")) {
|
||||||
|
for (Long sessionId : sessions) {
|
||||||
|
progress.setLong(1, publishedAtMs);
|
||||||
|
progress.setLong(2, sessionId);
|
||||||
|
progress.addBatch();
|
||||||
|
}
|
||||||
|
progress.executeBatch();
|
||||||
|
}
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
if (e instanceof SQLException sql) throw sql;
|
||||||
|
throw new SQLException("Failed to mark candidate DataItems published", e);
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyRotationCandidateBlockEntry map(ResultSet rs) throws SQLException {
|
||||||
|
KeyRotationCandidateBlockEntry e = new KeyRotationCandidateBlockEntry();
|
||||||
|
e.setId(rs.getLong("id"));
|
||||||
|
e.setRotationSessionId(rs.getLong("rotation_session_id"));
|
||||||
|
e.setLogin(rs.getString("login"));
|
||||||
|
e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name"));
|
||||||
|
e.setBlockNumber(rs.getInt("block_number"));
|
||||||
|
e.setBlockHash(rs.getBytes("block_hash"));
|
||||||
|
e.setDataItemId(rs.getBytes("data_item_id"));
|
||||||
|
e.setBlockBytes(rs.getBytes("block_bytes"));
|
||||||
|
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending"));
|
||||||
|
e.setArweavePublishedAtMs((Long) rs.getObject("arweave_published_at_ms"));
|
||||||
|
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DAO для серверной машины смены ключей.
|
||||||
|
*
|
||||||
|
* Все методы изменения статуса синхронно обновляют и key_rotation_sessions,
|
||||||
|
* и solana_user_pda_current.rotation_status / rotation_session_id.
|
||||||
|
*/
|
||||||
|
public final class KeyRotationSessionsDAO {
|
||||||
|
|
||||||
|
private static volatile KeyRotationSessionsDAO instance;
|
||||||
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
|
private KeyRotationSessionsDAO() { }
|
||||||
|
|
||||||
|
public static KeyRotationSessionsDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (KeyRotationSessionsDAO.class) {
|
||||||
|
if (instance == null) instance = new KeyRotationSessionsDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Атомарно создаёт первую серверную запись ротации сразу в COPYING_CHAIN.
|
||||||
|
* PREPARING в БД отсутствует: до этого момента всё является локальным UI-состоянием.
|
||||||
|
*/
|
||||||
|
public KeyRotationSessionEntry createCopyingSession(KeyRotationSessionEntry entry) throws SQLException {
|
||||||
|
validateNewSession(entry);
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry created = createCopyingSession(c, entry);
|
||||||
|
c.commit();
|
||||||
|
return created;
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
if (e instanceof SQLException sql) throw sql;
|
||||||
|
throw new SQLException("Failed to create key rotation session", e);
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRotationSessionEntry createCopyingSession(Connection c, KeyRotationSessionEntry entry) throws SQLException {
|
||||||
|
validateNewSession(entry);
|
||||||
|
|
||||||
|
String currentStatus;
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT rotation_status
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
WHERE login = ?
|
||||||
|
FOR UPDATE
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, entry.getLogin());
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) throw new SQLException("Unknown login for key rotation: " + entry.getLogin());
|
||||||
|
currentStatus = rs.getString(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!KeyRotationStatus.NONE.name().equals(currentStatus)) {
|
||||||
|
throw new SQLException("Key rotation already active for login=" + entry.getLogin() + ", status=" + currentStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = entry.getCreatedAtMs() > 0 ? entry.getCreatedAtMs() : System.currentTimeMillis();
|
||||||
|
entry.setCreatedAtMs(now);
|
||||||
|
entry.setUpdatedAtMs(now);
|
||||||
|
entry.setStatus(KeyRotationStatus.COPYING_CHAIN);
|
||||||
|
if (entry.getComment() == null) entry.setComment("");
|
||||||
|
if (entry.getWalletMigrationStatus() == null) entry.setWalletMigrationStatus("PENDING");
|
||||||
|
if (entry.getMessageMigrationStatus() == null) entry.setMessageMigrationStatus("PENDING");
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO key_rotation_sessions (
|
||||||
|
login, status,
|
||||||
|
source_blockchain_name, candidate_blockchain_name,
|
||||||
|
old_root_key, old_blockchain_key, old_client_key,
|
||||||
|
new_root_key, new_blockchain_key, new_client_key,
|
||||||
|
fork_from_block, fork_from_hash,
|
||||||
|
source_tip_block, source_tip_hash,
|
||||||
|
reason_code, comment,
|
||||||
|
progress_current, progress_total,
|
||||||
|
pda_rotation_signature,
|
||||||
|
wallet_migration_status, message_migration_status,
|
||||||
|
last_error, last_error_at_ms, retry_count,
|
||||||
|
created_at_ms, updated_at_ms, completed_at_ms, aborted_at_ms
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
|
||||||
|
long id;
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||||
|
int i = 1;
|
||||||
|
ps.setString(i++, entry.getLogin());
|
||||||
|
ps.setString(i++, entry.getStatus().name());
|
||||||
|
ps.setString(i++, entry.getSourceBlockchainName());
|
||||||
|
ps.setString(i++, entry.getCandidateBlockchainName());
|
||||||
|
ps.setString(i++, entry.getOldRootKey());
|
||||||
|
ps.setString(i++, entry.getOldBlockchainKey());
|
||||||
|
ps.setString(i++, entry.getOldClientKey());
|
||||||
|
ps.setString(i++, entry.getNewRootKey());
|
||||||
|
ps.setString(i++, entry.getNewBlockchainKey());
|
||||||
|
ps.setString(i++, entry.getNewClientKey());
|
||||||
|
ps.setInt(i++, entry.getForkFromBlock());
|
||||||
|
ps.setBytes(i++, entry.getForkFromHash());
|
||||||
|
ps.setInt(i++, entry.getSourceTipBlock());
|
||||||
|
ps.setBytes(i++, entry.getSourceTipHash());
|
||||||
|
ps.setShort(i++, entry.getReasonCode());
|
||||||
|
ps.setString(i++, entry.getComment());
|
||||||
|
ps.setInt(i++, entry.getProgressCurrent());
|
||||||
|
ps.setInt(i++, entry.getProgressTotal());
|
||||||
|
ps.setString(i++, entry.getPdaRotationSignature());
|
||||||
|
ps.setString(i++, entry.getWalletMigrationStatus());
|
||||||
|
ps.setString(i++, entry.getMessageMigrationStatus());
|
||||||
|
ps.setString(i++, entry.getLastError());
|
||||||
|
if (entry.getLastErrorAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getLastErrorAtMs());
|
||||||
|
ps.setInt(i++, entry.getRetryCount());
|
||||||
|
ps.setLong(i++, entry.getCreatedAtMs());
|
||||||
|
ps.setLong(i++, entry.getUpdatedAtMs());
|
||||||
|
if (entry.getCompletedAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getCompletedAtMs());
|
||||||
|
if (entry.getAbortedAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getAbortedAtMs());
|
||||||
|
ps.executeUpdate();
|
||||||
|
try (ResultSet keys = ps.getGeneratedKeys()) {
|
||||||
|
if (!keys.next()) throw new SQLException("key_rotation_sessions insert returned no id");
|
||||||
|
id = keys.getLong(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE solana_user_pda_current
|
||||||
|
SET rotation_status = ?, rotation_session_id = ?
|
||||||
|
WHERE login = ? AND rotation_status = 'NONE'
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, KeyRotationStatus.COPYING_CHAIN.name());
|
||||||
|
ps.setLong(2, id);
|
||||||
|
ps.setString(3, entry.getLogin());
|
||||||
|
if (ps.executeUpdate() != 1) {
|
||||||
|
throw new SQLException("Failed to attach key rotation session to login=" + entry.getLogin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.setId(id);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRotationSessionEntry getById(long id) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return getById(c, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRotationSessionEntry getById(Connection c, long id) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(selectBase() + " WHERE id = ?")) {
|
||||||
|
ps.setLong(1, id);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? mapRow(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRotationSessionEntry getActiveByLogin(String login) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return getActiveByLogin(c, login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRotationSessionEntry getActiveByLogin(Connection c, String login) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(selectBase() + """
|
||||||
|
WHERE login = ?
|
||||||
|
AND status NOT IN ('COMPLETE', 'ABORTED')
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? mapRow(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает несколько сессий одного состояния для фоновых workers. */
|
||||||
|
public java.util.List<KeyRotationSessionEntry> listByStatus(KeyRotationStatus status, int limit) throws SQLException {
|
||||||
|
if (status == null || status == KeyRotationStatus.NONE) return java.util.List.of();
|
||||||
|
int safeLimit = Math.max(1, Math.min(limit, 100));
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement(selectBase() + """
|
||||||
|
WHERE status = ?
|
||||||
|
ORDER BY updated_at_ms, id
|
||||||
|
LIMIT ?
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, status.name());
|
||||||
|
ps.setInt(2, safeLimit);
|
||||||
|
java.util.List<KeyRotationSessionEntry> out = new java.util.ArrayList<>();
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Атомарный переход state machine. Смена статуса выполняется только из expected.
|
||||||
|
*/
|
||||||
|
public KeyRotationSessionEntry transition(long id,
|
||||||
|
KeyRotationStatus expected,
|
||||||
|
KeyRotationStatus next) throws SQLException {
|
||||||
|
if (expected == null || next == null || !expected.canTransitionTo(next)) {
|
||||||
|
throw new IllegalArgumentException("Forbidden key rotation transition: " + expected + " -> " + next);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
|
||||||
|
if (current == null) throw new SQLException("Unknown key rotation session id=" + id);
|
||||||
|
if (current.getStatus() != expected) {
|
||||||
|
throw new SQLException("Key rotation status mismatch: expected=" + expected + ", actual=" + current.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Long completed = current.getCompletedAtMs();
|
||||||
|
if (next == KeyRotationStatus.COMPLETE) completed = now;
|
||||||
|
Long aborted = current.getAbortedAtMs();
|
||||||
|
if (next == KeyRotationStatus.ABORTED) aborted = now;
|
||||||
|
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET status = ?, updated_at_ms = ?, completed_at_ms = ?, aborted_at_ms = ?
|
||||||
|
WHERE id = ? AND status = ?
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, next.name());
|
||||||
|
ps.setLong(2, now);
|
||||||
|
if (completed == null) ps.setNull(3, java.sql.Types.BIGINT); else ps.setLong(3, completed);
|
||||||
|
if (aborted == null) ps.setNull(4, java.sql.Types.BIGINT); else ps.setLong(4, aborted);
|
||||||
|
ps.setLong(5, id);
|
||||||
|
ps.setString(6, expected.name());
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent key rotation transition for id=" + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyRotationStatus userStatus = next.userStatus();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE solana_user_pda_current
|
||||||
|
SET rotation_status = ?, rotation_session_id = ?
|
||||||
|
WHERE login = ? AND rotation_session_id = ?
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, userStatus.name());
|
||||||
|
if (userStatus == KeyRotationStatus.NONE) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, id);
|
||||||
|
ps.setString(3, current.getLogin());
|
||||||
|
ps.setLong(4, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate-копии нужны только пока ротация активна. В Arweave они уже опубликованы,
|
||||||
|
// а в PostgreSQL после COMPLETE/ABORTED временные строки больше не нужны.
|
||||||
|
if (next.isTerminalSessionStatus()) {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(
|
||||||
|
"DELETE FROM key_rotation_candidate_blocks WHERE rotation_session_id = ?")) {
|
||||||
|
ps.setLong(1, id);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.commit();
|
||||||
|
current.setStatus(next);
|
||||||
|
current.setUpdatedAtMs(now);
|
||||||
|
current.setCompletedAtMs(completed);
|
||||||
|
current.setAbortedAtMs(aborted);
|
||||||
|
return current;
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
if (e instanceof SQLException sql) throw sql;
|
||||||
|
throw new SQLException("Failed key rotation transition", e);
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateProgress(long id, int current, int total) throws SQLException {
|
||||||
|
if (current < 0 || total < 0 || current > total) {
|
||||||
|
throw new IllegalArgumentException("Invalid rotation progress: " + current + "/" + total);
|
||||||
|
}
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET progress_current = ?, progress_total = ?, updated_at_ms = ?
|
||||||
|
WHERE id = ? AND status = 'COPYING_CHAIN'
|
||||||
|
""")) {
|
||||||
|
ps.setInt(1, current);
|
||||||
|
ps.setInt(2, total);
|
||||||
|
ps.setLong(3, System.currentTimeMillis());
|
||||||
|
ps.setLong(4, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Rotation progress can only be updated in COPYING_CHAIN, id=" + id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Атомарно фиксирует tx signature и переводит CHAIN_READY -> ROTATING_PDA.
|
||||||
|
* Это точка, после которой Abort запрещён: отправленная Solana-транзакция может подтвердиться позже.
|
||||||
|
*/
|
||||||
|
public KeyRotationSessionEntry beginPdaRotation(long id, String signature) throws SQLException {
|
||||||
|
if (signature == null || signature.isBlank()) throw new IllegalArgumentException("signature is empty");
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
|
||||||
|
if (current == null) throw new SQLException("Unknown key rotation session id=" + id);
|
||||||
|
if (current.getStatus() != KeyRotationStatus.CHAIN_READY) {
|
||||||
|
throw new SQLException("PDA rotation requires CHAIN_READY, actual=" + current.getStatus());
|
||||||
|
}
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET status = 'ROTATING_PDA', pda_rotation_signature = ?, updated_at_ms = ?,
|
||||||
|
last_error = NULL, last_error_at_ms = NULL
|
||||||
|
WHERE id = ? AND status = 'CHAIN_READY'
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, signature);
|
||||||
|
ps.setLong(2, now);
|
||||||
|
ps.setLong(3, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation start for id=" + id);
|
||||||
|
}
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE solana_user_pda_current
|
||||||
|
SET rotation_status = 'ROTATING_PDA', rotation_session_id = ?
|
||||||
|
WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'CHAIN_READY'
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, id);
|
||||||
|
ps.setString(2, current.getLogin());
|
||||||
|
ps.setLong(3, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin());
|
||||||
|
}
|
||||||
|
c.commit();
|
||||||
|
current.setStatus(KeyRotationStatus.ROTATING_PDA);
|
||||||
|
current.setPdaRotationSignature(signature);
|
||||||
|
current.setUpdatedAtMs(now);
|
||||||
|
current.setLastError(null);
|
||||||
|
current.setLastErrorAtMs(null);
|
||||||
|
return current;
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
if (e instanceof SQLException sql) throw sql;
|
||||||
|
throw new SQLException("Failed to begin PDA rotation", e);
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Если Solana sync уже записал в current-PDA весь ожидаемый новый набор ключей,
|
||||||
|
* атомарно переводит ROTATING_PDA -> PDA_ROTATED. Иначе возвращает null.
|
||||||
|
*/
|
||||||
|
public KeyRotationSessionEntry tryMarkPdaRotatedFromCurrentState(long id) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
|
||||||
|
if (current == null || current.getStatus() != KeyRotationStatus.ROTATING_PDA) {
|
||||||
|
c.rollback();
|
||||||
|
return current != null && current.getStatus() == KeyRotationStatus.PDA_ROTATED ? current : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String root;
|
||||||
|
String blockchain;
|
||||||
|
String client;
|
||||||
|
String blockchainName;
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT root_key, blockchain_key, client_key, blockchain_name
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
WHERE login = ?
|
||||||
|
FOR UPDATE
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, current.getLogin());
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) { c.rollback(); return null; }
|
||||||
|
root = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("root_key"));
|
||||||
|
blockchain = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key"));
|
||||||
|
client = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("client_key"));
|
||||||
|
blockchainName = rs.getString("blockchain_name");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current.getNewRootKey().equals(root)
|
||||||
|
|| !current.getNewBlockchainKey().equals(blockchain)
|
||||||
|
|| !current.getNewClientKey().equals(client)
|
||||||
|
|| !current.getCandidateBlockchainName().equals(blockchainName)) {
|
||||||
|
c.rollback();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET status = 'PDA_ROTATED', updated_at_ms = ?, last_error = NULL, last_error_at_ms = NULL
|
||||||
|
WHERE id = ? AND status = 'ROTATING_PDA'
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, now);
|
||||||
|
ps.setLong(2, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation confirm for id=" + id);
|
||||||
|
}
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE solana_user_pda_current
|
||||||
|
SET rotation_status = 'PDA_ROTATED', rotation_session_id = ?
|
||||||
|
WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'ROTATING_PDA'
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, id);
|
||||||
|
ps.setString(2, current.getLogin());
|
||||||
|
ps.setLong(3, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to current PDA login=" + current.getLogin());
|
||||||
|
}
|
||||||
|
c.commit();
|
||||||
|
current.setStatus(KeyRotationStatus.PDA_ROTATED);
|
||||||
|
current.setUpdatedAtMs(now);
|
||||||
|
current.setLastError(null);
|
||||||
|
current.setLastErrorAtMs(null);
|
||||||
|
return current;
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
if (e instanceof SQLException sql) throw sql;
|
||||||
|
throw new SQLException("Failed to confirm rotated PDA", e);
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPdaRotationSignature(long id, String signature) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET pda_rotation_signature = ?, updated_at_ms = ?
|
||||||
|
WHERE id = ? AND status = 'ROTATING_PDA'
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, signature);
|
||||||
|
ps.setLong(2, System.currentTimeMillis());
|
||||||
|
ps.setLong(3, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("PDA signature requires ROTATING_PDA, id=" + id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фиксирует результат необязательного этапа wallet/DM.
|
||||||
|
* Имя колонки выбирается только из закрытого списка — внешние значения в SQL не подставляются.
|
||||||
|
*/
|
||||||
|
public void setMigrationSubStatus(long id, String column, String value) throws SQLException {
|
||||||
|
String safeColumn = switch (String.valueOf(column)) {
|
||||||
|
case "wallet_migration_status" -> "wallet_migration_status";
|
||||||
|
case "message_migration_status" -> "message_migration_status";
|
||||||
|
default -> throw new IllegalArgumentException("Unsupported migration status column: " + column);
|
||||||
|
};
|
||||||
|
String safeValue = switch (String.valueOf(value)) {
|
||||||
|
case "PENDING", "COMPLETE", "SKIPPED", "NOT_IMPLEMENTED" -> String.valueOf(value);
|
||||||
|
default -> throw new IllegalArgumentException("Unsupported migration status value: " + value);
|
||||||
|
};
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement(
|
||||||
|
"UPDATE key_rotation_sessions SET " + safeColumn + " = ?, updated_at_ms = ? WHERE id = ?")) {
|
||||||
|
ps.setString(1, safeValue);
|
||||||
|
ps.setLong(2, System.currentTimeMillis());
|
||||||
|
ps.setLong(3, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Unknown key rotation session id=" + id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordError(long id, String error) throws SQLException {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET last_error = ?, last_error_at_ms = ?, retry_count = retry_count + 1, updated_at_ms = ?
|
||||||
|
WHERE id = ? AND status NOT IN ('COMPLETE', 'ABORTED')
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, error);
|
||||||
|
ps.setLong(2, now);
|
||||||
|
ps.setLong(3, now);
|
||||||
|
ps.setLong(4, id);
|
||||||
|
if (ps.executeUpdate() != 1) throw new SQLException("Cannot record error for inactive rotation id=" + id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearError(long id) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET last_error = NULL, last_error_at_ms = NULL, updated_at_ms = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, System.currentTimeMillis());
|
||||||
|
ps.setLong(2, id);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private KeyRotationSessionEntry getByIdForUpdate(Connection c, long id) throws SQLException {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(selectBase() + " WHERE id = ? FOR UPDATE")) {
|
||||||
|
ps.setLong(1, id);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? mapRow(rs) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String selectBase() {
|
||||||
|
return """
|
||||||
|
SELECT
|
||||||
|
id, login, status,
|
||||||
|
source_blockchain_name, candidate_blockchain_name,
|
||||||
|
old_root_key, old_blockchain_key, old_client_key,
|
||||||
|
new_root_key, new_blockchain_key, new_client_key,
|
||||||
|
fork_from_block, fork_from_hash,
|
||||||
|
source_tip_block, source_tip_hash,
|
||||||
|
reason_code, comment,
|
||||||
|
progress_current, progress_total,
|
||||||
|
pda_rotation_signature,
|
||||||
|
wallet_migration_status, message_migration_status,
|
||||||
|
last_error, last_error_at_ms, retry_count,
|
||||||
|
created_at_ms, updated_at_ms, completed_at_ms, aborted_at_ms
|
||||||
|
FROM key_rotation_sessions
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyRotationSessionEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
KeyRotationSessionEntry e = new KeyRotationSessionEntry();
|
||||||
|
e.setId(rs.getLong("id"));
|
||||||
|
e.setLogin(rs.getString("login"));
|
||||||
|
e.setStatus(KeyRotationStatus.valueOf(rs.getString("status")));
|
||||||
|
e.setSourceBlockchainName(rs.getString("source_blockchain_name"));
|
||||||
|
e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name"));
|
||||||
|
e.setOldRootKey(rs.getString("old_root_key"));
|
||||||
|
e.setOldBlockchainKey(rs.getString("old_blockchain_key"));
|
||||||
|
e.setOldClientKey(rs.getString("old_client_key"));
|
||||||
|
e.setNewRootKey(rs.getString("new_root_key"));
|
||||||
|
e.setNewBlockchainKey(rs.getString("new_blockchain_key"));
|
||||||
|
e.setNewClientKey(rs.getString("new_client_key"));
|
||||||
|
e.setForkFromBlock(rs.getInt("fork_from_block"));
|
||||||
|
e.setForkFromHash(rs.getBytes("fork_from_hash"));
|
||||||
|
e.setSourceTipBlock(rs.getInt("source_tip_block"));
|
||||||
|
e.setSourceTipHash(rs.getBytes("source_tip_hash"));
|
||||||
|
e.setReasonCode(rs.getShort("reason_code"));
|
||||||
|
e.setComment(rs.getString("comment"));
|
||||||
|
e.setProgressCurrent(rs.getInt("progress_current"));
|
||||||
|
e.setProgressTotal(rs.getInt("progress_total"));
|
||||||
|
e.setPdaRotationSignature(rs.getString("pda_rotation_signature"));
|
||||||
|
e.setWalletMigrationStatus(rs.getString("wallet_migration_status"));
|
||||||
|
e.setMessageMigrationStatus(rs.getString("message_migration_status"));
|
||||||
|
e.setLastError(rs.getString("last_error"));
|
||||||
|
long lastErrorAt = rs.getLong("last_error_at_ms");
|
||||||
|
e.setLastErrorAtMs(rs.wasNull() ? null : lastErrorAt);
|
||||||
|
e.setRetryCount(rs.getInt("retry_count"));
|
||||||
|
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||||
|
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||||
|
long completed = rs.getLong("completed_at_ms");
|
||||||
|
e.setCompletedAtMs(rs.wasNull() ? null : completed);
|
||||||
|
long aborted = rs.getLong("aborted_at_ms");
|
||||||
|
e.setAbortedAtMs(rs.wasNull() ? null : aborted);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateNewSession(KeyRotationSessionEntry e) {
|
||||||
|
if (e == null) throw new IllegalArgumentException("entry is null");
|
||||||
|
if (blank(e.getLogin())) throw new IllegalArgumentException("login is required");
|
||||||
|
if (blank(e.getSourceBlockchainName()) || blank(e.getCandidateBlockchainName())) {
|
||||||
|
throw new IllegalArgumentException("source/candidate blockchain names are required");
|
||||||
|
}
|
||||||
|
if (blank(e.getOldRootKey()) || blank(e.getOldBlockchainKey()) || blank(e.getOldClientKey())
|
||||||
|
|| blank(e.getNewRootKey()) || blank(e.getNewBlockchainKey()) || blank(e.getNewClientKey())) {
|
||||||
|
throw new IllegalArgumentException("all old/new public keys are required");
|
||||||
|
}
|
||||||
|
if (e.getForkFromHash() == null || e.getForkFromHash().length != 32) {
|
||||||
|
throw new IllegalArgumentException("forkFromHash must be 32 bytes");
|
||||||
|
}
|
||||||
|
if (e.getSourceTipHash() == null || e.getSourceTipHash().length != 32) {
|
||||||
|
throw new IllegalArgumentException("sourceTipHash must be 32 bytes");
|
||||||
|
}
|
||||||
|
if (e.getForkFromBlock() < 0 || e.getSourceTipBlock() < e.getForkFromBlock()) {
|
||||||
|
throw new IllegalArgumentException("invalid fork/source tip block numbers");
|
||||||
|
}
|
||||||
|
if (e.getReasonCode() < 1 || e.getReasonCode() > 4) {
|
||||||
|
throw new IllegalArgumentException("reasonCode must be 1..4");
|
||||||
|
}
|
||||||
|
String comment = e.getComment() == null ? "" : e.getComment();
|
||||||
|
if (comment.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1024) {
|
||||||
|
throw new IllegalArgumentException("comment must be <= 1024 UTF-8 bytes");
|
||||||
|
}
|
||||||
|
if (e.getProgressCurrent() < 0 || e.getProgressTotal() < e.getProgressCurrent()) {
|
||||||
|
throw new IllegalArgumentException("invalid initial progress");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean blank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,27 @@ public final class SolanaUserPdaCurrentDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public SolanaUserPdaCurrentEntry getByLogin(String login) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
login,
|
||||||
|
blockchain_name,
|
||||||
|
blockchain_key,
|
||||||
|
paid_limit_bytes
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
WHERE LOWER(login) = LOWER(?)
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
return mapRow(rs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate-блок будущего fork во время смены ключей.
|
||||||
|
*
|
||||||
|
* Эти записи намеренно не попадают в обычную таблицу blocks и поэтому
|
||||||
|
* не влияют на лайки, ответы, каналы и другие materialized state до
|
||||||
|
* финального переключения активного fork.
|
||||||
|
*/
|
||||||
|
public final class KeyRotationCandidateBlockEntry {
|
||||||
|
private long id;
|
||||||
|
private long rotationSessionId;
|
||||||
|
private String login;
|
||||||
|
private String candidateBlockchainName;
|
||||||
|
private int blockNumber;
|
||||||
|
private byte[] blockHash;
|
||||||
|
private byte[] dataItemId;
|
||||||
|
private byte[] blockBytes;
|
||||||
|
private boolean arweavePublishPending;
|
||||||
|
private Long arweavePublishedAtMs;
|
||||||
|
private long createdAtMs;
|
||||||
|
|
||||||
|
public long getId() { return id; }
|
||||||
|
public void setId(long id) { this.id = id; }
|
||||||
|
public long getRotationSessionId() { return rotationSessionId; }
|
||||||
|
public void setRotationSessionId(long rotationSessionId) { this.rotationSessionId = rotationSessionId; }
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public String getCandidateBlockchainName() { return candidateBlockchainName; }
|
||||||
|
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
|
||||||
|
public int getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
public byte[] getBlockHash() { return blockHash; }
|
||||||
|
public void setBlockHash(byte[] blockHash) { this.blockHash = blockHash; }
|
||||||
|
public byte[] getDataItemId() { return dataItemId; }
|
||||||
|
public void setDataItemId(byte[] dataItemId) { this.dataItemId = dataItemId; }
|
||||||
|
public byte[] getBlockBytes() { return blockBytes; }
|
||||||
|
public void setBlockBytes(byte[] blockBytes) { this.blockBytes = blockBytes; }
|
||||||
|
public boolean isArweavePublishPending() { return arweavePublishPending; }
|
||||||
|
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
|
||||||
|
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
|
||||||
|
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
|
||||||
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публичное/runtime-состояние одной смены ключей.
|
||||||
|
* Приватные ключи и пароли в этой сущности не хранятся никогда.
|
||||||
|
*/
|
||||||
|
public final class KeyRotationSessionEntry {
|
||||||
|
private long id;
|
||||||
|
private String login;
|
||||||
|
private KeyRotationStatus status;
|
||||||
|
private String sourceBlockchainName;
|
||||||
|
private String candidateBlockchainName;
|
||||||
|
private String oldRootKey;
|
||||||
|
private String oldBlockchainKey;
|
||||||
|
private String oldClientKey;
|
||||||
|
private String newRootKey;
|
||||||
|
private String newBlockchainKey;
|
||||||
|
private String newClientKey;
|
||||||
|
private int forkFromBlock;
|
||||||
|
private byte[] forkFromHash;
|
||||||
|
private int sourceTipBlock;
|
||||||
|
private byte[] sourceTipHash;
|
||||||
|
private short reasonCode;
|
||||||
|
private String comment;
|
||||||
|
private int progressCurrent;
|
||||||
|
private int progressTotal;
|
||||||
|
private String pdaRotationSignature;
|
||||||
|
private String walletMigrationStatus;
|
||||||
|
private String messageMigrationStatus;
|
||||||
|
private String lastError;
|
||||||
|
private Long lastErrorAtMs;
|
||||||
|
private int retryCount;
|
||||||
|
private long createdAtMs;
|
||||||
|
private long updatedAtMs;
|
||||||
|
private Long completedAtMs;
|
||||||
|
private Long abortedAtMs;
|
||||||
|
|
||||||
|
public long getId() { return id; }
|
||||||
|
public void setId(long id) { this.id = id; }
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public KeyRotationStatus getStatus() { return status; }
|
||||||
|
public void setStatus(KeyRotationStatus status) { this.status = status; }
|
||||||
|
public String getSourceBlockchainName() { return sourceBlockchainName; }
|
||||||
|
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
|
||||||
|
public String getCandidateBlockchainName() { return candidateBlockchainName; }
|
||||||
|
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
|
||||||
|
public String getOldRootKey() { return oldRootKey; }
|
||||||
|
public void setOldRootKey(String oldRootKey) { this.oldRootKey = oldRootKey; }
|
||||||
|
public String getOldBlockchainKey() { return oldBlockchainKey; }
|
||||||
|
public void setOldBlockchainKey(String oldBlockchainKey) { this.oldBlockchainKey = oldBlockchainKey; }
|
||||||
|
public String getOldClientKey() { return oldClientKey; }
|
||||||
|
public void setOldClientKey(String oldClientKey) { this.oldClientKey = oldClientKey; }
|
||||||
|
public String getNewRootKey() { return newRootKey; }
|
||||||
|
public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; }
|
||||||
|
public String getNewBlockchainKey() { return newBlockchainKey; }
|
||||||
|
public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; }
|
||||||
|
public String getNewClientKey() { return newClientKey; }
|
||||||
|
public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; }
|
||||||
|
public int getForkFromBlock() { return forkFromBlock; }
|
||||||
|
public void setForkFromBlock(int forkFromBlock) { this.forkFromBlock = forkFromBlock; }
|
||||||
|
public byte[] getForkFromHash() { return forkFromHash; }
|
||||||
|
public void setForkFromHash(byte[] forkFromHash) { this.forkFromHash = forkFromHash; }
|
||||||
|
public int getSourceTipBlock() { return sourceTipBlock; }
|
||||||
|
public void setSourceTipBlock(int sourceTipBlock) { this.sourceTipBlock = sourceTipBlock; }
|
||||||
|
public byte[] getSourceTipHash() { return sourceTipHash; }
|
||||||
|
public void setSourceTipHash(byte[] sourceTipHash) { this.sourceTipHash = sourceTipHash; }
|
||||||
|
public short getReasonCode() { return reasonCode; }
|
||||||
|
public void setReasonCode(short reasonCode) { this.reasonCode = reasonCode; }
|
||||||
|
public String getComment() { return comment; }
|
||||||
|
public void setComment(String comment) { this.comment = comment; }
|
||||||
|
public int getProgressCurrent() { return progressCurrent; }
|
||||||
|
public void setProgressCurrent(int progressCurrent) { this.progressCurrent = progressCurrent; }
|
||||||
|
public int getProgressTotal() { return progressTotal; }
|
||||||
|
public void setProgressTotal(int progressTotal) { this.progressTotal = progressTotal; }
|
||||||
|
public String getPdaRotationSignature() { return pdaRotationSignature; }
|
||||||
|
public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; }
|
||||||
|
public String getWalletMigrationStatus() { return walletMigrationStatus; }
|
||||||
|
public void setWalletMigrationStatus(String walletMigrationStatus) { this.walletMigrationStatus = walletMigrationStatus; }
|
||||||
|
public String getMessageMigrationStatus() { return messageMigrationStatus; }
|
||||||
|
public void setMessageMigrationStatus(String messageMigrationStatus) { this.messageMigrationStatus = messageMigrationStatus; }
|
||||||
|
public String getLastError() { return lastError; }
|
||||||
|
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||||
|
public Long getLastErrorAtMs() { return lastErrorAtMs; }
|
||||||
|
public void setLastErrorAtMs(Long lastErrorAtMs) { this.lastErrorAtMs = lastErrorAtMs; }
|
||||||
|
public int getRetryCount() { return retryCount; }
|
||||||
|
public void setRetryCount(int retryCount) { this.retryCount = retryCount; }
|
||||||
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||||
|
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||||
|
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||||
|
public Long getCompletedAtMs() { return completedAtMs; }
|
||||||
|
public void setCompletedAtMs(Long completedAtMs) { this.completedAtMs = completedAtMs; }
|
||||||
|
public Long getAbortedAtMs() { return abortedAtMs; }
|
||||||
|
public void setAbortedAtMs(Long abortedAtMs) { this.abortedAtMs = abortedAtMs; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояния серверной машины смены ключей.
|
||||||
|
*
|
||||||
|
* NONE используется только в solana_user_pda_current.rotation_status.
|
||||||
|
* COMPLETE/ABORTED остаются в истории key_rotation_sessions, после чего
|
||||||
|
* пользователь снова получает rotation_status=NONE.
|
||||||
|
*/
|
||||||
|
public enum KeyRotationStatus {
|
||||||
|
NONE,
|
||||||
|
COPYING_CHAIN,
|
||||||
|
CHAIN_READY,
|
||||||
|
ROTATING_PDA,
|
||||||
|
PDA_ROTATED,
|
||||||
|
REBUILDING_SERVER,
|
||||||
|
WALLET_MIGRATION,
|
||||||
|
MESSAGE_MIGRATION,
|
||||||
|
FINALIZING,
|
||||||
|
COMPLETE,
|
||||||
|
ABORTED;
|
||||||
|
|
||||||
|
public boolean isTerminalSessionStatus() {
|
||||||
|
return this == COMPLETE || this == ABORTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUserActiveStatus() {
|
||||||
|
return this != NONE && !isTerminalSessionStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Допустимые переходы одной ротации.
|
||||||
|
* Abort сознательно разрешён только до начала ROTATING_PDA: после отправки
|
||||||
|
* Solana-транзакции нельзя надёжно знать, не будет ли она подтверждена позже.
|
||||||
|
*/
|
||||||
|
public boolean canTransitionTo(KeyRotationStatus next) {
|
||||||
|
if (next == null) return false;
|
||||||
|
return switch (this) {
|
||||||
|
case COPYING_CHAIN -> EnumSet.of(CHAIN_READY, ABORTED).contains(next);
|
||||||
|
case CHAIN_READY -> EnumSet.of(ROTATING_PDA, ABORTED).contains(next);
|
||||||
|
case ROTATING_PDA -> next == PDA_ROTATED;
|
||||||
|
case PDA_ROTATED -> next == REBUILDING_SERVER;
|
||||||
|
case REBUILDING_SERVER -> next == WALLET_MIGRATION;
|
||||||
|
case WALLET_MIGRATION -> next == MESSAGE_MIGRATION;
|
||||||
|
case MESSAGE_MIGRATION -> next == FINALIZING;
|
||||||
|
case FINALIZING -> next == COMPLETE;
|
||||||
|
case NONE, COMPLETE, ABORTED -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Set<KeyRotationStatus> activeStatuses() {
|
||||||
|
return EnumSet.of(
|
||||||
|
COPYING_CHAIN,
|
||||||
|
CHAIN_READY,
|
||||||
|
ROTATING_PDA,
|
||||||
|
PDA_ROTATED,
|
||||||
|
REBUILDING_SERVER,
|
||||||
|
WALLET_MIGRATION,
|
||||||
|
MESSAGE_MIGRATION,
|
||||||
|
FINALIZING
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Статус, который должен храниться у пользователя для данной session-state. */
|
||||||
|
public KeyRotationStatus userStatus() {
|
||||||
|
return isTerminalSessionStatus() ? NONE : this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
-- v26: серверная state machine смены ключей пользователя.
|
||||||
|
--
|
||||||
|
-- Важно:
|
||||||
|
-- - rotation_status / rotation_session_id являются ЛОКАЛЬНЫМ runtime-состоянием сервера,
|
||||||
|
-- а не полями Solana PDA;
|
||||||
|
-- - Solana sync не должен перезаписывать эти колонки при upsert PDA;
|
||||||
|
-- - приватные ключи здесь никогда не хранятся, только публичные ключи ротации.
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
|
||||||
|
rotation_status IN (
|
||||||
|
'NONE',
|
||||||
|
'COPYING_CHAIN',
|
||||||
|
'CHAIN_READY',
|
||||||
|
'ROTATING_PDA',
|
||||||
|
'PDA_ROTATED',
|
||||||
|
'REBUILDING_SERVER',
|
||||||
|
'WALLET_MIGRATION',
|
||||||
|
'MESSAGE_MIGRATION',
|
||||||
|
'FINALIZING'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
|
||||||
|
ON solana_user_pda_current(rotation_status)
|
||||||
|
WHERE rotation_status <> 'NONE';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL CHECK (
|
||||||
|
status IN (
|
||||||
|
'COPYING_CHAIN',
|
||||||
|
'CHAIN_READY',
|
||||||
|
'ROTATING_PDA',
|
||||||
|
'PDA_ROTATED',
|
||||||
|
'REBUILDING_SERVER',
|
||||||
|
'WALLET_MIGRATION',
|
||||||
|
'MESSAGE_MIGRATION',
|
||||||
|
'FINALIZING',
|
||||||
|
'COMPLETE',
|
||||||
|
'ABORTED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
source_blockchain_name TEXT NOT NULL,
|
||||||
|
candidate_blockchain_name TEXT NOT NULL,
|
||||||
|
|
||||||
|
old_root_key TEXT NOT NULL,
|
||||||
|
old_blockchain_key TEXT NOT NULL,
|
||||||
|
old_client_key TEXT NOT NULL,
|
||||||
|
new_root_key TEXT NOT NULL,
|
||||||
|
new_blockchain_key TEXT NOT NULL,
|
||||||
|
new_client_key TEXT NOT NULL,
|
||||||
|
|
||||||
|
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
|
||||||
|
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
|
||||||
|
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
|
||||||
|
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
|
||||||
|
|
||||||
|
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
|
||||||
|
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
|
||||||
|
|
||||||
|
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
|
||||||
|
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
|
||||||
|
|
||||||
|
pda_rotation_signature TEXT,
|
||||||
|
|
||||||
|
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||||
|
wallet_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
|
||||||
|
),
|
||||||
|
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||||
|
message_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
|
||||||
|
),
|
||||||
|
|
||||||
|
last_error TEXT,
|
||||||
|
last_error_at_ms BIGINT,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||||
|
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
completed_at_ms BIGINT,
|
||||||
|
aborted_at_ms BIGINT,
|
||||||
|
|
||||||
|
CHECK (progress_current <= progress_total),
|
||||||
|
CHECK (fork_from_block <= source_tip_block)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- У одного login одновременно может существовать только одна незавершённая ротация.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
|
||||||
|
ON key_rotation_sessions(login)
|
||||||
|
WHERE status NOT IN ('COMPLETE', 'ABORTED');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
|
||||||
|
ON key_rotation_sessions(login, created_at_ms DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
|
||||||
|
ON key_rotation_sessions(status);
|
||||||
|
|
||||||
|
-- Быстрый указатель из текущего пользователя на активную/последнюю runtime-сессию.
|
||||||
|
-- FK добавляется после создания key_rotation_sessions, чтобы не создавать циклический DDL порядок.
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
|
||||||
|
FOREIGN KEY (rotation_session_id)
|
||||||
|
REFERENCES key_rotation_sessions(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
UPDATE db_schema_version
|
||||||
|
SET schema_version = 26,
|
||||||
|
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
WHERE id = 1;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
candidate_blockchain_name TEXT NOT NULL,
|
||||||
|
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||||
|
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
|
||||||
|
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
|
||||||
|
block_bytes BYTEA NOT NULL,
|
||||||
|
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
arweave_published_at_ms BIGINT,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
UNIQUE (rotation_session_id, block_number),
|
||||||
|
UNIQUE (rotation_session_id, data_item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
|
||||||
|
ON key_rotation_candidate_blocks(id)
|
||||||
|
WHERE arweave_publish_pending = TRUE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
|
||||||
|
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
|
||||||
|
|
||||||
|
UPDATE db_schema_version
|
||||||
|
SET schema_version = 27,
|
||||||
|
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||||
|
WHERE id = 1;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
|||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
VALUES (1, 24, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
VALUES (1, 26, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
schema_version = EXCLUDED.schema_version,
|
schema_version = EXCLUDED.schema_version,
|
||||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
@@ -2081,8 +2081,111 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
|
|||||||
ON solana_user_pda_current(is_server, archive_imported)
|
ON solana_user_pda_current(is_server, archive_imported)
|
||||||
WHERE archive_head_tx_id <> '';
|
WHERE archive_head_tx_id <> '';
|
||||||
|
|
||||||
|
-- Server-local key rotation state machine (schema v26).
|
||||||
|
-- Эти поля не являются частью Solana PDA и не перезаписываются Solana sync upsert-ом.
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
|
||||||
|
rotation_status IN (
|
||||||
|
'NONE','COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
|
||||||
|
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
|
||||||
|
ON solana_user_pda_current(rotation_status)
|
||||||
|
WHERE rotation_status <> 'NONE';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL CHECK (
|
||||||
|
status IN (
|
||||||
|
'COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
|
||||||
|
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING',
|
||||||
|
'COMPLETE','ABORTED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
source_blockchain_name TEXT NOT NULL,
|
||||||
|
candidate_blockchain_name TEXT NOT NULL,
|
||||||
|
old_root_key TEXT NOT NULL,
|
||||||
|
old_blockchain_key TEXT NOT NULL,
|
||||||
|
old_client_key TEXT NOT NULL,
|
||||||
|
new_root_key TEXT NOT NULL,
|
||||||
|
new_blockchain_key TEXT NOT NULL,
|
||||||
|
new_client_key TEXT NOT NULL,
|
||||||
|
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
|
||||||
|
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
|
||||||
|
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
|
||||||
|
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
|
||||||
|
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
|
||||||
|
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
|
||||||
|
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
|
||||||
|
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
|
||||||
|
pda_rotation_signature TEXT,
|
||||||
|
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||||
|
wallet_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
|
||||||
|
),
|
||||||
|
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||||
|
message_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
|
||||||
|
),
|
||||||
|
last_error TEXT,
|
||||||
|
last_error_at_ms BIGINT,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
completed_at_ms BIGINT,
|
||||||
|
aborted_at_ms BIGINT,
|
||||||
|
CHECK (progress_current <= progress_total),
|
||||||
|
CHECK (fork_from_block <= source_tip_block)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
|
||||||
|
ON key_rotation_sessions(login)
|
||||||
|
WHERE status NOT IN ('COMPLETE','ABORTED');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
|
||||||
|
ON key_rotation_sessions(login, created_at_ms DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
|
||||||
|
ON key_rotation_sessions(status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
candidate_blockchain_name TEXT NOT NULL,
|
||||||
|
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||||
|
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
|
||||||
|
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
|
||||||
|
block_bytes BYTEA NOT NULL,
|
||||||
|
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
arweave_published_at_ms BIGINT,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
UNIQUE (rotation_session_id, block_number),
|
||||||
|
UNIQUE (rotation_session_id, data_item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
|
||||||
|
ON key_rotation_candidate_blocks(id)
|
||||||
|
WHERE arweave_publish_pending = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
|
||||||
|
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
|
||||||
|
FOREIGN KEY (rotation_session_id)
|
||||||
|
REFERENCES key_rotation_sessions(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||||
VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
VALUES(1,27,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||||
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
+37
@@ -42,8 +42,25 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing
|
|||||||
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetMyBlockchain_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStart_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAddBlock_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationFinishChain_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationRotatePda_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStatus_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAbort_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationContinue_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request;
|
||||||
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
||||||
@@ -191,6 +208,16 @@ public final class JsonHandlerRegistry {
|
|||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||||
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
||||||
|
Map.entry("GetMyBlockchain", new Net_GetMyBlockchain_Handler()),
|
||||||
|
|
||||||
|
// --- key rotation ---
|
||||||
|
Map.entry("KeyRotationStart", new Net_KeyRotationStart_Handler()),
|
||||||
|
Map.entry("KeyRotationStatus", new Net_KeyRotationStatus_Handler()),
|
||||||
|
Map.entry("KeyRotationAddBlock", new Net_KeyRotationAddBlock_Handler()),
|
||||||
|
Map.entry("KeyRotationFinishChain", new Net_KeyRotationFinishChain_Handler()),
|
||||||
|
Map.entry("KeyRotationRotatePda", new Net_KeyRotationRotatePda_Handler()),
|
||||||
|
Map.entry("KeyRotationContinue", new Net_KeyRotationContinue_Handler()),
|
||||||
|
Map.entry("KeyRotationAbort", new Net_KeyRotationAbort_Handler()),
|
||||||
|
|
||||||
// --- userParams ---
|
// --- userParams ---
|
||||||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||||||
@@ -283,6 +310,16 @@ public final class JsonHandlerRegistry {
|
|||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
||||||
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
|
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
|
||||||
|
Map.entry("GetMyBlockchain", Net_GetMyBlockchain_Request.class),
|
||||||
|
|
||||||
|
// --- key rotation ---
|
||||||
|
Map.entry("KeyRotationStart", Net_KeyRotationStart_Request.class),
|
||||||
|
Map.entry("KeyRotationStatus", Net_KeyRotationStatus_Request.class),
|
||||||
|
Map.entry("KeyRotationAddBlock", Net_KeyRotationAddBlock_Request.class),
|
||||||
|
Map.entry("KeyRotationFinishChain", Net_KeyRotationFinishChain_Request.class),
|
||||||
|
Map.entry("KeyRotationRotatePda", Net_KeyRotationRotatePda_Request.class),
|
||||||
|
Map.entry("KeyRotationContinue", Net_KeyRotationContinue_Request.class),
|
||||||
|
Map.entry("KeyRotationAbort", Net_KeyRotationAbort_Request.class),
|
||||||
|
|
||||||
// --- userParams ---
|
// --- userParams ---
|
||||||
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
||||||
|
|||||||
+31
@@ -98,6 +98,18 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||||
lock.lock();
|
lock.lock();
|
||||||
try {
|
try {
|
||||||
|
try {
|
||||||
|
if (isKeyRotationActive(blockchainName)) {
|
||||||
|
BlockchainStateEntry currentState = stateDAO.getByBlockchainName(blockchainName);
|
||||||
|
int lastNum = currentState != null ? currentState.getLastBlockNumber() : -1;
|
||||||
|
String lastHash = currentState != null ? toHex(currentState.getLastBlockHash()) : "";
|
||||||
|
return error(req, 423, "key_rotation_in_progress", lastNum, lastHash);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("AddBlock: не удалось проверить key rotation status для {}", blockchainName, e);
|
||||||
|
return error(req, WireCodes.Status.INTERNAL_ERROR, "key_rotation_check_failed", -1, "");
|
||||||
|
}
|
||||||
|
|
||||||
AddBlockResult r = addBlock(
|
AddBlockResult r = addBlock(
|
||||||
blockchainName,
|
blockchainName,
|
||||||
req.getBlockNumber(), // старое поле, пока оставляем
|
req.getBlockNumber(), // старое поле, пока оставляем
|
||||||
@@ -152,6 +164,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isKeyRotationActive(String blockchainName) throws java.sql.SQLException {
|
||||||
|
String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName);
|
||||||
|
if (login == null || login.isBlank()) return false;
|
||||||
|
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||||
|
PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT rotation_status
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
WHERE normalized_login = LOWER(BTRIM(?))
|
||||||
|
LIMIT 1
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() && !"NONE".equals(rs.getString(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String humanMessage(String code) {
|
private static String humanMessage(String code) {
|
||||||
if (code == null) return "Ошибка добавления блока";
|
if (code == null) return "Ошибка добавления блока";
|
||||||
return switch (code) {
|
return switch (code) {
|
||||||
@@ -184,6 +213,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
|
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
|
||||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||||
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||||
|
case "key_rotation_in_progress" -> "Сейчас выполняется смена ключей; обычные новые блоки временно запрещены";
|
||||||
|
case "key_rotation_check_failed" -> "Не удалось проверить состояние смены ключей";
|
||||||
default -> "Ошибка: " + code;
|
default -> "Ошибка: " + code;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain;
|
||||||
|
|
||||||
|
import blockchain.BchBlockEntry;
|
||||||
|
import blockchain.body.BodyHasTarget;
|
||||||
|
import server.logic.ws_protocol.Base64Ws;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.SolanaUserPdaCurrentDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Authenticated, paginated current-chain view used by "Мой блокчейн" and key rotation. */
|
||||||
|
public final class Net_GetMyBlockchain_Handler implements JsonMessageHandler {
|
||||||
|
private final BlocksDAO blocks = BlocksDAO.getInstance();
|
||||||
|
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
|
||||||
|
private final SolanaUserPdaCurrentDAO users = SolanaUserPdaCurrentDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_GetMyBlockchain_Request req = (Net_GetMyBlockchain_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
SolanaUserPdaCurrentEntry user = users.getByLogin(login);
|
||||||
|
if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "Текущий блокчейн пользователя не найден");
|
||||||
|
}
|
||||||
|
String bch = user.getBlockchainName();
|
||||||
|
BlockchainStateEntry state = states.getByBlockchainName(bch);
|
||||||
|
int tip = state == null ? -1 : state.getLastBlockNumber();
|
||||||
|
String tipHash = state == null ? null : hex(state.getLastBlockHash());
|
||||||
|
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(100, req.getLimit()));
|
||||||
|
int before = req.getBeforeBlock() == null || req.getBeforeBlock() < 0 ? tip : Math.min(tip, req.getBeforeBlock());
|
||||||
|
boolean includeBytes = Boolean.TRUE.equals(req.getIncludeBlockBytes());
|
||||||
|
|
||||||
|
List<BlockEntry> rows = before < 0
|
||||||
|
? List.of()
|
||||||
|
: blocks.listRangeByNumber(bch, Math.max(0, before - limit + 1), before);
|
||||||
|
Collections.reverse(rows);
|
||||||
|
List<Net_GetMyBlockchain_Response.BlockItem> items = new ArrayList<>(rows.size());
|
||||||
|
for (BlockEntry row : rows) {
|
||||||
|
BchBlockEntry parsed = new BchBlockEntry(row.getBlockBytes());
|
||||||
|
Net_GetMyBlockchain_Response.BlockItem item = new Net_GetMyBlockchain_Response.BlockItem();
|
||||||
|
item.setBlockNumber(row.getBlockNumber());
|
||||||
|
item.setBlockHash(hex(row.getBlockHash()));
|
||||||
|
item.setPrevBlockHash(hex(parsed.prevHash32));
|
||||||
|
item.setTimestampMs(parsed.timestamp * 1000L);
|
||||||
|
item.setMsgType(Short.toUnsignedInt(parsed.type));
|
||||||
|
item.setMsgSubType(Short.toUnsignedInt(parsed.subType));
|
||||||
|
item.setMsgVersion(Short.toUnsignedInt(parsed.version));
|
||||||
|
if (parsed.body instanceof BodyHasTarget target) {
|
||||||
|
item.setToLogin(target.toLogin());
|
||||||
|
item.setToBlockNumber(target.toBlockGlobalNumber());
|
||||||
|
item.setToBlockHash(hex(target.toBlockHashBytes()));
|
||||||
|
}
|
||||||
|
if (includeBytes) item.setBlockBytesB64(Base64Ws.encode(row.getBlockBytes()));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_GetMyBlockchain_Response resp = new Net_GetMyBlockchain_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setLogin(login);
|
||||||
|
resp.setBlockchainName(bch);
|
||||||
|
resp.setTipBlockNumber(tip);
|
||||||
|
resp.setTipBlockHash(tipHash);
|
||||||
|
resp.setBlocks(items);
|
||||||
|
int lowest = rows.isEmpty() ? -1 : rows.get(rows.size() - 1).getBlockNumber();
|
||||||
|
resp.setNextBeforeBlock(lowest > 0 ? lowest - 1 : null);
|
||||||
|
return resp;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "GET_MY_BLOCKCHAIN_FAILED", "Не удалось прочитать блокчейн пользователя");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String hex(byte[] bytes) {
|
||||||
|
if (bytes == null) return null;
|
||||||
|
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||||
|
for (byte b : bytes) sb.append(String.format("%02x", b & 0xff));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/** Paginated read of the authenticated user's current materialized blockchain. */
|
||||||
|
public final class Net_GetMyBlockchain_Request extends Net_Request {
|
||||||
|
/** Inclusive highest block number to return. Null/<0 means current tip. */
|
||||||
|
private Integer beforeBlock;
|
||||||
|
/** 1..100, default 50. */
|
||||||
|
private Integer limit;
|
||||||
|
/** Include complete serialized ANS-104 DataItem for rotation/UI inspection. */
|
||||||
|
private Boolean includeBlockBytes;
|
||||||
|
|
||||||
|
public Integer getBeforeBlock() { return beforeBlock; }
|
||||||
|
public void setBeforeBlock(Integer beforeBlock) { this.beforeBlock = beforeBlock; }
|
||||||
|
public Integer getLimit() { return limit; }
|
||||||
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
public Boolean getIncludeBlockBytes() { return includeBlockBytes; }
|
||||||
|
public void setIncludeBlockBytes(Boolean includeBlockBytes) { this.includeBlockBytes = includeBlockBytes; }
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class Net_GetMyBlockchain_Response extends Net_Response {
|
||||||
|
private String login;
|
||||||
|
private String blockchainName;
|
||||||
|
private int tipBlockNumber;
|
||||||
|
private String tipBlockHash;
|
||||||
|
private Integer nextBeforeBlock;
|
||||||
|
private List<BlockItem> blocks = new ArrayList<>();
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public String getBlockchainName() { return blockchainName; }
|
||||||
|
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||||
|
public int getTipBlockNumber() { return tipBlockNumber; }
|
||||||
|
public void setTipBlockNumber(int tipBlockNumber) { this.tipBlockNumber = tipBlockNumber; }
|
||||||
|
public String getTipBlockHash() { return tipBlockHash; }
|
||||||
|
public void setTipBlockHash(String tipBlockHash) { this.tipBlockHash = tipBlockHash; }
|
||||||
|
public Integer getNextBeforeBlock() { return nextBeforeBlock; }
|
||||||
|
public void setNextBeforeBlock(Integer nextBeforeBlock) { this.nextBeforeBlock = nextBeforeBlock; }
|
||||||
|
public List<BlockItem> getBlocks() { return blocks; }
|
||||||
|
public void setBlocks(List<BlockItem> blocks) { this.blocks = blocks; }
|
||||||
|
|
||||||
|
public static final class BlockItem {
|
||||||
|
private int blockNumber;
|
||||||
|
private String blockHash;
|
||||||
|
private String prevBlockHash;
|
||||||
|
private long timestampMs;
|
||||||
|
private int msgType;
|
||||||
|
private int msgSubType;
|
||||||
|
private int msgVersion;
|
||||||
|
private String toLogin;
|
||||||
|
private Integer toBlockNumber;
|
||||||
|
private String toBlockHash;
|
||||||
|
private String blockBytesB64;
|
||||||
|
|
||||||
|
public int getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
public String getBlockHash() { return blockHash; }
|
||||||
|
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||||
|
public String getPrevBlockHash() { return prevBlockHash; }
|
||||||
|
public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; }
|
||||||
|
public long getTimestampMs() { return timestampMs; }
|
||||||
|
public void setTimestampMs(long timestampMs) { this.timestampMs = timestampMs; }
|
||||||
|
public int getMsgType() { return msgType; }
|
||||||
|
public void setMsgType(int msgType) { this.msgType = msgType; }
|
||||||
|
public int getMsgSubType() { return msgSubType; }
|
||||||
|
public void setMsgSubType(int msgSubType) { this.msgSubType = msgSubType; }
|
||||||
|
public int getMsgVersion() { return msgVersion; }
|
||||||
|
public void setMsgVersion(int msgVersion) { this.msgVersion = msgVersion; }
|
||||||
|
public String getToLogin() { return toLogin; }
|
||||||
|
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||||
|
public Integer getToBlockNumber() { return toBlockNumber; }
|
||||||
|
public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; }
|
||||||
|
public String getToBlockHash() { return toBlockHash; }
|
||||||
|
public void setToBlockHash(String toBlockHash) { this.toBlockHash = toBlockHash; }
|
||||||
|
public String getBlockBytesB64() { return blockBytesB64; }
|
||||||
|
public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-6
@@ -105,7 +105,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
|||||||
String order = asc ? "ASC" : "DESC";
|
String order = asc ? "ASC" : "DESC";
|
||||||
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
|
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
|
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type,
|
||||||
|
to_login, to_bch_name, to_block_number, to_block_hash
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE login = ? AND msg_type = ?
|
WHERE login = ? AND msg_type = ?
|
||||||
ORDER BY block_number
|
ORDER BY block_number
|
||||||
@@ -132,9 +133,9 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
|||||||
item.setLikedByMe(false);
|
item.setLikedByMe(false);
|
||||||
item.setRepliesCount(0);
|
item.setRepliesCount(0);
|
||||||
item.setRatingsCount(0);
|
item.setRatingsCount(0);
|
||||||
item.setTargetBlockchainName(statusBody.toBchName());
|
item.setTargetBlockchainName(rs.getString("to_bch_name"));
|
||||||
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber());
|
item.setTargetBlockNumber((Integer) rs.getObject("to_block_number"));
|
||||||
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes()));
|
item.setTargetBlockHash(ChannelsReadSupport.toHex(rs.getBytes("to_block_hash")));
|
||||||
|
|
||||||
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
|
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
|
||||||
c,
|
c,
|
||||||
@@ -148,7 +149,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
|||||||
item.setVersionsTotal(versions.size());
|
item.setVersionsTotal(versions.size());
|
||||||
item.setText(versions.get(versions.size() - 1).getText());
|
item.setText(versions.get(versions.size() - 1).getText());
|
||||||
|
|
||||||
fillTargetDetails(c, item, statusBody.toBchName(), statusBody.toBlockGlobalNumber(), statusBody.toBlockHashBytes());
|
fillTargetDetails(c, item, rs.getString("to_bch_name"),
|
||||||
|
(Integer) rs.getObject("to_block_number"), rs.getBytes("to_block_hash"));
|
||||||
out.add(item);
|
out.add(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,7 +217,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
|
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type,
|
||||||
|
to_login, to_bch_name, to_block_number, to_block_hash
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name = ? AND block_number = ?
|
WHERE bch_name = ? AND block_number = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationState_Response;
|
||||||
|
import shine.db.KeyEncodingUtil;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
final class KeyRotationApiSupport {
|
||||||
|
private KeyRotationApiSupport() {}
|
||||||
|
|
||||||
|
static String normalizePublicKey32(String raw) {
|
||||||
|
String normalized = KeyEncodingUtil.normalizeKeyToBase64_32(raw);
|
||||||
|
if (normalized == null || normalized.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("public key is empty");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] decoded = Base64.getDecoder().decode(normalized);
|
||||||
|
if (decoded.length != 32) throw new IllegalArgumentException("public key must be 32 bytes");
|
||||||
|
return Base64.getEncoder().encodeToString(decoded);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new IllegalArgumentException("public key must be Base58/Base64 of 32 bytes", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] parseHash32(String hex) {
|
||||||
|
if (hex == null) throw new IllegalArgumentException("hash is null");
|
||||||
|
String s = hex.trim();
|
||||||
|
if (s.length() != 64) throw new IllegalArgumentException("hash must contain 64 hex chars");
|
||||||
|
byte[] out = new byte[32];
|
||||||
|
for (int i = 0; i < 32; i++) {
|
||||||
|
int hi = Character.digit(s.charAt(i * 2), 16);
|
||||||
|
int lo = Character.digit(s.charAt(i * 2 + 1), 16);
|
||||||
|
if (hi < 0 || lo < 0) throw new IllegalArgumentException("hash contains non-hex chars");
|
||||||
|
out[i] = (byte) ((hi << 4) | lo);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String toHex(byte[] bytes) {
|
||||||
|
if (bytes == null) return null;
|
||||||
|
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||||
|
for (byte b : bytes) sb.append(String.format("%02x", b & 0xff));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String nextBlockchainName(String login, String source) {
|
||||||
|
if (login == null || login.isBlank() || source == null || source.length() < 5) {
|
||||||
|
throw new IllegalArgumentException("bad source blockchain name");
|
||||||
|
}
|
||||||
|
String prefix = login + "-";
|
||||||
|
if (!source.startsWith(prefix) || source.length() != prefix.length() + 3) {
|
||||||
|
throw new IllegalArgumentException("source blockchain name does not match login");
|
||||||
|
}
|
||||||
|
String suffix = source.substring(prefix.length());
|
||||||
|
if (!suffix.chars().allMatch(Character::isDigit)) {
|
||||||
|
throw new IllegalArgumentException("bad blockchain suffix");
|
||||||
|
}
|
||||||
|
int n = Integer.parseInt(suffix);
|
||||||
|
if (n >= 999) throw new IllegalArgumentException("blockchain fork limit reached");
|
||||||
|
return login + "-" + String.format("%03d", n + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void requireKeysChangedAndDistinct(String oldRoot, String oldBlockchain, String oldClient,
|
||||||
|
String newRoot, String newBlockchain, String newClient) {
|
||||||
|
java.util.Set<String> oldKeys = java.util.Set.of(oldRoot, oldBlockchain, oldClient);
|
||||||
|
java.util.Set<String> newKeys = java.util.Set.of(newRoot, newBlockchain, newClient);
|
||||||
|
if (newKeys.size() != 3) {
|
||||||
|
throw new IllegalArgumentException("new root/blockchain/client keys must be distinct");
|
||||||
|
}
|
||||||
|
for (String newKey : newKeys) {
|
||||||
|
if (oldKeys.contains(newKey)) {
|
||||||
|
throw new IllegalArgumentException("new keys must not reuse any key from the old key set");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean hashesEqual(byte[] a, byte[] b) {
|
||||||
|
return Arrays.equals(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Net_KeyRotationState_Response response(String op, String requestId, KeyRotationSessionEntry e) {
|
||||||
|
Net_KeyRotationState_Response r = new Net_KeyRotationState_Response();
|
||||||
|
r.setOp(op);
|
||||||
|
r.setRequestId(requestId);
|
||||||
|
r.setStatus(200);
|
||||||
|
if (e == null) {
|
||||||
|
r.setRotationStatus(KeyRotationStatus.NONE.name());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
r.setRotationSessionId(e.getId());
|
||||||
|
r.setRotationStatus(e.getStatus().name());
|
||||||
|
r.setSourceBlockchainName(e.getSourceBlockchainName());
|
||||||
|
r.setCandidateBlockchainName(e.getCandidateBlockchainName());
|
||||||
|
r.setOldRootKey(e.getOldRootKey());
|
||||||
|
r.setOldBlockchainKey(e.getOldBlockchainKey());
|
||||||
|
r.setOldClientKey(e.getOldClientKey());
|
||||||
|
r.setNewRootKey(e.getNewRootKey());
|
||||||
|
r.setNewBlockchainKey(e.getNewBlockchainKey());
|
||||||
|
r.setNewClientKey(e.getNewClientKey());
|
||||||
|
r.setForkFromBlock(e.getForkFromBlock());
|
||||||
|
r.setForkFromHash(toHex(e.getForkFromHash()));
|
||||||
|
r.setSourceTipBlock(e.getSourceTipBlock());
|
||||||
|
r.setSourceTipHash(toHex(e.getSourceTipHash()));
|
||||||
|
r.setReasonCode(e.getReasonCode());
|
||||||
|
r.setComment(e.getComment());
|
||||||
|
r.setProgressCurrent(e.getProgressCurrent());
|
||||||
|
r.setProgressTotal(e.getProgressTotal());
|
||||||
|
r.setPdaRotationSignature(e.getPdaRotationSignature());
|
||||||
|
r.setWalletMigrationStatus(e.getWalletMigrationStatus());
|
||||||
|
r.setMessageMigrationStatus(e.getMessageMigrationStatus());
|
||||||
|
r.setLastError(e.getLastError());
|
||||||
|
r.setRetryCount(e.getRetryCount());
|
||||||
|
r.setCreatedAtMs(e.getCreatedAtMs());
|
||||||
|
r.setUpdatedAtMs(e.getUpdatedAtMs());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
/** Прерывание разрешено только пока Solana-транзакция ротации ещё не могла быть отправлена. */
|
||||||
|
public final class Net_KeyRotationAbort_Handler implements JsonMessageHandler {
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationAbort_Request req = (Net_KeyRotationAbort_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
|
||||||
|
if (current == null) return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), null);
|
||||||
|
if (current.getStatus() != KeyRotationStatus.COPYING_CHAIN && current.getStatus() != KeyRotationStatus.CHAIN_READY) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_ABORT_TOO_LATE",
|
||||||
|
"После начала ротации PDA процесс можно только завершить");
|
||||||
|
}
|
||||||
|
KeyRotationSessionEntry aborted = rotations.transition(current.getId(), current.getStatus(), KeyRotationStatus.ABORTED);
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), aborted);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ABORT_FAILED", "Не удалось прервать смену ключей");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
import blockchain.BchBlockEntry;
|
||||||
|
import blockchain.BchCryptoVerifier;
|
||||||
|
import blockchain.MsgSubType;
|
||||||
|
import blockchain.body.ForkBody;
|
||||||
|
import server.logic.ws_protocol.Base64Ws;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationCandidateBlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Принимает DataItem будущего fork во время COPYING_CHAIN.
|
||||||
|
* Candidate-блок сохраняется отдельно от blocks и не влияет на materialized state.
|
||||||
|
*/
|
||||||
|
public final class Net_KeyRotationAddBlock_Handler implements JsonMessageHandler {
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
|
||||||
|
private final BlocksDAO blocks = BlocksDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationAddBlock_Request req = (Net_KeyRotationAddBlock_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
if (req.getBlockNumber() < 0 || req.getBlockBytesB64() == null || req.getBlockBytesB64().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "blockNumber и blockBytesB64 обязательны");
|
||||||
|
}
|
||||||
|
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
KeyRotationSessionEntry initial;
|
||||||
|
try {
|
||||||
|
initial = rotations.getActiveByLogin(login);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_DB_ERROR", "Не удалось прочитать состояние смены ключей");
|
||||||
|
}
|
||||||
|
if (initial == null || initial.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация не находится на этапе COPYING_CHAIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
ReentrantLock lock = BlockchainLocks.lockFor(initial.getCandidateBlockchainName());
|
||||||
|
lock.lock();
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
KeyRotationSessionEntry session = rotations.getActiveByLogin(c, login);
|
||||||
|
if (session == null || session.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация больше не находится на этапе COPYING_CHAIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
int stored = candidates.countStored(c, session.getId());
|
||||||
|
if (req.getBlockNumber() < stored) {
|
||||||
|
KeyRotationCandidateBlockEntry existing = candidates.getByNumber(c, session.getId(), req.getBlockNumber());
|
||||||
|
if (existing == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CANDIDATE_GAP", "Нарушена последовательность candidate-блоков");
|
||||||
|
}
|
||||||
|
BchBlockEntry repeated;
|
||||||
|
try { repeated = new BchBlockEntry(Base64Ws.decode(req.getBlockBytesB64())); }
|
||||||
|
catch (Exception e) { return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "Не удалось распарсить DataItem"); }
|
||||||
|
if (!Arrays.equals(existing.getBlockHash(), repeated.getHash32())
|
||||||
|
|| !Arrays.equals(existing.getDataItemId(), repeated.getDataItemId32())) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_CONFLICT", "На этом номере уже сохранён другой candidate-блок");
|
||||||
|
}
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), session);
|
||||||
|
}
|
||||||
|
if (req.getBlockNumber() != stored) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_OUT_OF_ORDER", "Candidate-блоки нужно добавлять последовательно с block 0");
|
||||||
|
}
|
||||||
|
if (stored >= session.getProgressTotal()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_ALREADY_COMPLETE", "Все candidate-блоки уже приняты сервером");
|
||||||
|
}
|
||||||
|
|
||||||
|
final byte[] raw;
|
||||||
|
final BchBlockEntry block;
|
||||||
|
try {
|
||||||
|
raw = Base64Ws.decode(req.getBlockBytesB64());
|
||||||
|
block = new BchBlockEntry(raw);
|
||||||
|
block.body.check();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "Некорректный SHiNE/ANS-104 блок");
|
||||||
|
}
|
||||||
|
if (block.blockNumber != req.getBlockNumber()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BLOCK_NUMBER_MISMATCH", "blockNumber запроса не совпадает с блоком");
|
||||||
|
}
|
||||||
|
if (!block.getDataItem().hasTag("App", "test5590")) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_APP_TAG", "Отсутствует App=test5590");
|
||||||
|
}
|
||||||
|
if (!requestPrevHashMatches(req.getPrevBlockHash(), block.prevHash32)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_PREV_HASH_MISMATCH", "prevBlockHash запроса не совпадает с блоком");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] newBlockchainKey;
|
||||||
|
try {
|
||||||
|
newBlockchainKey = Base64.getDecoder().decode(session.getNewBlockchainKey());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_BAD_STORED_KEY", "Некорректный new blockchain public key в rotation session");
|
||||||
|
}
|
||||||
|
if (newBlockchainKey.length != 32 || !BchCryptoVerifier.verifyBlock(block, newBlockchainKey)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_SIGNATURE", "Candidate DataItem должен быть подписан новым blockchain key");
|
||||||
|
}
|
||||||
|
|
||||||
|
String validationError;
|
||||||
|
try {
|
||||||
|
validationError = validateCandidate(c, session, block);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_VALIDATE_FAILED", "Не удалось проверить candidate-блок");
|
||||||
|
}
|
||||||
|
if (validationError != null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, validationError, "Candidate-блок не соответствует выбранному fork");
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyRotationCandidateBlockEntry entry = new KeyRotationCandidateBlockEntry();
|
||||||
|
entry.setRotationSessionId(session.getId());
|
||||||
|
entry.setLogin(login);
|
||||||
|
entry.setCandidateBlockchainName(session.getCandidateBlockchainName());
|
||||||
|
entry.setBlockNumber(block.blockNumber);
|
||||||
|
entry.setBlockHash(block.getHash32());
|
||||||
|
entry.setDataItemId(block.getDataItemId32());
|
||||||
|
entry.setBlockBytes(raw);
|
||||||
|
entry.setCreatedAtMs(System.currentTimeMillis());
|
||||||
|
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
candidates.insertOrGet(c, entry);
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_CONFLICT", "Не удалось сохранить candidate-блок");
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// progressCurrent обновляет Arweave publisher только после реальной публикации.
|
||||||
|
KeyRotationSessionEntry refreshed = rotations.getActiveByLogin(login);
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), refreshed == null ? session : refreshed);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ADD_BLOCK_FAILED", "Не удалось принять candidate-блок");
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateCandidate(Connection c, KeyRotationSessionEntry session, BchBlockEntry candidate) throws Exception {
|
||||||
|
if (candidate.blockNumber <= session.getForkFromBlock()) {
|
||||||
|
BlockEntry sourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), candidate.blockNumber);
|
||||||
|
if (sourceEntry == null || sourceEntry.getBlockBytes() == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
|
||||||
|
BchBlockEntry source = new BchBlockEntry(sourceEntry.getBlockBytes());
|
||||||
|
|
||||||
|
// Перепубликуется тот же Frame и те же ANS-104 tags/target/anchor; меняются только owner/signature/DataItem id.
|
||||||
|
if (!Arrays.equals(source.getFrameBytes(), candidate.getFrameBytes())) return "KEY_ROTATION_FRAME_MISMATCH";
|
||||||
|
if (!Arrays.equals(source.getDataItem().rawTags(), candidate.getDataItem().rawTags())) return "KEY_ROTATION_TAGS_MISMATCH";
|
||||||
|
if (!Arrays.equals(source.getDataItem().target(), candidate.getDataItem().target())) return "KEY_ROTATION_TARGET_MISMATCH";
|
||||||
|
if (!Arrays.equals(source.getDataItem().anchor(), candidate.getDataItem().anchor())) return "KEY_ROTATION_ANCHOR_MISMATCH";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int techForkNumber = session.getForkFromBlock() + 1;
|
||||||
|
if (candidate.blockNumber != techForkNumber) return "KEY_ROTATION_UNEXPECTED_BLOCK_NUMBER";
|
||||||
|
if ((candidate.type & 0xFFFF) != 0
|
||||||
|
|| (candidate.subType & 0xFFFF) != (MsgSubType.TECH_FORK & 0xFFFF)
|
||||||
|
|| !(candidate.body instanceof ForkBody fork)) {
|
||||||
|
return "KEY_ROTATION_TECH_FORK_REQUIRED";
|
||||||
|
}
|
||||||
|
if (!Arrays.equals(candidate.prevHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_PREV_HASH";
|
||||||
|
|
||||||
|
BlockEntry forkSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getForkFromBlock());
|
||||||
|
BlockEntry tipSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getSourceTipBlock());
|
||||||
|
if (forkSourceEntry == null || tipSourceEntry == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
|
||||||
|
BchBlockEntry forkSource = new BchBlockEntry(forkSourceEntry.getBlockBytes());
|
||||||
|
BchBlockEntry tipSource = new BchBlockEntry(tipSourceEntry.getBlockBytes());
|
||||||
|
|
||||||
|
byte[] oldBlockchain = Base64.getDecoder().decode(session.getOldBlockchainKey());
|
||||||
|
if (!Arrays.equals(fork.parentBlockchainKey32, oldBlockchain)) return "KEY_ROTATION_TECH_FORK_PARENT_KEY";
|
||||||
|
if (fork.forkPointBlockNumber != session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_POINT";
|
||||||
|
if (!Arrays.equals(fork.forkPointBlockHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_POINT_HASH";
|
||||||
|
if (fork.forkPointTimestampMs != Math.multiplyExact(forkSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_POINT_TIME";
|
||||||
|
if (fork.parentTipBlockNumber != session.getSourceTipBlock()) return "KEY_ROTATION_TECH_FORK_TIP";
|
||||||
|
if (!Arrays.equals(fork.parentTipBlockHash32, session.getSourceTipHash())) return "KEY_ROTATION_TECH_FORK_TIP_HASH";
|
||||||
|
if (fork.parentTipTimestampMs != Math.multiplyExact(tipSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_TIP_TIME";
|
||||||
|
if (fork.discardedBlocksCount != session.getSourceTipBlock() - session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_DISCARDED";
|
||||||
|
if (fork.reasonCode != session.getReasonCode()) return "KEY_ROTATION_TECH_FORK_REASON";
|
||||||
|
if (!normalizeComment(fork.comment).equals(normalizeComment(session.getComment()))) return "KEY_ROTATION_TECH_FORK_COMMENT";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean requestPrevHashMatches(String raw, byte[] actual) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return isZero32(actual);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Arrays.equals(KeyRotationApiSupport.parseHash32(raw), actual);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isZero32(byte[] value) {
|
||||||
|
if (value == null || value.length != 32) return false;
|
||||||
|
for (byte b : value) if (b != 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeComment(String value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сейчас wallet/DM migration — явные заглушки. Continue фиксирует NOT_IMPLEMENTED
|
||||||
|
* и переводит state machine дальше, не делая вид, что данные были реально мигрированы.
|
||||||
|
*/
|
||||||
|
public final class Net_KeyRotationContinue_Handler implements JsonMessageHandler {
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationContinue_Request req = (Net_KeyRotationContinue_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
|
||||||
|
if (current == null) return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), null);
|
||||||
|
|
||||||
|
if (current.getStatus() == KeyRotationStatus.WALLET_MIGRATION) {
|
||||||
|
rotations.setMigrationSubStatus(current.getId(), "wallet_migration_status", "NOT_IMPLEMENTED");
|
||||||
|
current = rotations.transition(current.getId(), KeyRotationStatus.WALLET_MIGRATION, KeyRotationStatus.MESSAGE_MIGRATION);
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
|
||||||
|
}
|
||||||
|
if (current.getStatus() == KeyRotationStatus.MESSAGE_MIGRATION) {
|
||||||
|
rotations.setMigrationSubStatus(current.getId(), "message_migration_status", "NOT_IMPLEMENTED");
|
||||||
|
current = rotations.transition(current.getId(), KeyRotationStatus.MESSAGE_MIGRATION, KeyRotationStatus.FINALIZING);
|
||||||
|
// FINALIZING пока не содержит отдельной пользовательской работы: сразу завершаем.
|
||||||
|
current = rotations.transition(current.getId(), KeyRotationStatus.FINALIZING, KeyRotationStatus.COMPLETE);
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
|
||||||
|
}
|
||||||
|
if (current.getStatus() == KeyRotationStatus.FINALIZING) {
|
||||||
|
current = rotations.transition(current.getId(), KeyRotationStatus.FINALIZING, KeyRotationStatus.COMPLETE);
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
|
||||||
|
}
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CONTINUE_NOT_ALLOWED",
|
||||||
|
"На текущем этапе продолжение этой операцией не требуется");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_CONTINUE_FAILED",
|
||||||
|
"Не удалось продолжить смену ключей");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
import blockchain.BchBlockEntry;
|
||||||
|
import blockchain.BchCryptoVerifier;
|
||||||
|
import blockchain.MsgSubType;
|
||||||
|
import blockchain.body.ForkBody;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationCandidateBlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, что candidate-chain полностью сохранена и опубликована в Arweave/Turbo,
|
||||||
|
* затем переводит ротацию COPYING_CHAIN -> CHAIN_READY.
|
||||||
|
*/
|
||||||
|
public final class Net_KeyRotationFinishChain_Handler implements JsonMessageHandler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_KeyRotationFinishChain_Handler.class);
|
||||||
|
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
|
||||||
|
private final BlocksDAO blocks = BlocksDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationFinishChain_Request req = (Net_KeyRotationFinishChain_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
|
||||||
|
KeyRotationSessionEntry initial;
|
||||||
|
try {
|
||||||
|
initial = rotations.getActiveByLogin(login);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_DB_ERROR", "Не удалось прочитать состояние смены ключей");
|
||||||
|
}
|
||||||
|
if (initial == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
|
||||||
|
}
|
||||||
|
if (initial.getStatus() == KeyRotationStatus.CHAIN_READY) {
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), initial);
|
||||||
|
}
|
||||||
|
if (initial.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация не находится на этапе COPYING_CHAIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
ReentrantLock lock = BlockchainLocks.lockFor(initial.getCandidateBlockchainName());
|
||||||
|
lock.lock();
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
KeyRotationSessionEntry session = rotations.getActiveByLogin(c, login);
|
||||||
|
if (session == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
|
||||||
|
}
|
||||||
|
if (session.getStatus() == KeyRotationStatus.CHAIN_READY) {
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), session);
|
||||||
|
}
|
||||||
|
if (session.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация больше не находится на этапе COPYING_CHAIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
int expected = session.getProgressTotal();
|
||||||
|
int stored = candidates.countStored(c, session.getId());
|
||||||
|
int published = candidates.countPublished(c, session.getId());
|
||||||
|
if (stored != expected) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_INCOMPLETE",
|
||||||
|
"Candidate-chain ещё не полностью загружена: " + stored + "/" + expected);
|
||||||
|
}
|
||||||
|
if (published != expected) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_NOT_PUBLISHED",
|
||||||
|
"Candidate-chain ещё не полностью опубликована в Arweave/Turbo: " + published + "/" + expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<KeyRotationCandidateBlockEntry> entries = candidates.listBySession(c, session.getId());
|
||||||
|
String validationError = validateCompleteChain(c, session, entries);
|
||||||
|
if (validationError != null) {
|
||||||
|
try { rotations.recordError(session.getId(), validationError); } catch (Exception ignored) { }
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, validationError, "Финальная проверка candidate-chain не пройдена");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_FINISH_CHAIN_FAILED", "Не удалось завершить проверку candidate-chain");
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry ready = rotations.transition(
|
||||||
|
initial.getId(), KeyRotationStatus.COPYING_CHAIN, KeyRotationStatus.CHAIN_READY);
|
||||||
|
rotations.clearError(initial.getId());
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), ready);
|
||||||
|
} catch (Exception transitionFailure) {
|
||||||
|
log.warn("KeyRotationFinishChain transition failed: login={}, sessionId={}",
|
||||||
|
login, initial.getId(), transitionFailure);
|
||||||
|
// Идемпотентность для параллельного FinishChain из другой сессии.
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry now = rotations.getActiveByLogin(login);
|
||||||
|
if (now != null && now.getStatus() == KeyRotationStatus.CHAIN_READY) {
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), now);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) { }
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_FINISH_CHAIN_RACE", "Состояние ротации изменилось во время FinishChain");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateCompleteChain(Connection c,
|
||||||
|
KeyRotationSessionEntry session,
|
||||||
|
List<KeyRotationCandidateBlockEntry> entries) throws Exception {
|
||||||
|
if (entries == null || entries.size() != session.getProgressTotal()) return "KEY_ROTATION_CHAIN_INCOMPLETE";
|
||||||
|
if (session.getProgressTotal() != session.getForkFromBlock() + 2) return "KEY_ROTATION_BAD_PROGRESS_TOTAL";
|
||||||
|
|
||||||
|
byte[] newBlockchainKey = Base64.getDecoder().decode(session.getNewBlockchainKey());
|
||||||
|
if (newBlockchainKey.length != 32) return "KEY_ROTATION_BAD_STORED_KEY";
|
||||||
|
|
||||||
|
BchBlockEntry previous = null;
|
||||||
|
for (int i = 0; i < entries.size(); i++) {
|
||||||
|
KeyRotationCandidateBlockEntry stored = entries.get(i);
|
||||||
|
if (stored.getBlockNumber() != i) return "KEY_ROTATION_CANDIDATE_GAP";
|
||||||
|
if (stored.isArweavePublishPending() || stored.getArweavePublishedAtMs() == null) {
|
||||||
|
return "KEY_ROTATION_CHAIN_NOT_PUBLISHED";
|
||||||
|
}
|
||||||
|
|
||||||
|
BchBlockEntry candidate;
|
||||||
|
try {
|
||||||
|
candidate = new BchBlockEntry(stored.getBlockBytes());
|
||||||
|
candidate.body.check();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "KEY_ROTATION_BAD_STORED_BLOCK";
|
||||||
|
}
|
||||||
|
if (candidate.blockNumber != i) return "KEY_ROTATION_BLOCK_NUMBER_MISMATCH";
|
||||||
|
if (!Arrays.equals(candidate.getHash32(), stored.getBlockHash())) return "KEY_ROTATION_STORED_HASH_MISMATCH";
|
||||||
|
if (!Arrays.equals(candidate.getDataItemId32(), stored.getDataItemId())) return "KEY_ROTATION_STORED_DATAITEM_MISMATCH";
|
||||||
|
if (!candidate.getDataItem().hasTag("App", "test5590")) return "KEY_ROTATION_BAD_APP_TAG";
|
||||||
|
if (!BchCryptoVerifier.verifyBlock(candidate, newBlockchainKey)) return "KEY_ROTATION_BAD_SIGNATURE";
|
||||||
|
|
||||||
|
if (i == 0) {
|
||||||
|
if (!isZero32(candidate.prevHash32)) return "KEY_ROTATION_GENESIS_PREV_HASH";
|
||||||
|
} else {
|
||||||
|
if (previous == null || !Arrays.equals(candidate.prevHash32, previous.getHash32())) {
|
||||||
|
return "KEY_ROTATION_CHAIN_HASH_MISMATCH";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String semanticError = validateAgainstRotation(c, session, candidate);
|
||||||
|
if (semanticError != null) return semanticError;
|
||||||
|
previous = candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateAgainstRotation(Connection c, KeyRotationSessionEntry session, BchBlockEntry candidate) throws Exception {
|
||||||
|
if (candidate.blockNumber <= session.getForkFromBlock()) {
|
||||||
|
BlockEntry sourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), candidate.blockNumber);
|
||||||
|
if (sourceEntry == null || sourceEntry.getBlockBytes() == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
|
||||||
|
BchBlockEntry source = new BchBlockEntry(sourceEntry.getBlockBytes());
|
||||||
|
if (!Arrays.equals(source.getFrameBytes(), candidate.getFrameBytes())) return "KEY_ROTATION_FRAME_MISMATCH";
|
||||||
|
if (!Arrays.equals(source.getDataItem().rawTags(), candidate.getDataItem().rawTags())) return "KEY_ROTATION_TAGS_MISMATCH";
|
||||||
|
if (!Arrays.equals(source.getDataItem().target(), candidate.getDataItem().target())) return "KEY_ROTATION_TARGET_MISMATCH";
|
||||||
|
if (!Arrays.equals(source.getDataItem().anchor(), candidate.getDataItem().anchor())) return "KEY_ROTATION_ANCHOR_MISMATCH";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.blockNumber != session.getForkFromBlock() + 1) return "KEY_ROTATION_UNEXPECTED_BLOCK_NUMBER";
|
||||||
|
if ((candidate.type & 0xFFFF) != 0
|
||||||
|
|| (candidate.subType & 0xFFFF) != (MsgSubType.TECH_FORK & 0xFFFF)
|
||||||
|
|| !(candidate.body instanceof ForkBody fork)) {
|
||||||
|
return "KEY_ROTATION_TECH_FORK_REQUIRED";
|
||||||
|
}
|
||||||
|
if (!Arrays.equals(candidate.prevHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_PREV_HASH";
|
||||||
|
|
||||||
|
BlockEntry forkSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getForkFromBlock());
|
||||||
|
BlockEntry tipSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getSourceTipBlock());
|
||||||
|
if (forkSourceEntry == null || tipSourceEntry == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
|
||||||
|
BchBlockEntry forkSource = new BchBlockEntry(forkSourceEntry.getBlockBytes());
|
||||||
|
BchBlockEntry tipSource = new BchBlockEntry(tipSourceEntry.getBlockBytes());
|
||||||
|
|
||||||
|
byte[] oldBlockchain = Base64.getDecoder().decode(session.getOldBlockchainKey());
|
||||||
|
if (!Arrays.equals(fork.parentBlockchainKey32, oldBlockchain)) return "KEY_ROTATION_TECH_FORK_PARENT_KEY";
|
||||||
|
if (fork.forkPointBlockNumber != session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_POINT";
|
||||||
|
if (!Arrays.equals(fork.forkPointBlockHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_POINT_HASH";
|
||||||
|
if (fork.forkPointTimestampMs != Math.multiplyExact(forkSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_POINT_TIME";
|
||||||
|
if (fork.parentTipBlockNumber != session.getSourceTipBlock()) return "KEY_ROTATION_TECH_FORK_TIP";
|
||||||
|
if (!Arrays.equals(fork.parentTipBlockHash32, session.getSourceTipHash())) return "KEY_ROTATION_TECH_FORK_TIP_HASH";
|
||||||
|
if (fork.parentTipTimestampMs != Math.multiplyExact(tipSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_TIP_TIME";
|
||||||
|
if (fork.discardedBlocksCount != session.getSourceTipBlock() - session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_DISCARDED";
|
||||||
|
if (fork.reasonCode != session.getReasonCode()) return "KEY_ROTATION_TECH_FORK_REASON";
|
||||||
|
if (!normalizeComment(fork.comment).equals(normalizeComment(session.getComment()))) return "KEY_ROTATION_TECH_FORK_COMMENT";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isZero32(byte[] value) {
|
||||||
|
if (value == null || value.length != 32) return false;
|
||||||
|
for (byte b : value) if (b != 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeComment(String value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фиксирует уже отправленную клиентом Solana-транзакцию ротации PDA.
|
||||||
|
* Приватные ключи и подписанная транзакция через сервер не проходят: клиент передаёт только tx signature.
|
||||||
|
*/
|
||||||
|
public final class Net_KeyRotationRotatePda_Handler implements JsonMessageHandler {
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationRotatePda_Request req = (Net_KeyRotationRotatePda_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
|
||||||
|
final String signature;
|
||||||
|
try {
|
||||||
|
signature = normalizeSolanaSignature(req.getPdaRotationSignature());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_SOLANA_SIGNATURE", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = rotations.getActiveByLogin(login);
|
||||||
|
if (current == null) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.getStatus() == KeyRotationStatus.PDA_ROTATED) {
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.getStatus() == KeyRotationStatus.ROTATING_PDA) {
|
||||||
|
String stored = current.getPdaRotationSignature();
|
||||||
|
if (stored != null && !stored.isBlank() && !stored.equals(signature)) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_PDA_SIGNATURE_CONFLICT",
|
||||||
|
"Для этой ротации уже сохранена другая Solana transaction signature");
|
||||||
|
}
|
||||||
|
if (stored == null || stored.isBlank()) {
|
||||||
|
rotations.setPdaRotationSignature(current.getId(), signature);
|
||||||
|
}
|
||||||
|
} else if (current.getStatus() == KeyRotationStatus.CHAIN_READY) {
|
||||||
|
current = rotations.beginPdaRotation(current.getId(), signature);
|
||||||
|
} else {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_CHAIN_READY",
|
||||||
|
"PDA можно ротировать только после CHAIN_READY");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solana sync мог увидеть новое PDA ещё до этого API-вызова.
|
||||||
|
KeyRotationSessionEntry confirmed = rotations.tryMarkPdaRotatedFromCurrentState(current.getId());
|
||||||
|
rotations.clearError(current.getId());
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), confirmed != null ? confirmed : rotations.getById(current.getId()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ROTATE_PDA_FAILED",
|
||||||
|
"Не удалось зафиксировать Solana-ротацию PDA");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeSolanaSignature(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) throw new IllegalArgumentException("pdaRotationSignature обязательна");
|
||||||
|
String s = raw.trim();
|
||||||
|
if (s.length() > 128) throw new IllegalArgumentException("Слишком длинная Solana transaction signature");
|
||||||
|
final String alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
if (alphabet.indexOf(s.charAt(i)) < 0) {
|
||||||
|
throw new IllegalArgumentException("Solana transaction signature должна быть Base58");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт серверную ротацию сразу в COPYING_CHAIN.
|
||||||
|
* До этого момента всё заполнение формы остаётся только локальным UI-состоянием.
|
||||||
|
*/
|
||||||
|
public final class Net_KeyRotationStart_Handler implements JsonMessageHandler {
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
|
||||||
|
private final BlocksDAO blocks = BlocksDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationStart_Request req = (Net_KeyRotationStart_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
String ctxBlockchainName = ctx.getCurrentUser() != null ? ctx.getCurrentUser().getBlockchainName() : null;
|
||||||
|
if (ctxBlockchainName == null || ctxBlockchainName.isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_SESSION_STALE",
|
||||||
|
"В сессии отсутствует актуальный blockchainName; войдите заново");
|
||||||
|
}
|
||||||
|
|
||||||
|
final String newRoot;
|
||||||
|
final String newBlockchain;
|
||||||
|
final String newClient;
|
||||||
|
final byte[] requestedForkHash;
|
||||||
|
try {
|
||||||
|
newRoot = KeyRotationApiSupport.normalizePublicKey32(req.getNewRootKey());
|
||||||
|
newBlockchain = KeyRotationApiSupport.normalizePublicKey32(req.getNewBlockchainKey());
|
||||||
|
newClient = KeyRotationApiSupport.normalizePublicKey32(req.getNewClientKey());
|
||||||
|
requestedForkHash = KeyRotationApiSupport.parseHash32(req.getForkFromHash());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FIELDS", e.getMessage());
|
||||||
|
}
|
||||||
|
if (req.getReasonCode() < 1 || req.getReasonCode() > 4) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_REASON", "reasonCode должен быть 1..4");
|
||||||
|
}
|
||||||
|
String comment = req.getComment() == null ? "" : req.getComment();
|
||||||
|
if (comment.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1024) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_COMMENT_TOO_LONG", "Комментарий должен быть не длиннее 1024 UTF-8 байт");
|
||||||
|
}
|
||||||
|
if (req.getForkFromBlock() < 0) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "forkFromBlock должен быть >= 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
ReentrantLock lock = BlockchainLocks.lockFor(ctxBlockchainName);
|
||||||
|
lock.lock();
|
||||||
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
PdaSnapshot pda = loadPdaForUpdate(c, login);
|
||||||
|
if (pda == null) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "KEY_ROTATION_USER_NOT_FOUND", "Пользователь не найден в текущем Solana PDA state");
|
||||||
|
}
|
||||||
|
if (!KeyRotationStatus.NONE.name().equals(pda.rotationStatus)) {
|
||||||
|
KeyRotationSessionEntry active = rotations.getActiveByLogin(c, login);
|
||||||
|
c.rollback();
|
||||||
|
if (active != null
|
||||||
|
&& sameStartRequest(active, newRoot, newBlockchain, newClient,
|
||||||
|
req.getForkFromBlock(), requestedForkHash, req.getReasonCode(), comment)) {
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), active);
|
||||||
|
}
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_ALREADY_ACTIVE", "Смена ключей уже выполняется с другими параметрами");
|
||||||
|
}
|
||||||
|
if (!ctxBlockchainName.equals(pda.blockchainName)) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_SESSION_STALE",
|
||||||
|
"Активный blockchain изменился; обновите сессию и повторите");
|
||||||
|
}
|
||||||
|
|
||||||
|
String oldRoot = KeyRotationApiSupport.normalizePublicKey32(pda.rootKey);
|
||||||
|
String oldBlockchain = KeyRotationApiSupport.normalizePublicKey32(pda.blockchainKey);
|
||||||
|
String oldClient = KeyRotationApiSupport.normalizePublicKey32(pda.clientKey);
|
||||||
|
try {
|
||||||
|
KeyRotationApiSupport.requireKeysChangedAndDistinct(
|
||||||
|
oldRoot, oldBlockchain, oldClient, newRoot, newBlockchain, newClient);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_NEW_KEYS", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockchainStateEntry state = states.getByBlockchainName(c, pda.blockchainName);
|
||||||
|
if (state == null || state.getLastBlockHash() == null || state.getLastBlockHash().length != 32) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_STATE_MISSING", "Не найдено корректное текущее состояние блокчейна");
|
||||||
|
}
|
||||||
|
if (req.getForkFromBlock() > state.getLastBlockNumber()) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "Точка fork находится после текущего tip");
|
||||||
|
}
|
||||||
|
BlockEntry forkBlock = blocks.getByNumber(c, pda.blockchainName, req.getForkFromBlock());
|
||||||
|
if (forkBlock == null || forkBlock.getBlockHash() == null || forkBlock.getBlockHash().length != 32) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "KEY_ROTATION_FORK_BLOCK_NOT_FOUND", "Выбранный блок не найден на сервере");
|
||||||
|
}
|
||||||
|
if (!KeyRotationApiSupport.hashesEqual(forkBlock.getBlockHash(), requestedForkHash)) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_FORK_HASH_MISMATCH", "Хэш выбранного блока не совпадает с сервером");
|
||||||
|
}
|
||||||
|
|
||||||
|
final String candidateName;
|
||||||
|
try {
|
||||||
|
candidateName = KeyRotationApiSupport.nextBlockchainName(login, pda.blockchainName);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCKCHAIN_NAME", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyRotationSessionEntry entry = new KeyRotationSessionEntry();
|
||||||
|
entry.setLogin(login);
|
||||||
|
entry.setSourceBlockchainName(pda.blockchainName);
|
||||||
|
entry.setCandidateBlockchainName(candidateName);
|
||||||
|
entry.setOldRootKey(oldRoot);
|
||||||
|
entry.setOldBlockchainKey(oldBlockchain);
|
||||||
|
entry.setOldClientKey(oldClient);
|
||||||
|
entry.setNewRootKey(newRoot);
|
||||||
|
entry.setNewBlockchainKey(newBlockchain);
|
||||||
|
entry.setNewClientKey(newClient);
|
||||||
|
entry.setForkFromBlock(req.getForkFromBlock());
|
||||||
|
entry.setForkFromHash(requestedForkHash);
|
||||||
|
entry.setSourceTipBlock(state.getLastBlockNumber());
|
||||||
|
entry.setSourceTipHash(state.getLastBlockHash());
|
||||||
|
entry.setReasonCode(req.getReasonCode());
|
||||||
|
entry.setComment(comment);
|
||||||
|
entry.setProgressCurrent(0);
|
||||||
|
entry.setProgressTotal(Math.addExact(req.getForkFromBlock(), 2)); // 0..N + TECH_FORK
|
||||||
|
|
||||||
|
KeyRotationSessionEntry created = rotations.createCopyingSession(c, entry);
|
||||||
|
c.commit();
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), created);
|
||||||
|
} catch (ArithmeticException e) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "Слишком большой номер блока");
|
||||||
|
} catch (Exception e) {
|
||||||
|
c.rollback();
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_START_FAILED", "Не удалось запустить смену ключей");
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(oldAutoCommit);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_START_FAILED", "Не удалось открыть транзакцию смены ключей");
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static boolean sameStartRequest(KeyRotationSessionEntry active,
|
||||||
|
String newRoot, String newBlockchain, String newClient,
|
||||||
|
int forkFromBlock, byte[] forkFromHash, short reasonCode, String comment) {
|
||||||
|
return active != null
|
||||||
|
&& java.util.Objects.equals(active.getNewRootKey(), newRoot)
|
||||||
|
&& java.util.Objects.equals(active.getNewBlockchainKey(), newBlockchain)
|
||||||
|
&& java.util.Objects.equals(active.getNewClientKey(), newClient)
|
||||||
|
&& active.getForkFromBlock() == forkFromBlock
|
||||||
|
&& java.util.Arrays.equals(active.getForkFromHash(), forkFromHash)
|
||||||
|
&& active.getReasonCode() == reasonCode
|
||||||
|
&& java.util.Objects.equals(active.getComment() == null ? "" : active.getComment(), comment == null ? "" : comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PdaSnapshot loadPdaForUpdate(Connection c, String login) throws Exception {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
SELECT root_key, blockchain_key, client_key, blockchain_name, rotation_status
|
||||||
|
FROM solana_user_pda_current
|
||||||
|
WHERE normalized_login = LOWER(BTRIM(?))
|
||||||
|
FOR UPDATE
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
return new PdaSnapshot(
|
||||||
|
rs.getString("root_key"),
|
||||||
|
rs.getString("blockchain_key"),
|
||||||
|
rs.getString("client_key"),
|
||||||
|
rs.getString("blockchain_name"),
|
||||||
|
rs.getString("rotation_status")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record PdaSnapshot(String rootKey, String blockchainKey, String clientKey,
|
||||||
|
String blockchainName, String rotationStatus) { }
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
|
||||||
|
public final class Net_KeyRotationStatus_Handler implements JsonMessageHandler {
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_KeyRotationStatus_Request req = (Net_KeyRotationStatus_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
|
||||||
|
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_STATUS_FAILED",
|
||||||
|
"Не удалось прочитать состояние смены ключей");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/** Прервать ротацию до отправки Solana-транзакции. */
|
||||||
|
public final class Net_KeyRotationAbort_Request extends Net_Request {
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/** Один подписанный новым blockchain key DataItem будущего fork. */
|
||||||
|
public final class Net_KeyRotationAddBlock_Request extends Net_Request {
|
||||||
|
private int blockNumber;
|
||||||
|
private String prevBlockHash;
|
||||||
|
private String blockBytesB64;
|
||||||
|
|
||||||
|
public int getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
public String getPrevBlockHash() { return prevBlockHash; }
|
||||||
|
public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; }
|
||||||
|
public String getBlockBytesB64() { return blockBytesB64; }
|
||||||
|
public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; }
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/** Продолжить один из интерактивных/заглушечных этапов после rebuild. */
|
||||||
|
public final class Net_KeyRotationContinue_Request extends Net_Request {
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/** Финальная проверка полностью опубликованной candidate-chain. */
|
||||||
|
public final class Net_KeyRotationFinishChain_Request extends Net_Request {
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Клиент уже локально подписал и отправил Solana-транзакцию ротации PDA.
|
||||||
|
* Серверу передаётся только публичная Solana transaction signature для аудита/возобновления.
|
||||||
|
*/
|
||||||
|
public final class Net_KeyRotationRotatePda_Request extends Net_Request {
|
||||||
|
private String pdaRotationSignature;
|
||||||
|
|
||||||
|
public String getPdaRotationSignature() { return pdaRotationSignature; }
|
||||||
|
public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; }
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запуск смены ключей. Старые ключи сервер берёт из текущего Solana PDA;
|
||||||
|
* клиент передаёт только новые публичные ключи и выбранную точку fork.
|
||||||
|
*/
|
||||||
|
public final class Net_KeyRotationStart_Request extends Net_Request {
|
||||||
|
private String newRootKey;
|
||||||
|
private String newBlockchainKey;
|
||||||
|
private String newClientKey;
|
||||||
|
private int forkFromBlock;
|
||||||
|
private String forkFromHash;
|
||||||
|
private short reasonCode;
|
||||||
|
private String comment;
|
||||||
|
|
||||||
|
public String getNewRootKey() { return newRootKey; }
|
||||||
|
public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; }
|
||||||
|
public String getNewBlockchainKey() { return newBlockchainKey; }
|
||||||
|
public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; }
|
||||||
|
public String getNewClientKey() { return newClientKey; }
|
||||||
|
public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; }
|
||||||
|
public int getForkFromBlock() { return forkFromBlock; }
|
||||||
|
public void setForkFromBlock(int forkFromBlock) { this.forkFromBlock = forkFromBlock; }
|
||||||
|
public String getForkFromHash() { return forkFromHash; }
|
||||||
|
public void setForkFromHash(String forkFromHash) { this.forkFromHash = forkFromHash; }
|
||||||
|
public short getReasonCode() { return reasonCode; }
|
||||||
|
public void setReasonCode(short reasonCode) { this.reasonCode = reasonCode; }
|
||||||
|
public String getComment() { return comment; }
|
||||||
|
public void setComment(String comment) { this.comment = comment; }
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
/** Публичное состояние текущей/только что созданной ротации. */
|
||||||
|
public final class Net_KeyRotationState_Response extends Net_Response {
|
||||||
|
private Long rotationSessionId;
|
||||||
|
private String rotationStatus;
|
||||||
|
private String sourceBlockchainName;
|
||||||
|
private String candidateBlockchainName;
|
||||||
|
private String oldRootKey;
|
||||||
|
private String oldBlockchainKey;
|
||||||
|
private String oldClientKey;
|
||||||
|
private String newRootKey;
|
||||||
|
private String newBlockchainKey;
|
||||||
|
private String newClientKey;
|
||||||
|
private Integer forkFromBlock;
|
||||||
|
private String forkFromHash;
|
||||||
|
private Integer sourceTipBlock;
|
||||||
|
private String sourceTipHash;
|
||||||
|
private Short reasonCode;
|
||||||
|
private String comment;
|
||||||
|
private Integer progressCurrent;
|
||||||
|
private Integer progressTotal;
|
||||||
|
private String pdaRotationSignature;
|
||||||
|
private String walletMigrationStatus;
|
||||||
|
private String messageMigrationStatus;
|
||||||
|
private String lastError;
|
||||||
|
private Integer retryCount;
|
||||||
|
private Long createdAtMs;
|
||||||
|
private Long updatedAtMs;
|
||||||
|
|
||||||
|
public Long getRotationSessionId() { return rotationSessionId; }
|
||||||
|
public void setRotationSessionId(Long rotationSessionId) { this.rotationSessionId = rotationSessionId; }
|
||||||
|
public String getRotationStatus() { return rotationStatus; }
|
||||||
|
public void setRotationStatus(String rotationStatus) { this.rotationStatus = rotationStatus; }
|
||||||
|
public String getSourceBlockchainName() { return sourceBlockchainName; }
|
||||||
|
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
|
||||||
|
public String getCandidateBlockchainName() { return candidateBlockchainName; }
|
||||||
|
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
|
||||||
|
public String getOldRootKey() { return oldRootKey; }
|
||||||
|
public void setOldRootKey(String oldRootKey) { this.oldRootKey = oldRootKey; }
|
||||||
|
public String getOldBlockchainKey() { return oldBlockchainKey; }
|
||||||
|
public void setOldBlockchainKey(String oldBlockchainKey) { this.oldBlockchainKey = oldBlockchainKey; }
|
||||||
|
public String getOldClientKey() { return oldClientKey; }
|
||||||
|
public void setOldClientKey(String oldClientKey) { this.oldClientKey = oldClientKey; }
|
||||||
|
public String getNewRootKey() { return newRootKey; }
|
||||||
|
public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; }
|
||||||
|
public String getNewBlockchainKey() { return newBlockchainKey; }
|
||||||
|
public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; }
|
||||||
|
public String getNewClientKey() { return newClientKey; }
|
||||||
|
public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; }
|
||||||
|
public Integer getForkFromBlock() { return forkFromBlock; }
|
||||||
|
public void setForkFromBlock(Integer forkFromBlock) { this.forkFromBlock = forkFromBlock; }
|
||||||
|
public String getForkFromHash() { return forkFromHash; }
|
||||||
|
public void setForkFromHash(String forkFromHash) { this.forkFromHash = forkFromHash; }
|
||||||
|
public Integer getSourceTipBlock() { return sourceTipBlock; }
|
||||||
|
public void setSourceTipBlock(Integer sourceTipBlock) { this.sourceTipBlock = sourceTipBlock; }
|
||||||
|
public String getSourceTipHash() { return sourceTipHash; }
|
||||||
|
public void setSourceTipHash(String sourceTipHash) { this.sourceTipHash = sourceTipHash; }
|
||||||
|
public Short getReasonCode() { return reasonCode; }
|
||||||
|
public void setReasonCode(Short reasonCode) { this.reasonCode = reasonCode; }
|
||||||
|
public String getComment() { return comment; }
|
||||||
|
public void setComment(String comment) { this.comment = comment; }
|
||||||
|
public Integer getProgressCurrent() { return progressCurrent; }
|
||||||
|
public void setProgressCurrent(Integer progressCurrent) { this.progressCurrent = progressCurrent; }
|
||||||
|
public Integer getProgressTotal() { return progressTotal; }
|
||||||
|
public void setProgressTotal(Integer progressTotal) { this.progressTotal = progressTotal; }
|
||||||
|
public String getPdaRotationSignature() { return pdaRotationSignature; }
|
||||||
|
public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; }
|
||||||
|
public String getWalletMigrationStatus() { return walletMigrationStatus; }
|
||||||
|
public void setWalletMigrationStatus(String walletMigrationStatus) { this.walletMigrationStatus = walletMigrationStatus; }
|
||||||
|
public String getMessageMigrationStatus() { return messageMigrationStatus; }
|
||||||
|
public void setMessageMigrationStatus(String messageMigrationStatus) { this.messageMigrationStatus = messageMigrationStatus; }
|
||||||
|
public String getLastError() { return lastError; }
|
||||||
|
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||||
|
public Integer getRetryCount() { return retryCount; }
|
||||||
|
public void setRetryCount(Integer retryCount) { this.retryCount = retryCount; }
|
||||||
|
public Long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
public void setCreatedAtMs(Long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||||
|
public Long getUpdatedAtMs() { return updatedAtMs; }
|
||||||
|
public void setUpdatedAtMs(Long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/** Запрос текущего состояния ротации авторизованного пользователя. */
|
||||||
|
public final class Net_KeyRotationStatus_Request extends Net_Request {
|
||||||
|
}
|
||||||
+180
-2
@@ -216,10 +216,188 @@ public final class ShineUsersCodec {
|
|||||||
|
|
||||||
int formatMajor = raw[5] & 0xFF;
|
int formatMajor = raw[5] & 0xFF;
|
||||||
int formatMinor = raw[6] & 0xFF;
|
int formatMinor = raw[6] & 0xFF;
|
||||||
if (formatMajor != 1 || formatMinor != 2) {
|
if (formatMajor != 1) {
|
||||||
throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor);
|
throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor);
|
||||||
}
|
}
|
||||||
return parseUserPdaAccountV12(pdaAddress, slot, rawDataBase64, lastTxSignature, raw);
|
if (formatMinor == 2) {
|
||||||
|
return parseUserPdaAccountV12(pdaAddress, slot, rawDataBase64, lastTxSignature, raw);
|
||||||
|
}
|
||||||
|
if (formatMinor == 0) {
|
||||||
|
// Временная read-only совместимость: legacy PDA 1.0 снова читается
|
||||||
|
// server-sync только для bootstrap старых server-записей. Новые записи
|
||||||
|
// и update по-прежнему работают только с текущим форматом 1.2.
|
||||||
|
return parseUserPdaAccountV10(pdaAddress, slot, rawDataBase64, lastTxSignature, raw);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserPdaSnapshot parseUserPdaAccountV10(
|
||||||
|
String pdaAddress,
|
||||||
|
long slot,
|
||||||
|
String rawDataBase64,
|
||||||
|
String lastTxSignature,
|
||||||
|
byte[] raw
|
||||||
|
) {
|
||||||
|
int recordLen = u16le(raw, 7);
|
||||||
|
if (recordLen < 9 + 64 || recordLen > raw.length) {
|
||||||
|
throw new IllegalArgumentException("Invalid PDA 1.0 record length");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] useful = new byte[recordLen];
|
||||||
|
System.arraycopy(raw, 0, useful, 0, recordLen);
|
||||||
|
Reader reader = new Reader(useful);
|
||||||
|
reader.skip(9);
|
||||||
|
|
||||||
|
long createdAtMs = reader.readU64();
|
||||||
|
long updatedAtMs = reader.readU64();
|
||||||
|
int recordNumber = (int) reader.readU32();
|
||||||
|
String prevHash = toHex(reader.readFixed(32));
|
||||||
|
String login = reader.readStringU8();
|
||||||
|
int blocksCount = reader.readU8();
|
||||||
|
|
||||||
|
String recoveryKey = null;
|
||||||
|
String rootKey = null;
|
||||||
|
String clientKey = null;
|
||||||
|
String blockchainName = null;
|
||||||
|
String blockchainKey = null;
|
||||||
|
long paidLimitBytes = 0L;
|
||||||
|
long usedBytes = 0L;
|
||||||
|
int lastBlockNumber = 0;
|
||||||
|
String lastBlockHash = "";
|
||||||
|
String lastBlockSignature = "";
|
||||||
|
String arweaveTxId = "";
|
||||||
|
String archiveHeadTxId = "";
|
||||||
|
String archiveHeadHash = "";
|
||||||
|
boolean isServer = false;
|
||||||
|
int addressFormatType = 0;
|
||||||
|
int addressFormatVersion = 0;
|
||||||
|
String serverAddress = "";
|
||||||
|
List<String> syncServers = new ArrayList<>();
|
||||||
|
List<String> accessServers = new ArrayList<>();
|
||||||
|
int sessionsMode = 1;
|
||||||
|
List<UserSessionSnapshot> sessions = new ArrayList<>();
|
||||||
|
int trustedCount = 0;
|
||||||
|
|
||||||
|
boolean seenRecovery = false;
|
||||||
|
boolean seenRoot = false;
|
||||||
|
boolean seenClient = false;
|
||||||
|
boolean seenBlockchain = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < blocksCount; i++) {
|
||||||
|
int blockType = reader.readU8();
|
||||||
|
int blockVersion = reader.readU8();
|
||||||
|
if (blockVersion != BLOCK_VERSION_0) {
|
||||||
|
throw new IllegalArgumentException("Unsupported PDA 1.0 block version: type=" + blockType + " version=" + blockVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (blockType) {
|
||||||
|
case BLOCK_TYPE_RECOVERY_KEY -> {
|
||||||
|
if (seenRecovery) throw new IllegalArgumentException("Duplicate RecoveryKeyBlock in PDA 1.0");
|
||||||
|
recoveryKey = Base58Util.encode(reader.readFixed(32));
|
||||||
|
seenRecovery = true;
|
||||||
|
}
|
||||||
|
case BLOCK_TYPE_ROOT_KEY -> {
|
||||||
|
if (seenRoot) throw new IllegalArgumentException("Duplicate RootKeyBlock in PDA 1.0");
|
||||||
|
rootKey = Base58Util.encode(reader.readFixed(32));
|
||||||
|
seenRoot = true;
|
||||||
|
}
|
||||||
|
case BLOCK_TYPE_CLIENT_KEY -> {
|
||||||
|
if (seenClient) throw new IllegalArgumentException("Duplicate ClientKeyBlock in PDA 1.0");
|
||||||
|
clientKey = Base58Util.encode(reader.readFixed(32));
|
||||||
|
seenClient = true;
|
||||||
|
}
|
||||||
|
case BLOCK_TYPE_BLOCKCHAIN_REGISTRY -> {
|
||||||
|
if (seenBlockchain) throw new IllegalArgumentException("Duplicate BlockchainRegistryBlock in PDA 1.0");
|
||||||
|
int count = reader.readU8();
|
||||||
|
if (count < 1) throw new IllegalArgumentException("Empty BlockchainRegistryBlock in PDA 1.0");
|
||||||
|
for (int j = 0; j < count; j++) {
|
||||||
|
int blockchainType = reader.readU8();
|
||||||
|
String currentName = reader.readStringU8();
|
||||||
|
String currentKey = Base58Util.encode(reader.readFixed(32));
|
||||||
|
long currentPaidLimit = reader.readU64();
|
||||||
|
long currentUsedBytes = reader.readU64();
|
||||||
|
long currentLastBlockNumber = reader.readU32();
|
||||||
|
String currentLastBlockHash = toHex(reader.readFixed(32));
|
||||||
|
String currentLastBlockSignature = Base58Util.encode(reader.readFixed(64));
|
||||||
|
int arweavePresent = reader.readU8();
|
||||||
|
String currentArweaveTxId = arweavePresent == 1 ? reader.readStringU8() : "";
|
||||||
|
if (arweavePresent != 0 && arweavePresent != 1) {
|
||||||
|
throw new IllegalArgumentException("Invalid PDA 1.0 arweave_present");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime SHiNE использует основной пользовательский blockchain (type=1).
|
||||||
|
// Если legacy запись содержит несколько chain records, берём первый MAIN_USER.
|
||||||
|
if (blockchainName == null && blockchainType == BLOCKCHAIN_TYPE_MAIN_USER) {
|
||||||
|
blockchainName = currentName;
|
||||||
|
blockchainKey = currentKey;
|
||||||
|
paidLimitBytes = currentPaidLimit;
|
||||||
|
usedBytes = currentUsedBytes;
|
||||||
|
lastBlockNumber = Math.toIntExact(currentLastBlockNumber);
|
||||||
|
lastBlockHash = currentLastBlockHash;
|
||||||
|
lastBlockSignature = currentLastBlockSignature;
|
||||||
|
arweaveTxId = currentArweaveTxId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seenBlockchain = true;
|
||||||
|
}
|
||||||
|
case BLOCK_TYPE_SERVER_PROFILE -> {
|
||||||
|
int serverFlag = reader.readU8();
|
||||||
|
if (serverFlag != 0 && serverFlag != 1) {
|
||||||
|
throw new IllegalArgumentException("Invalid PDA 1.0 is_server");
|
||||||
|
}
|
||||||
|
isServer = serverFlag == 1;
|
||||||
|
if (isServer) {
|
||||||
|
addressFormatType = reader.readU8();
|
||||||
|
addressFormatVersion = reader.readU8();
|
||||||
|
serverAddress = reader.readStringU8();
|
||||||
|
int count = reader.readU8();
|
||||||
|
for (int j = 0; j < count; j++) syncServers.add(reader.readStringU8());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case BLOCK_TYPE_ACCESS_SERVERS -> {
|
||||||
|
int count = reader.readU8();
|
||||||
|
for (int j = 0; j < count; j++) accessServers.add(reader.readStringU8());
|
||||||
|
}
|
||||||
|
case BLOCK_TYPE_SESSIONS -> {
|
||||||
|
sessionsMode = reader.readU8();
|
||||||
|
int count = reader.readU8();
|
||||||
|
for (int j = 0; j < count; j++) {
|
||||||
|
int sessionType = reader.readU8();
|
||||||
|
int sessionVersion = reader.readU8();
|
||||||
|
String sessionName = reader.readStringU8();
|
||||||
|
String sessionPubKey = Base58Util.encode(reader.readFixed(32));
|
||||||
|
sessions.add(new UserSessionSnapshot(sessionType, sessionVersion, sessionName, sessionPubKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case BLOCK_TYPE_TRUSTED_STATE -> trustedCount = reader.readU8();
|
||||||
|
case BLOCK_TYPE_ARCHIVE_HEAD -> {
|
||||||
|
archiveHeadTxId = Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32));
|
||||||
|
archiveHeadHash = toHex(reader.readFixed(32));
|
||||||
|
}
|
||||||
|
default -> throw new IllegalArgumentException("Unsupported PDA 1.0 block type: " + blockType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String signature = Base58Util.encode(reader.readFixed(64));
|
||||||
|
if (reader.remaining() != 0) {
|
||||||
|
throw new IllegalArgumentException("Unexpected tail inside PDA 1.0 record");
|
||||||
|
}
|
||||||
|
if (!seenRecovery || !seenRoot || !seenClient || !seenBlockchain
|
||||||
|
|| recoveryKey == null || rootKey == null || clientKey == null
|
||||||
|
|| blockchainName == null || blockchainKey == null) {
|
||||||
|
throw new IllegalArgumentException("PDA 1.0 misses required blocks");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UserPdaSnapshot(
|
||||||
|
pdaAddress, login, recordNumber, slot, lastTxSignature,
|
||||||
|
recoveryKey, rootKey, clientKey, blockchainName, blockchainKey, paidLimitBytes,
|
||||||
|
usedBytes, lastBlockNumber, lastBlockHash, lastBlockSignature, arweaveTxId,
|
||||||
|
archiveHeadTxId, archiveHeadHash,
|
||||||
|
isServer, addressFormatType, addressFormatVersion, serverAddress,
|
||||||
|
List.copyOf(syncServers), List.copyOf(accessServers), sessionsMode, List.copyOf(sessions), trustedCount,
|
||||||
|
createdAtMs, updatedAtMs, prevHash, signature, rawDataBase64,
|
||||||
|
List.of(new BlockchainForkSnapshot(0, blockchainKey, createdAtMs, paidLimitBytes))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ParsedInstruction parseV12PdaMutationInstruction(
|
private static ParsedInstruction parseV12PdaMutationInstruction(
|
||||||
|
|||||||
+95
@@ -1,5 +1,6 @@
|
|||||||
package sync.storage.postgres;
|
package sync.storage.postgres;
|
||||||
|
|
||||||
|
import sync.util.Base58Util;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -272,6 +273,11 @@ public final class PostgresStorageRepository
|
|||||||
snapshots
|
snapshots
|
||||||
);
|
);
|
||||||
|
|
||||||
|
reconcileConfirmedKeyRotations(
|
||||||
|
connection,
|
||||||
|
snapshots
|
||||||
|
);
|
||||||
|
|
||||||
upsertSyncState(
|
upsertSyncState(
|
||||||
connection,
|
connection,
|
||||||
newState
|
newState
|
||||||
@@ -305,6 +311,11 @@ public final class PostgresStorageRepository
|
|||||||
snapshots
|
snapshots
|
||||||
);
|
);
|
||||||
|
|
||||||
|
reconcileConfirmedKeyRotations(
|
||||||
|
connection,
|
||||||
|
snapshots
|
||||||
|
);
|
||||||
|
|
||||||
upsertSyncState(
|
upsertSyncState(
|
||||||
connection,
|
connection,
|
||||||
newState
|
newState
|
||||||
@@ -536,6 +547,90 @@ public final class PostgresStorageRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solana является источником истины для момента завершения ротации PDA.
|
||||||
|
* После upsert current-снимка сверяем все три новых public key и новый blockchainName.
|
||||||
|
* Совпадение переводит локальную машину ROTATING_PDA -> PDA_ROTATED в той же DB-транзакции.
|
||||||
|
*/
|
||||||
|
private void reconcileConfirmedKeyRotations(
|
||||||
|
Connection connection,
|
||||||
|
List<ShineUsersCodec.UserPdaSnapshot> snapshots
|
||||||
|
) throws Exception {
|
||||||
|
if (snapshots == null || snapshots.isEmpty()) return;
|
||||||
|
|
||||||
|
String select = """
|
||||||
|
SELECT id, new_root_key, new_blockchain_key, new_client_key, candidate_blockchain_name
|
||||||
|
FROM key_rotation_sessions
|
||||||
|
WHERE login = ? AND status IN ('CHAIN_READY', 'ROTATING_PDA')
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE
|
||||||
|
""";
|
||||||
|
|
||||||
|
for (ShineUsersCodec.UserPdaSnapshot snapshot : snapshots) {
|
||||||
|
if (snapshot == null || snapshot.login() == null || snapshot.login().isBlank()) continue;
|
||||||
|
|
||||||
|
try (PreparedStatement ps = connection.prepareStatement(select)) {
|
||||||
|
ps.setString(1, snapshot.login());
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) continue;
|
||||||
|
|
||||||
|
long rotationId = rs.getLong("id");
|
||||||
|
String expectedRoot = rs.getString("new_root_key");
|
||||||
|
String expectedBlockchain = rs.getString("new_blockchain_key");
|
||||||
|
String expectedClient = rs.getString("new_client_key");
|
||||||
|
String expectedBlockchainName = rs.getString("candidate_blockchain_name");
|
||||||
|
|
||||||
|
String actualRoot = keyToBase64(snapshot.rootKey());
|
||||||
|
String actualBlockchain = keyToBase64(snapshot.blockchainKey());
|
||||||
|
String actualClient = keyToBase64(snapshot.clientKey());
|
||||||
|
|
||||||
|
if (!expectedRoot.equals(actualRoot)
|
||||||
|
|| !expectedBlockchain.equals(actualBlockchain)
|
||||||
|
|| !expectedClient.equals(actualClient)
|
||||||
|
|| !expectedBlockchainName.equals(snapshot.blockchainName())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
try (PreparedStatement update = connection.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET status = 'PDA_ROTATED',
|
||||||
|
pda_rotation_signature = COALESCE(pda_rotation_signature, ?),
|
||||||
|
updated_at_ms = ?,
|
||||||
|
last_error = NULL,
|
||||||
|
last_error_at_ms = NULL
|
||||||
|
WHERE id = ? AND status IN ('CHAIN_READY', 'ROTATING_PDA')
|
||||||
|
""")) {
|
||||||
|
update.setString(1, snapshot.lastTxSignature() == null || snapshot.lastTxSignature().isBlank() ? null : snapshot.lastTxSignature());
|
||||||
|
update.setLong(2, now);
|
||||||
|
update.setLong(3, rotationId);
|
||||||
|
if (update.executeUpdate() != 1) continue;
|
||||||
|
}
|
||||||
|
try (PreparedStatement update = connection.prepareStatement("""
|
||||||
|
UPDATE solana_user_pda_current
|
||||||
|
SET rotation_status = 'PDA_ROTATED', rotation_session_id = ?
|
||||||
|
WHERE login = ? AND rotation_session_id = ? AND rotation_status IN ('CHAIN_READY', 'ROTATING_PDA')
|
||||||
|
""")) {
|
||||||
|
update.setLong(1, rotationId);
|
||||||
|
update.setString(2, snapshot.login());
|
||||||
|
update.setLong(3, rotationId);
|
||||||
|
if (update.executeUpdate() != 1) {
|
||||||
|
throw new SQLException("Rotation session is not attached to synced login=" + snapshot.login());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String keyToBase64(String base58) {
|
||||||
|
if (base58 == null || base58.isBlank()) return "";
|
||||||
|
byte[] decoded = Base58Util.decode(base58);
|
||||||
|
if (decoded.length != 32) return "";
|
||||||
|
return java.util.Base64.getEncoder().encodeToString(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
private void bindSnapshot(
|
private void bindSnapshot(
|
||||||
PreparedStatement statement,
|
PreparedStatement statement,
|
||||||
ShineUsersCodec.UserPdaSnapshot snapshot,
|
ShineUsersCodec.UserPdaSnapshot snapshot,
|
||||||
|
|||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
package sync.codec;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class ShineUsersCodecLegacyV10Test {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsLegacyV10ServerPdaForBootstrap() {
|
||||||
|
byte[] raw = buildLegacyServerPda();
|
||||||
|
ShineUsersCodec.UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(
|
||||||
|
"legacy-pda", 123L, Base64.getEncoder().encodeToString(raw), "legacy-tx"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals("legacy_server", snapshot.login());
|
||||||
|
assertEquals("legacy_server-001", snapshot.blockchainName());
|
||||||
|
assertEquals(100_000L, snapshot.paidLimitBytes());
|
||||||
|
assertEquals(12_345L, snapshot.usedBytes());
|
||||||
|
assertEquals(7, snapshot.lastBlockNumber());
|
||||||
|
assertTrue(snapshot.isServer());
|
||||||
|
assertEquals(1, snapshot.addressFormatType());
|
||||||
|
assertEquals(0, snapshot.addressFormatVersion());
|
||||||
|
assertEquals("wss://legacy.example/ws", snapshot.serverAddress());
|
||||||
|
assertEquals(java.util.List.of("sync-old"), snapshot.syncServers());
|
||||||
|
assertEquals(java.util.List.of("access-old"), snapshot.accessServers());
|
||||||
|
assertEquals(1, snapshot.blockchainForks().size());
|
||||||
|
assertEquals(snapshot.blockchainKey(), snapshot.blockchainForks().get(0).blockchainKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] buildLegacyServerPda() {
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
bytes(out, "SHiNE".getBytes(StandardCharsets.UTF_8));
|
||||||
|
u8(out, 1);
|
||||||
|
u8(out, 0);
|
||||||
|
u16(out, 0); // record_len patch later
|
||||||
|
u64(out, 1_700_000_000_000L);
|
||||||
|
u64(out, 1_700_000_001_000L);
|
||||||
|
u32(out, 3);
|
||||||
|
bytes(out, repeat(0x11, 32));
|
||||||
|
str(out, "legacy_server");
|
||||||
|
u8(out, 7); // recovery, root, client, blockchain, server, access, trusted
|
||||||
|
|
||||||
|
u8(out, 0); u8(out, 0); bytes(out, repeat(0x21, 32));
|
||||||
|
u8(out, 1); u8(out, 0); bytes(out, repeat(0x22, 32));
|
||||||
|
u8(out, 2); u8(out, 0); bytes(out, repeat(0x23, 32));
|
||||||
|
|
||||||
|
u8(out, 3); u8(out, 0);
|
||||||
|
u8(out, 1);
|
||||||
|
u8(out, 1);
|
||||||
|
str(out, "legacy_server-001");
|
||||||
|
bytes(out, repeat(0x24, 32));
|
||||||
|
u64(out, 100_000L);
|
||||||
|
u64(out, 12_345L);
|
||||||
|
u32(out, 7);
|
||||||
|
bytes(out, repeat(0x25, 32));
|
||||||
|
bytes(out, repeat(0x26, 64));
|
||||||
|
u8(out, 1);
|
||||||
|
str(out, "legacy-arweave-tx");
|
||||||
|
|
||||||
|
u8(out, 30); u8(out, 0);
|
||||||
|
u8(out, 1);
|
||||||
|
u8(out, 1);
|
||||||
|
u8(out, 0);
|
||||||
|
str(out, "wss://legacy.example/ws");
|
||||||
|
u8(out, 1);
|
||||||
|
str(out, "sync-old");
|
||||||
|
|
||||||
|
u8(out, 40); u8(out, 0);
|
||||||
|
u8(out, 1);
|
||||||
|
str(out, "access-old");
|
||||||
|
|
||||||
|
u8(out, 70); u8(out, 0); u8(out, 2);
|
||||||
|
|
||||||
|
bytes(out, repeat(0x27, 64));
|
||||||
|
|
||||||
|
byte[] record = out.toByteArray();
|
||||||
|
record[7] = (byte) (record.length & 0xff);
|
||||||
|
record[8] = (byte) ((record.length >>> 8) & 0xff);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] repeat(int value, int count) {
|
||||||
|
byte[] bytes = new byte[count];
|
||||||
|
java.util.Arrays.fill(bytes, (byte) value);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void str(ByteArrayOutputStream out, String value) {
|
||||||
|
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||||
|
u8(out, bytes.length);
|
||||||
|
bytes(out, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void bytes(ByteArrayOutputStream out, byte[] value) {
|
||||||
|
out.writeBytes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void u8(ByteArrayOutputStream out, long value) {
|
||||||
|
out.write((int) value & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void u16(ByteArrayOutputStream out, long value) {
|
||||||
|
u8(out, value);
|
||||||
|
u8(out, value >>> 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void u32(ByteArrayOutputStream out, long value) {
|
||||||
|
for (int i = 0; i < 4; i++) u8(out, value >>> (8 * i));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void u64(ByteArrayOutputStream out, long value) {
|
||||||
|
for (int i = 0; i < 8; i++) u8(out, value >>> (8 * i));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package server.keyrotation;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ThreadFactory;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/** Небольшой background worker, который продолжает rebuild даже если UI был закрыт. */
|
||||||
|
public final class KeyRotationRebuildScheduler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(KeyRotationRebuildScheduler.class);
|
||||||
|
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||||
|
private static final KeyRotationRebuildService SERVICE = new KeyRotationRebuildService();
|
||||||
|
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
|
||||||
|
@Override public Thread newThread(Runnable r) {
|
||||||
|
Thread t = new Thread(r, "key-rotation-rebuild");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private KeyRotationRebuildScheduler() { }
|
||||||
|
|
||||||
|
public static void startOrLog() {
|
||||||
|
if (!STARTED.compareAndSet(false, true)) return;
|
||||||
|
EXECUTOR.scheduleWithFixedDelay(() -> {
|
||||||
|
try { SERVICE.runReady(4); }
|
||||||
|
catch (Exception e) { log.warn("Key rotation rebuild cycle failed", e); }
|
||||||
|
}, 1, 2, TimeUnit.SECONDS);
|
||||||
|
log.info("Key rotation rebuild scheduler started");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package server.keyrotation;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||||
|
import server.sync.BlockchainResyncGuard;
|
||||||
|
import shine.db.dao.BlockchainResyncCleanupDAO;
|
||||||
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.dao.KeyRotationCandidateBlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.dao.SolanaUserPdaCurrentDAO;
|
||||||
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||||
|
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Автоматически materialize-ит candidate fork после того, как Solana sync подтвердил новое PDA.
|
||||||
|
* Candidate-блоки уже опубликованы в Arweave; здесь они только становятся единственной рабочей
|
||||||
|
* цепочкой PostgreSQL и повторно проходят обычный AddBlock validation/projection path.
|
||||||
|
*/
|
||||||
|
public final class KeyRotationRebuildService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(KeyRotationRebuildService.class);
|
||||||
|
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
|
||||||
|
private final BlockchainResyncCleanupDAO cleanup = BlockchainResyncCleanupDAO.getInstance();
|
||||||
|
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
|
||||||
|
private final SolanaUserPdaCurrentDAO users = SolanaUserPdaCurrentDAO.getInstance();
|
||||||
|
private final Net_AddBlock_Handler addBlock = new Net_AddBlock_Handler();
|
||||||
|
|
||||||
|
public int runReady(int limit) {
|
||||||
|
int processed = 0;
|
||||||
|
try {
|
||||||
|
for (KeyRotationSessionEntry session : rotations.listByStatus(KeyRotationStatus.PDA_ROTATED, limit)) {
|
||||||
|
if (tryRebuild(session)) processed++;
|
||||||
|
}
|
||||||
|
for (KeyRotationSessionEntry session : rotations.listByStatus(KeyRotationStatus.REBUILDING_SERVER, Math.max(1, limit - processed))) {
|
||||||
|
if (tryRebuild(session)) processed++;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Key rotation rebuild scan failed", e);
|
||||||
|
}
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean tryRebuild(KeyRotationSessionEntry session) {
|
||||||
|
if (session == null) return false;
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = rotations.getById(session.getId());
|
||||||
|
if (current == null) return false;
|
||||||
|
if (current.getStatus() == KeyRotationStatus.PDA_ROTATED) {
|
||||||
|
current = rotations.transition(current.getId(), KeyRotationStatus.PDA_ROTATED, KeyRotationStatus.REBUILDING_SERVER);
|
||||||
|
}
|
||||||
|
if (current.getStatus() != KeyRotationStatus.REBUILDING_SERVER) return false;
|
||||||
|
rebuild(current);
|
||||||
|
rotations.clearError(current.getId());
|
||||||
|
rotations.transition(current.getId(), KeyRotationStatus.REBUILDING_SERVER, KeyRotationStatus.WALLET_MIGRATION);
|
||||||
|
log.info("Key rotation rebuild complete: login={} {} -> {}", current.getLogin(), current.getSourceBlockchainName(), current.getCandidateBlockchainName());
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { rotations.recordError(session.getId(), "REBUILDING_SERVER: " + safeMessage(e)); } catch (Exception ignored) { }
|
||||||
|
log.warn("Key rotation rebuild failed: id={} login={}", session.getId(), session.getLogin(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rebuild(KeyRotationSessionEntry session) throws Exception {
|
||||||
|
verifyCurrentPda(session);
|
||||||
|
List<KeyRotationCandidateBlockEntry> candidateBlocks;
|
||||||
|
try (var c = shine.db.DbController.getInstance().getConnection()) {
|
||||||
|
candidateBlocks = candidates.listBySession(c, session.getId());
|
||||||
|
}
|
||||||
|
if (candidateBlocks.size() != session.getProgressTotal()) {
|
||||||
|
throw new IllegalStateException("candidate count mismatch: " + candidateBlocks.size() + "/" + session.getProgressTotal());
|
||||||
|
}
|
||||||
|
for (KeyRotationCandidateBlockEntry block : candidateBlocks) {
|
||||||
|
if (block.isArweavePublishPending()) {
|
||||||
|
throw new IllegalStateException("candidate block is not published: #" + block.getBlockNumber());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String source = session.getSourceBlockchainName();
|
||||||
|
String target = session.getCandidateBlockchainName();
|
||||||
|
ReentrantLock first = BlockchainLocks.lockFor(source.compareTo(target) <= 0 ? source : target);
|
||||||
|
ReentrantLock second = BlockchainLocks.lockFor(source.compareTo(target) <= 0 ? target : source);
|
||||||
|
first.lock();
|
||||||
|
second.lock();
|
||||||
|
boolean sourceGuard = false;
|
||||||
|
boolean targetGuard = false;
|
||||||
|
try {
|
||||||
|
sourceGuard = BlockchainResyncGuard.tryBegin(source);
|
||||||
|
targetGuard = BlockchainResyncGuard.tryBegin(target);
|
||||||
|
if (!sourceGuard || !targetGuard) {
|
||||||
|
throw new IllegalStateException("another resync/rebuild is active");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторный запуск безопасен: сначала убираем любую частично materialized candidate-цепочку.
|
||||||
|
if (states.getByBlockchainName(target) != null) {
|
||||||
|
cleanup.cleanupBlockchainForFullResync(target);
|
||||||
|
}
|
||||||
|
if (states.getByBlockchainName(source) != null) {
|
||||||
|
cleanup.cleanupBlockchainForFullResync(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCandidateState(session);
|
||||||
|
BlockchainResyncGuard.withBypass(target, () -> {
|
||||||
|
for (KeyRotationCandidateBlockEntry candidate : candidateBlocks) {
|
||||||
|
Net_AddBlock_Handler.ArweaveImportResult result = addBlock.addBlockFromArweave(target, candidate.getBlockBytes());
|
||||||
|
if (!result.ok()) {
|
||||||
|
throw new IllegalStateException("candidate replay rejected at #" + candidate.getBlockNumber() + ": " + result.reasonCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Чужие подписанные ссылки сохраняют логическую identity login+number+hash.
|
||||||
|
// Здесь меняется только их локальный cache физического fork.
|
||||||
|
cleanup.refreshLogicalTargetBlockchainCache(session.getLogin(), source, target);
|
||||||
|
} finally {
|
||||||
|
if (targetGuard) BlockchainResyncGuard.end(target);
|
||||||
|
if (sourceGuard) BlockchainResyncGuard.end(source);
|
||||||
|
second.unlock();
|
||||||
|
first.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyCurrentPda(KeyRotationSessionEntry session) throws Exception {
|
||||||
|
SolanaUserPdaCurrentEntry user = users.getByLogin(session.getLogin());
|
||||||
|
if (user == null) throw new IllegalStateException("current PDA not found");
|
||||||
|
if (!session.getCandidateBlockchainName().equals(user.getBlockchainName())) {
|
||||||
|
throw new IllegalStateException("current PDA blockchainName mismatch");
|
||||||
|
}
|
||||||
|
if (!session.getNewBlockchainKey().equals(user.getBlockchainKey())) {
|
||||||
|
throw new IllegalStateException("current PDA blockchain key mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createCandidateState(KeyRotationSessionEntry session) throws Exception {
|
||||||
|
SolanaUserPdaCurrentEntry user = users.getByLogin(session.getLogin());
|
||||||
|
byte[] key = Base64.getDecoder().decode(session.getNewBlockchainKey());
|
||||||
|
if (key.length != 32) throw new IllegalStateException("new blockchain key length != 32");
|
||||||
|
|
||||||
|
BlockchainStateEntry state = new BlockchainStateEntry();
|
||||||
|
state.setBlockchainName(session.getCandidateBlockchainName());
|
||||||
|
state.setLogin(session.getLogin());
|
||||||
|
state.setBlockchainKey(session.getNewBlockchainKey());
|
||||||
|
state.setSizeLimit(user != null && user.getPaidLimitBytes() > 0 ? user.getPaidLimitBytes() : 100_000L);
|
||||||
|
state.setFileSizeBytes(0L);
|
||||||
|
state.setLastBlockNumber(-1);
|
||||||
|
state.setLastBlockHash(null);
|
||||||
|
state.setUpdatedAtMs(System.currentTimeMillis());
|
||||||
|
states.insertIfMissing(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeMessage(Throwable t) {
|
||||||
|
String value = t == null ? "unknown" : String.valueOf(t.getMessage());
|
||||||
|
if (value.length() > 1000) value = value.substring(0, 1000);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package server.ws;
|
package server.ws;
|
||||||
|
|
||||||
|
import server.keyrotation.KeyRotationRebuildScheduler;
|
||||||
|
|
||||||
import org.eclipse.jetty.server.Server;
|
import org.eclipse.jetty.server.Server;
|
||||||
import org.eclipse.jetty.servlet.ServletContextHandler;
|
import org.eclipse.jetty.servlet.ServletContextHandler;
|
||||||
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
|
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
|
||||||
@@ -61,6 +63,7 @@ public final class WsServer {
|
|||||||
// ANS-104: publish locally-created signed user blocks and discover blocks published by other servers.
|
// ANS-104: publish locally-created signed user blocks and discover blocks published by other servers.
|
||||||
ArweaveBlockPublisherScheduler.startOrLog();
|
ArweaveBlockPublisherScheduler.startOrLog();
|
||||||
ArweaveBlockSyncScheduler.startOrLog();
|
ArweaveBlockSyncScheduler.startOrLog();
|
||||||
|
KeyRotationRebuildScheduler.startOrLog();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 2) Запуск Jetty WS
|
// 2) Запуск Jetty WS
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import blockchain.MsgSubType;
|
|||||||
import blockchain.body.ConnectionBody;
|
import blockchain.body.ConnectionBody;
|
||||||
import blockchain.body.CreateChannelBody;
|
import blockchain.body.CreateChannelBody;
|
||||||
import blockchain.body.HeaderBody;
|
import blockchain.body.HeaderBody;
|
||||||
import blockchain.body.TextBody;
|
import blockchain.body.TextLineBody;
|
||||||
|
import blockchain.body.TextReplyBody;
|
||||||
import shine.db.DbController;
|
import shine.db.DbController;
|
||||||
import test.it.blockchain.AddBlockSender;
|
import test.it.blockchain.AddBlockSender;
|
||||||
import test.it.blockchain.ChainState;
|
import test.it.blockchain.ChainState;
|
||||||
@@ -27,7 +28,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
* CONNECTION (type=3):
|
* CONNECTION (type=3):
|
||||||
* - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber)
|
* - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber)
|
||||||
* - всегда имеет target:
|
* - всегда имеет target:
|
||||||
* toBlockchainName + toBlockGlobalNumber + toBlockHash32
|
* toLogin + toBlockGlobalNumber + toBlockHash32
|
||||||
*
|
*
|
||||||
* Правило target для связей/подписок:
|
* Правило target для связей/подписок:
|
||||||
* - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0)
|
* - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0)
|
||||||
@@ -88,12 +89,12 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
// POST в канал "0"
|
// POST в канал "0"
|
||||||
{
|
{
|
||||||
var ln = st1.nextTextLineByRoot(root0);
|
var ln = st1.nextTextLineByRoot(root0);
|
||||||
sender1.send(new TextBody(
|
sender1.send(new TextLineBody(
|
||||||
MsgSubType.TEXT_POST,
|
|
||||||
root0,
|
root0,
|
||||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||||
"U1: story/post in channel 0",
|
MsgSubType.TEXT_POST,
|
||||||
null, null, null
|
null, null, null,
|
||||||
|
"U1: story/post in channel 0"
|
||||||
), t);
|
), t);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,12 +149,12 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
byte[] newsPost0Hash;
|
byte[] newsPost0Hash;
|
||||||
{
|
{
|
||||||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||||||
sender1.send(new TextBody(
|
sender1.send(new TextLineBody(
|
||||||
MsgSubType.TEXT_POST,
|
|
||||||
newsRootBlock,
|
newsRootBlock,
|
||||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||||
"U1: News post #0",
|
MsgSubType.TEXT_POST,
|
||||||
null, null, null
|
null, null, null,
|
||||||
|
"U1: News post #0"
|
||||||
), t);
|
), t);
|
||||||
|
|
||||||
newsPost0Block = st1.lastBlockNumber();
|
newsPost0Block = st1.lastBlockNumber();
|
||||||
@@ -164,26 +165,26 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
// POST #1 в канал "News"
|
// POST #1 в канал "News"
|
||||||
{
|
{
|
||||||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||||||
sender1.send(new TextBody(
|
sender1.send(new TextLineBody(
|
||||||
MsgSubType.TEXT_POST,
|
|
||||||
newsRootBlock,
|
newsRootBlock,
|
||||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||||
"U1: News post #1",
|
MsgSubType.TEXT_POST,
|
||||||
null, null, null
|
null, null, null,
|
||||||
|
"U1: News post #1"
|
||||||
), t);
|
), t);
|
||||||
}
|
}
|
||||||
|
|
||||||
// EDIT_POST (в линии канала) -> target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName)
|
// EDIT_POST -> target на ОРИГИНАЛЬНЫЙ POST по login + number + hash
|
||||||
{
|
{
|
||||||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||||||
sender1.send(new TextBody(
|
sender1.send(new TextLineBody(
|
||||||
MsgSubType.TEXT_EDIT_POST,
|
|
||||||
newsRootBlock,
|
newsRootBlock,
|
||||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||||
"U1: News post #0 (EDIT)",
|
MsgSubType.TEXT_EDIT_POST,
|
||||||
null,
|
|
||||||
newsPost0Block,
|
newsPost0Block,
|
||||||
newsPost0Hash
|
newsPost0Hash,
|
||||||
|
u1,
|
||||||
|
"U1: News post #0 (EDIT)"
|
||||||
), t);
|
), t);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,17 +207,17 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
|
|
||||||
// 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2)
|
// 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2)
|
||||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW,
|
||||||
bch2, u2HeaderBlock, u2HeaderHash,
|
u2, u2HeaderBlock, u2HeaderHash,
|
||||||
"U1 follows U2 (target=U2 HEADER)", t);
|
"U1 follows U2 (target=U2 HEADER)", t);
|
||||||
|
|
||||||
// 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1)
|
// 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1)
|
||||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
||||||
bch1, newsRootBlock, newsRootHash,
|
u1, newsRootBlock, newsRootHash,
|
||||||
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
||||||
|
|
||||||
// 3) U2 подписался на второй канал U1 "Updates"
|
// 3) U2 подписался на второй канал U1 "Updates"
|
||||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
||||||
bch1, updatesRootBlock, updatesRootHash,
|
u1, updatesRootBlock, updatesRootHash,
|
||||||
"U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t);
|
"U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t);
|
||||||
|
|
||||||
assertEquals(2, countConnectionsByOwner(u2, u1),
|
assertEquals(2, countConnectionsByOwner(u2, u1),
|
||||||
@@ -230,30 +231,31 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
|
|
||||||
// 4) FRIEND взаимно (на HEADER)
|
// 4) FRIEND взаимно (на HEADER)
|
||||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||||
bch2, u2HeaderBlock, u2HeaderHash,
|
u2, u2HeaderBlock, u2HeaderHash,
|
||||||
"U1 -> U2: FRIEND", t);
|
"U1 -> U2: FRIEND", t);
|
||||||
|
|
||||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||||
bch1, u1HeaderBlock, u1HeaderHash,
|
u1, u1HeaderBlock, u1HeaderHash,
|
||||||
"U2 -> U1: FRIEND", t);
|
"U2 -> U1: FRIEND", t);
|
||||||
|
|
||||||
// 5) CONTACT несколько
|
// 5) CONTACT несколько
|
||||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
||||||
bch2, u2HeaderBlock, u2HeaderHash,
|
u2, u2HeaderBlock, u2HeaderHash,
|
||||||
"U1 -> U2: CONTACT", t);
|
"U1 -> U2: CONTACT", t);
|
||||||
|
|
||||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT,
|
sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT,
|
||||||
bch1, u1HeaderBlock, u1HeaderHash,
|
u1, u1HeaderBlock, u1HeaderHash,
|
||||||
"U2 -> U1: CONTACT", t);
|
"U2 -> U1: CONTACT", t);
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// USER2 REPLY (ответ в чужой канал)
|
// USER2 REPLY (ответ в чужой канал)
|
||||||
// =========================
|
// =========================
|
||||||
{
|
{
|
||||||
sender2.send(TextBody.newReply(
|
sender2.send(new TextReplyBody(
|
||||||
bch1,
|
MsgSubType.TEXT_REPLY,
|
||||||
newsPost0Block,
|
newsPost0Block,
|
||||||
newsPost0Hash,
|
newsPost0Hash,
|
||||||
|
u1,
|
||||||
"U2: reply to U1 News post #0 (cross-chain)"
|
"U2: reply to U1 News post #0 (cross-chain)"
|
||||||
), t);
|
), t);
|
||||||
}
|
}
|
||||||
@@ -273,12 +275,12 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
|
|
||||||
// U1 -> U3: CONTACT
|
// U1 -> U3: CONTACT
|
||||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
||||||
bch3, u3HeaderBlock, u3HeaderHash,
|
u3, u3HeaderBlock, u3HeaderHash,
|
||||||
"U1 -> U3: CONTACT", t);
|
"U1 -> U3: CONTACT", t);
|
||||||
|
|
||||||
// 6) U2 отписывается только от News
|
// 6) U2 отписывается только от News
|
||||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW,
|
sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW,
|
||||||
bch1, newsRootBlock, newsRootHash,
|
u1, newsRootBlock, newsRootHash,
|
||||||
"U2 unfollows U1 channel 'News'", t);
|
"U2 unfollows U1 channel 'News'", t);
|
||||||
|
|
||||||
assertEquals(1, countConnectionsByOwner(u2, u1),
|
assertEquals(1, countConnectionsByOwner(u2, u1),
|
||||||
@@ -292,7 +294,7 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
|
|
||||||
// 7) U1 убирает U2 из контактов (UNCONTACT)
|
// 7) U1 убирает U2 из контактов (UNCONTACT)
|
||||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
|
||||||
bch2, u2HeaderBlock, u2HeaderHash,
|
u2, u2HeaderBlock, u2HeaderHash,
|
||||||
"U1 -> U2: UNCONTACT", t);
|
"U1 -> U2: UNCONTACT", t);
|
||||||
|
|
||||||
r.ok("IT_03 сценарий блоков + connections выполнен");
|
r.ok("IT_03 сценарий блоков + connections выполнен");
|
||||||
@@ -313,7 +315,7 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
private static void sendConnection(AddBlockSender sender,
|
private static void sendConnection(AddBlockSender sender,
|
||||||
ChainState st,
|
ChainState st,
|
||||||
short subType,
|
short subType,
|
||||||
String toBlockchainName,
|
String toLogin,
|
||||||
int toBlockNumber,
|
int toBlockNumber,
|
||||||
byte[] toBlockHash32,
|
byte[] toBlockHash32,
|
||||||
String logNote,
|
String logNote,
|
||||||
@@ -321,7 +323,7 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
|
|
||||||
if (TestConfig.DEBUG()) {
|
if (TestConfig.DEBUG()) {
|
||||||
TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF)
|
TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF)
|
||||||
+ " to=" + toBlockchainName
|
+ " to=" + toLogin
|
||||||
+ " targetBlock=" + toBlockNumber
|
+ " targetBlock=" + toBlockNumber
|
||||||
+ " note=" + logNote);
|
+ " note=" + logNote);
|
||||||
}
|
}
|
||||||
@@ -330,14 +332,14 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
|
|
||||||
// КОНСТРУКТОР ИЗ ТВОЕГО КОДА:
|
// КОНСТРУКТОР ИЗ ТВОЕГО КОДА:
|
||||||
// ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber,
|
// ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber,
|
||||||
// short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32)
|
// short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32)
|
||||||
sender.send(new ConnectionBody(
|
sender.send(new ConnectionBody(
|
||||||
0, // lineCode для connection линии
|
0, // lineCode для connection линии
|
||||||
ln.prevLineNumber,
|
ln.prevLineNumber,
|
||||||
ln.prevLineHash32,
|
ln.prevLineHash32,
|
||||||
ln.thisLineNumber,
|
ln.thisLineNumber,
|
||||||
subType,
|
subType,
|
||||||
toBlockchainName,
|
toLogin,
|
||||||
toBlockNumber,
|
toBlockNumber,
|
||||||
toBlockHash32
|
toBlockHash32
|
||||||
), timeout);
|
), timeout);
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ public final class SeedDataPopulationHelper {
|
|||||||
line.prevLineHash32,
|
line.prevLineHash32,
|
||||||
line.thisLineNumber,
|
line.thisLineNumber,
|
||||||
relationSubType,
|
relationSubType,
|
||||||
bch(to),
|
to,
|
||||||
0,
|
0,
|
||||||
targetHeaderHash
|
targetHeaderHash
|
||||||
), timeout);
|
), timeout);
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.14.1
|
client.version=1.14.0
|
||||||
server.version=1.12.0
|
server.version=1.12.0
|
||||||
|
|||||||
@@ -113,3 +113,13 @@ cp /path/to/SHiNE-product/application.properties ./application.properties
|
|||||||
После обновления `shine_users` серверный модуль `shine-server-solana-users-sync` должен обновляться вместе с программой: текущий codec принимает только PDA 1.2. Legacy PDA 1.0 не мигрируются; тестовые legacy-записи можно закрыть временной инструкцией `close_legacy_pda`.
|
После обновления `shine_users` серверный модуль `shine-server-solana-users-sync` должен обновляться вместе с программой: текущий codec принимает только PDA 1.2. Legacy PDA 1.0 не мигрируются; тестовые legacy-записи можно закрыть временной инструкцией `close_legacy_pda`.
|
||||||
|
|
||||||
Миграция PostgreSQL v25 добавляет `blockchain_forks_json`. Старые compatibility-колонки продолжают содержать активный (последний) fork, а полный список ключей fork сохраняется отдельно и используется для поиска владельца Arweave-записей по любому историческому blockchain key.
|
Миграция PostgreSQL v25 добавляет `blockchain_forks_json`. Старые compatibility-колонки продолжают содержать активный (последний) fork, а полный список ключей fork сохраняется отдельно и используется для поиска владельца Arweave-записей по любому историческому blockchain key.
|
||||||
|
|
||||||
|
## PostgreSQL migration v26: key rotation runtime state
|
||||||
|
|
||||||
|
Migration v26 добавляет server-local поля `rotation_status` / `rotation_session_id` в `solana_user_pda_current` и таблицу `key_rotation_sessions`.
|
||||||
|
|
||||||
|
Это локальное состояние длительной смены ключей, а не часть Solana PDA. Solana users sync продолжает обновлять только PDA-поля и не должен затирать rotation-state. После обновления сервера миграция применяется стандартным `DatabaseInitializer` автоматически.
|
||||||
|
|
||||||
|
## Миграции key rotation
|
||||||
|
|
||||||
|
При обновлении сервера DatabaseInitializer последовательно применяет migration v26 (state machine смены ключей) и v27 (отдельные candidate-блоки будущего fork). Ручного создания таблиц не требуется. Candidate-блоки не входят в текущую таблицу `blocks` до финального переключения fork.
|
||||||
|
|||||||
@@ -43,6 +43,8 @@
|
|||||||
- `bad_block_number` — нарушена последовательность;
|
- `bad_block_number` — нарушена последовательность;
|
||||||
- `bad_prev_hash` — нарушена SHiNE hash chain;
|
- `bad_prev_hash` — нарушена SHiNE hash chain;
|
||||||
- `bad_block_bytes` — DataItem/Frame не парсится.
|
- `bad_block_bytes` — DataItem/Frame не парсится.
|
||||||
|
- `key_rotation_in_progress` (HTTP/status `423`) — для пользователя уже запущена смена ключей; обычный `AddBlock` временно запрещён до завершения/отмены ротации;
|
||||||
|
- `key_rotation_check_failed` — сервер не смог проверить локальный rotation status и отклонил запись fail-closed.
|
||||||
|
|
||||||
## Storage/publish
|
## Storage/publish
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,14 @@
|
|||||||
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
||||||
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
|
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
|
||||||
| `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна |
|
| `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна |
|
||||||
|
| `KeyRotationStart` | `19_Key_Rotation_API.md` | начать смену ключей сразу в состоянии `COPYING_CHAIN` |
|
||||||
|
| `KeyRotationStatus` | `19_Key_Rotation_API.md` | получить текущий этап и прогресс смены ключей |
|
||||||
|
| `KeyRotationAddBlock` | `19_Key_Rotation_API.md` | добавить подписанный новым blockchain key candidate-блок будущего fork |
|
||||||
|
| `KeyRotationFinishChain` | `19_Key_Rotation_API.md` | проверить полную публикацию candidate-chain и перевести её в `CHAIN_READY` |
|
||||||
|
| `KeyRotationRotatePda` | `19_Key_Rotation_API.md` | зафиксировать отправленную Solana-ротацию PDA и ждать подтверждения sync |
|
||||||
|
| `KeyRotationContinue` | `19_Key_Rotation_API.md` | продолжить интерактивный post-PDA этап; сейчас wallet/DM отмечаются как `NOT_IMPLEMENTED` и пропускаются |
|
||||||
|
| `KeyRotationAbort` | `19_Key_Rotation_API.md` | прервать ротацию до отправки Solana PDA-ротации |
|
||||||
|
| `GetMyBlockchain` | `20_Get_My_Blockchain_API.md` | постранично читать текущую активную собственную цепочку для аудита и выбора fork point |
|
||||||
| `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки |
|
| `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки |
|
||||||
| `Ping` | `05_Technical_Requests_API.md` | keep-alive |
|
| `Ping` | `05_Technical_Requests_API.md` | keep-alive |
|
||||||
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
|
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
# Key Rotation API
|
||||||
|
|
||||||
|
Этот раздел описывает публичные JSON/WebSocket операции мастера смены ключей пользователя.
|
||||||
|
|
||||||
|
Публично доступны:
|
||||||
|
|
||||||
|
- `KeyRotationStart`
|
||||||
|
- `KeyRotationStatus`
|
||||||
|
- `KeyRotationAddBlock`
|
||||||
|
- `KeyRotationFinishChain`
|
||||||
|
- `KeyRotationRotatePda`
|
||||||
|
- `KeyRotationContinue`
|
||||||
|
- `KeyRotationAbort`
|
||||||
|
|
||||||
|
После `PDA_ROTATED` отдельный фоновый worker автоматически переводит ротацию в `REBUILDING_SERVER`, делает новую candidate-chain единственной рабочей цепочкой PostgreSQL и затем переводит процесс в `WALLET_MIGRATION`.
|
||||||
|
|
||||||
|
## Общие правила
|
||||||
|
|
||||||
|
- операции доступны только авторизованному LOCAL-пользователю своего access server;
|
||||||
|
- login берётся из авторизованной сессии и не передаётся в payload;
|
||||||
|
- приватные ключи и пароли серверу не передаются никогда;
|
||||||
|
- в БД сохраняются только старые/новые публичные ключи;
|
||||||
|
- первая серверная запись появляется сразу в состоянии `COPYING_CHAIN`; состояния `PREPARING` в БД нет;
|
||||||
|
- во время `KeyRotationStart` сервер захватывает тот же per-blockchain lock, что использует обычный `AddBlock`, и фиксирует непротиворечивый source tip.
|
||||||
|
|
||||||
|
## `KeyRotationStart`
|
||||||
|
|
||||||
|
Создаёт новую серверную сессию ротации.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationStart",
|
||||||
|
"requestId": "kr-1",
|
||||||
|
"payload": {
|
||||||
|
"newRootKey": "<Base58 или Base64 публичного ключа 32B>",
|
||||||
|
"newBlockchainKey": "<Base58 или Base64 публичного ключа 32B>",
|
||||||
|
"newClientKey": "<Base58 или Base64 публичного ключа 32B>",
|
||||||
|
"forkFromBlock": 120,
|
||||||
|
"forkFromHash": "<64 hex>",
|
||||||
|
"reasonCode": 1,
|
||||||
|
"comment": "Плановая смена пароля"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`reasonCode`:
|
||||||
|
|
||||||
|
1. обычная ротация;
|
||||||
|
2. возможная компрометация;
|
||||||
|
3. подтверждённая компрометация / rollback;
|
||||||
|
4. recovery.
|
||||||
|
|
||||||
|
Сервер самостоятельно берёт из текущего PDA:
|
||||||
|
|
||||||
|
- `oldRootKey`;
|
||||||
|
- `oldBlockchainKey`;
|
||||||
|
- `oldClientKey`;
|
||||||
|
- текущий `sourceBlockchainName`.
|
||||||
|
|
||||||
|
Также сервер самостоятельно фиксирует текущий tip цепочки. Клиент не может подменить эти значения в запросе.
|
||||||
|
|
||||||
|
`forkFromBlock/forkFromHash` обязаны указывать на реально существующий блок текущей активной цепочки.
|
||||||
|
|
||||||
|
Новые root/blockchain/client keys должны быть валидными 32-байтовыми публичными ключами, отличаться от соответствующих старых ключей и друг от друга.
|
||||||
|
|
||||||
|
После успеха создаётся `key_rotation_sessions` со статусом `COPYING_CHAIN`. `progressTotal` равен количеству будущих candidate-блоков: копия `0..forkFromBlock` плюс `TECH_FORK`.
|
||||||
|
|
||||||
|
### Success response
|
||||||
|
|
||||||
|
Ответ использует тот же payload состояния, что и `KeyRotationStatus`.
|
||||||
|
|
||||||
|
## `KeyRotationStatus`
|
||||||
|
|
||||||
|
Возвращает текущее состояние ротации авторизованного пользователя.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationStatus",
|
||||||
|
"requestId": "kr-status-1",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Если активной ротации нет:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationStatus",
|
||||||
|
"requestId": "kr-status-1",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"rotationStatus": "NONE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Если ротация есть, payload содержит:
|
||||||
|
|
||||||
|
- `rotationSessionId`;
|
||||||
|
- `rotationStatus`;
|
||||||
|
- `sourceBlockchainName` / `candidateBlockchainName`;
|
||||||
|
- старые и новые публичные root/blockchain/client keys;
|
||||||
|
- `forkFromBlock` / `forkFromHash`;
|
||||||
|
- `sourceTipBlock` / `sourceTipHash`;
|
||||||
|
- `reasonCode` / `comment`;
|
||||||
|
- `progressCurrent` / `progressTotal`;
|
||||||
|
- `pdaRotationSignature` (когда появится на последующем этапе);
|
||||||
|
- `walletMigrationStatus`;
|
||||||
|
- `messageMigrationStatus`;
|
||||||
|
- `lastError` / `retryCount`;
|
||||||
|
- `createdAtMs` / `updatedAtMs`.
|
||||||
|
|
||||||
|
Любая авторизованная сессия пользователя может читать этот статус. Состояние ротации принадлежит аккаунту, а не конкретному WebSocket-сеансу.
|
||||||
|
|
||||||
|
|
||||||
|
## `KeyRotationAddBlock`
|
||||||
|
|
||||||
|
Принимает один ANS-104 DataItem будущего fork на этапе `COPYING_CHAIN`. Candidate-блоки хранятся отдельно от обычной таблицы `blocks`, поэтому до переключения fork они **не создают лайки, ответы, каналы, связи и другие materialized effects**.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationAddBlock",
|
||||||
|
"requestId": "kr-block-1",
|
||||||
|
"payload": {
|
||||||
|
"blockNumber": 0,
|
||||||
|
"prevBlockHash": "",
|
||||||
|
"blockBytesB64": "<полный ANS-104 DataItem в Base64>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- операция доступна только при `rotationStatus=COPYING_CHAIN`;
|
||||||
|
- блоки принимаются строго последовательно: `0..forkFromBlock`, затем один `TECH_FORK` с номером `forkFromBlock+1`;
|
||||||
|
- каждый DataItem обязан быть подписан `newBlockchainKey`;
|
||||||
|
- для копируемых блоков Frame, ANS-104 tags, target и anchor должны полностью совпадать с исходным блоком; меняются только owner/signature/DataItem id;
|
||||||
|
- последний блок обязан быть `TECH_FORK`, а его parent key, fork point, старый tip, reason/comment и число отброшенных блоков должны совпадать с `KeyRotationStart`;
|
||||||
|
- повтор уже принятого идентичного блока безопасен и возвращает success; другой DataItem/hash на том же номере возвращает conflict;
|
||||||
|
- candidate-блоки попадают в отдельную очередь Arweave/Turbo publisher-а и имеют приоритет перед обычными блоками;
|
||||||
|
- `progressCurrent` увеличивается **только после фактической успешной публикации** DataItem в Arweave/Turbo. Поэтому `KeyRotationStatus` показывает реальный прогресс публикации, а не только приём сервером.
|
||||||
|
|
||||||
|
### Основные ошибки
|
||||||
|
|
||||||
|
- `KEY_ROTATION_NOT_COPYING` — ротация не находится в `COPYING_CHAIN`;
|
||||||
|
- `KEY_ROTATION_BLOCK_OUT_OF_ORDER` — пропущен предыдущий candidate-блок;
|
||||||
|
- `KEY_ROTATION_BLOCK_CONFLICT` — на этом номере уже сохранён другой candidate-блок;
|
||||||
|
- `KEY_ROTATION_BAD_SIGNATURE` — DataItem подписан не новым blockchain key;
|
||||||
|
- `KEY_ROTATION_FRAME_MISMATCH` — копируемый Frame отличается от исходной цепочки;
|
||||||
|
- `KEY_ROTATION_TAGS_MISMATCH` — изменены ANS-104 tags копируемого блока;
|
||||||
|
- `KEY_ROTATION_TECH_FORK_REQUIRED` — вместо финального `TECH_FORK` передан другой блок;
|
||||||
|
- ошибки `KEY_ROTATION_TECH_FORK_*` — поля `TECH_FORK` не соответствуют зафиксированной rotation session.
|
||||||
|
|
||||||
|
## Основные ошибки `KeyRotationStart`
|
||||||
|
|
||||||
|
- `AUTH_REQUIRED` — нет авторизованной сессии;
|
||||||
|
- `KEY_ROTATION_ALREADY_ACTIVE` — для login уже выполняется ротация;
|
||||||
|
- `KEY_ROTATION_BAD_FIELDS` — некорректные public keys/hash;
|
||||||
|
- `KEY_ROTATION_BAD_NEW_KEYS` — ключи не изменились либо новые ключи совпадают друг с другом;
|
||||||
|
- `KEY_ROTATION_BAD_REASON` — reason вне диапазона `1..4`;
|
||||||
|
- `KEY_ROTATION_COMMENT_TOO_LONG` — комментарий больше 1024 UTF-8 байт;
|
||||||
|
- `KEY_ROTATION_BAD_FORK_POINT` — неверная точка fork;
|
||||||
|
- `KEY_ROTATION_FORK_BLOCK_NOT_FOUND` — выбранный блок отсутствует;
|
||||||
|
- `KEY_ROTATION_FORK_HASH_MISMATCH` — переданный hash не совпадает с сервером;
|
||||||
|
- `KEY_ROTATION_SESSION_STALE` — авторизованная сессия содержит уже неактуальный blockchainName.
|
||||||
|
|
||||||
|
|
||||||
|
## `KeyRotationFinishChain`
|
||||||
|
|
||||||
|
Финализирует этап построения candidate-chain. Операция **не меняет PDA** и не переключает активную цепочку пользователя: она только доказывает, что будущий fork уже полностью сохранён и опубликован в Arweave/Turbo.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationFinishChain",
|
||||||
|
"requestId": "kr-finish-chain-1",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед переходом в `CHAIN_READY` сервер повторно проверяет:
|
||||||
|
|
||||||
|
- количество candidate-блоков равно `progressTotal` (`0..forkFromBlock` + один `TECH_FORK`);
|
||||||
|
- каждый candidate-блок уже подтверждён Arweave/Turbo publisher-ом;
|
||||||
|
- номера идут строго `0,1,2,...` без дырок;
|
||||||
|
- `blockHash` и `DataItem id` совпадают с сохранёнными значениями;
|
||||||
|
- каждый DataItem подписан `newBlockchainKey`;
|
||||||
|
- `block 0` имеет нулевой `prevHash`, а каждый следующий блок ссылается на hash предыдущего Frame;
|
||||||
|
- копируемые блоки всё ещё байт-в-байт совпадают с выбранным префиксом исходной цепочки по Frame, tags, target и anchor;
|
||||||
|
- последний блок является корректным `TECH_FORK` и повторяет зафиксированные fork point, parent tip, reason/comment и число отброшенных блоков.
|
||||||
|
|
||||||
|
Только после успешной финальной проверки выполняется:
|
||||||
|
|
||||||
|
`COPYING_CHAIN -> CHAIN_READY`.
|
||||||
|
|
||||||
|
Повторный `KeyRotationFinishChain`, когда ротация уже находится в `CHAIN_READY`, идемпотентно возвращает success. Это позволяет безопасно вызывать операцию из нескольких сессий или повторить её после потери ответа.
|
||||||
|
|
||||||
|
### Основные ошибки
|
||||||
|
|
||||||
|
- `KEY_ROTATION_NOT_ACTIVE` — активная ротация отсутствует;
|
||||||
|
- `KEY_ROTATION_NOT_COPYING` — текущий этап уже не позволяет завершать candidate-chain;
|
||||||
|
- `KEY_ROTATION_CHAIN_INCOMPLETE` — сервер получил не все candidate-блоки;
|
||||||
|
- `KEY_ROTATION_CHAIN_NOT_PUBLISHED` — не все DataItem подтверждены Arweave/Turbo publisher-ом;
|
||||||
|
- `KEY_ROTATION_CANDIDATE_GAP` / `KEY_ROTATION_CHAIN_HASH_MISMATCH` — нарушена последовательность candidate-chain;
|
||||||
|
- `KEY_ROTATION_BAD_SIGNATURE` — сохранённый candidate не подтверждается новым blockchain key;
|
||||||
|
- `KEY_ROTATION_TECH_FORK_*` — финальный `TECH_FORK` больше не соответствует rotation session;
|
||||||
|
- `KEY_ROTATION_FINISH_CHAIN_RACE` — состояние ротации было одновременно изменено другой операцией.
|
||||||
|
|
||||||
|
|
||||||
|
## `KeyRotationRotatePda`
|
||||||
|
|
||||||
|
Фиксирует, что клиент уже локально подписал и отправил в Solana транзакцию полной ротации PDA.
|
||||||
|
|
||||||
|
Сервер **не получает приватные ключи и не подписывает транзакцию**. Клиент передаёт только публичную Solana transaction signature.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationRotatePda",
|
||||||
|
"requestId": "kr-rotate-pda-1",
|
||||||
|
"payload": {
|
||||||
|
"pdaRotationSignature": "<Base58 Solana transaction signature>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Операция разрешена только после `CHAIN_READY`. Сервер атомарно сохраняет signature и переводит:
|
||||||
|
|
||||||
|
`CHAIN_READY -> ROTATING_PDA`.
|
||||||
|
|
||||||
|
После этого отмена ротации уже запрещена: отправленная Solana-транзакция может подтвердиться позже, даже если клиент потерял соединение.
|
||||||
|
|
||||||
|
Подтверждением успешной ротации служит **не сам факт наличия signature**, а фактический current PDA, увиденный Solana sync. Sync требует одновременного совпадения:
|
||||||
|
|
||||||
|
- `rootKey == newRootKey`;
|
||||||
|
- `blockchainKey == newBlockchainKey`;
|
||||||
|
- `clientKey == newClientKey`;
|
||||||
|
- `blockchainName == candidateBlockchainName`.
|
||||||
|
|
||||||
|
Только после этого сервер атомарно переводит:
|
||||||
|
|
||||||
|
`ROTATING_PDA -> PDA_ROTATED`.
|
||||||
|
|
||||||
|
Если Solana sync успел увидеть новое PDA раньше вызова `KeyRotationRotatePda`, он умеет подтвердить ожидаемые ключи прямо из `CHAIN_READY`. Поэтому потеря клиентского запроса после уже подтверждённой Solana-транзакции не оставляет ротацию зависшей. Если API-вызов всё же приходит, handler идемпотентно возвращает текущее подтверждённое состояние.
|
||||||
|
|
||||||
|
Повтор с той же signature идемпотентен. Другая signature для уже начатого `ROTATING_PDA` возвращает conflict.
|
||||||
|
|
||||||
|
### Основные ошибки
|
||||||
|
|
||||||
|
- `KEY_ROTATION_NOT_ACTIVE` — активной ротации нет;
|
||||||
|
- `KEY_ROTATION_NOT_CHAIN_READY` — candidate-chain ещё не подтверждена;
|
||||||
|
- `KEY_ROTATION_BAD_SOLANA_SIGNATURE` — signature отсутствует или не Base58;
|
||||||
|
- `KEY_ROTATION_PDA_SIGNATURE_CONFLICT` — для этой ротации уже сохранена другая signature;
|
||||||
|
- `KEY_ROTATION_ROTATE_PDA_FAILED` — внутренняя ошибка фиксации этапа.
|
||||||
|
|
||||||
|
|
||||||
|
## Автоматический rebuild после `PDA_ROTATED`
|
||||||
|
|
||||||
|
После подтверждения нового current PDA сервер сам выполняет:
|
||||||
|
|
||||||
|
`PDA_ROTATED -> REBUILDING_SERVER -> WALLET_MIGRATION`.
|
||||||
|
|
||||||
|
Во время rebuild:
|
||||||
|
|
||||||
|
- проверяется, что current PDA действительно указывает на `candidateBlockchainName/newBlockchainKey`;
|
||||||
|
- старая активная цепочка удаляется из рабочих PostgreSQL-таблиц;
|
||||||
|
- candidate-блоки повторно проходят обычный `AddBlock` validation/projection path;
|
||||||
|
- runtime-cache `to_bch_name` у логических ссылок `login + blockNumber + blockHash` перепривязывается к новому fork;
|
||||||
|
- входящие `likes_count/replies_count` пересчитываются;
|
||||||
|
- после `COMPLETE` временные строки candidate-chain удаляются из PostgreSQL.
|
||||||
|
|
||||||
|
Если rebuild прерывается, `REBUILDING_SERVER` остаётся активным, ошибка записывается в `lastError/retryCount`, а worker безопасно повторяет rebuild.
|
||||||
|
|
||||||
|
## `KeyRotationContinue`
|
||||||
|
|
||||||
|
Продолжает интерактивные post-PDA этапы. В текущей версии два будущих этапа являются честными заглушками:
|
||||||
|
|
||||||
|
- `WALLET_MIGRATION`: `walletMigrationStatus = NOT_IMPLEMENTED`, затем переход в `MESSAGE_MIGRATION`;
|
||||||
|
- `MESSAGE_MIGRATION`: `messageMigrationStatus = NOT_IMPLEMENTED`, затем `FINALIZING -> COMPLETE`.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationContinue",
|
||||||
|
"requestId": "kr-continue-1",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`KeyRotationContinue` не переводит деньги и не перешифровывает DM. Это специально оставленные точки расширения для будущей реализации.
|
||||||
|
|
||||||
|
## `KeyRotationAbort`
|
||||||
|
|
||||||
|
Прерывает ротацию только пока Solana-ротация PDA ещё не могла быть отправлена:
|
||||||
|
|
||||||
|
- разрешено из `COPYING_CHAIN`;
|
||||||
|
- разрешено из `CHAIN_READY`;
|
||||||
|
- начиная с `ROTATING_PDA` отмена запрещена, процесс можно только довести вперёд.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationAbort",
|
||||||
|
"requestId": "kr-abort-1",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
При `ABORTED` временные candidate-строки удаляются из PostgreSQL. Уже опубликованные Arweave DataItem остаются неизменяемым сиротским историческим следом и не становятся активной цепочкой, поскольку PDA не переключён.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# GetMyBlockchain API
|
||||||
|
|
||||||
|
`GetMyBlockchain` — авторизованное постраничное чтение **только текущей активной версии собственного блокчейна**. API используется постоянным экраном «Мой блокчейн» и мастером смены ключей для выбора последнего доверенного блока.
|
||||||
|
|
||||||
|
Login и активный `blockchainName` сервер определяет из авторизованной сессии/current PDA; клиент не может запросить этим методом чужую цепочку.
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetMyBlockchain",
|
||||||
|
"requestId": "my-bch-1",
|
||||||
|
"payload": {
|
||||||
|
"beforeBlock": 500,
|
||||||
|
"limit": 50,
|
||||||
|
"includeBlockBytes": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Поля:
|
||||||
|
|
||||||
|
- `beforeBlock` — необязательный номер верхней границы страницы; если отсутствует, чтение начинается с current tip;
|
||||||
|
- `limit` — `1..100`, default `50`;
|
||||||
|
- `includeBlockBytes` — при `true` дополнительно вернуть полный ANS-104 DataItem в Base64.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
Payload содержит:
|
||||||
|
|
||||||
|
- `login`;
|
||||||
|
- `blockchainName`;
|
||||||
|
- `tipBlockNumber` / `tipBlockHash`;
|
||||||
|
- `nextBeforeBlock` для следующей страницы;
|
||||||
|
- `blocks[]` в порядке от новых к старым.
|
||||||
|
|
||||||
|
Каждый элемент `blocks[]` содержит:
|
||||||
|
|
||||||
|
- `blockNumber`;
|
||||||
|
- `blockHash` / `prevBlockHash`;
|
||||||
|
- `timestampMs`;
|
||||||
|
- `msgType` / `msgSubType` / `msgVersion`;
|
||||||
|
- для target-блоков: `toLogin + toBlockNumber + toBlockHash`;
|
||||||
|
- `blockBytesB64`, только если запрошен `includeBlockBytes=true`.
|
||||||
|
|
||||||
|
После fork API показывает только новую активную ветку PostgreSQL. Исторические fork при необходимости восстанавливаются из Arweave/PDA history отдельным будущим viewer-механизмом.
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## Быстрая карта типов
|
## Быстрая карта типов
|
||||||
|
|
||||||
- `type=0` — TECH: HEADER, CREATE_CHANNEL.
|
- `type=0` — TECH: HEADER, CREATE_CHANNEL, FORK.
|
||||||
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE.
|
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE.
|
||||||
- `type=2` — REACTION: LIKE/UNLIKE.
|
- `type=2` — REACTION: LIKE/UNLIKE.
|
||||||
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
|
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
|
||||||
|
|||||||
@@ -12,7 +12,43 @@ TECH-тип покрывает системные записи цепочки.
|
|||||||
- создание нового канала;
|
- создание нового канала;
|
||||||
- хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`.
|
- хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`.
|
||||||
|
|
||||||
|
3. `subType=2` — `TECH_FORK`
|
||||||
|
- первый новый блок после точной перепубликации выбранного префикса предыдущего fork новым blockchain key;
|
||||||
|
- связывает новую активную цепочку с предыдущей и фиксирует точку rollback/продолжения.
|
||||||
|
|
||||||
|
### `TECH_FORK` body (`version=1`)
|
||||||
|
|
||||||
|
Big-endian:
|
||||||
|
|
||||||
|
- `parentBlockchainKey[32]` — public key предыдущего fork;
|
||||||
|
- `forkPointBlockNumber[4]` — последний блок старой цепочки, сохранённый в новом fork;
|
||||||
|
- `forkPointBlockHash32[32]`;
|
||||||
|
- `forkPointTimestampMs[8]`;
|
||||||
|
- `parentTipBlockNumber[4]` — tip старой цепочки на момент начала ротации;
|
||||||
|
- `parentTipBlockHash32[32]`;
|
||||||
|
- `parentTipTimestampMs[8]`;
|
||||||
|
- `discardedBlocksCount[4]` — `parentTipBlockNumber - forkPointBlockNumber`;
|
||||||
|
- `reasonCode[1]`;
|
||||||
|
- `commentUtf8Length[2]`;
|
||||||
|
- `comment[N]` — произвольный комментарий пользователя, максимум 1024 UTF-8 байт.
|
||||||
|
|
||||||
|
`reasonCode`:
|
||||||
|
|
||||||
|
- `1` — `ROUTINE_ROTATION`: обычная смена пароля/ключей, компрометация не предполагается;
|
||||||
|
- `2` — `POSSIBLE_COMPROMISE`: возможная компрометация, неизвестные записи не подтверждены;
|
||||||
|
- `3` — `CONFIRMED_COMPROMISE_ROLLBACK`: обнаружены нежелательные/чужие записи и выполнен rollback;
|
||||||
|
- `4` — `RECOVERY`: восстановление доступа recovery-механизмом.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- блоки `0..forkPointBlockNumber` в новом fork должны быть точными Frame-копиями выбранного префикса предыдущей цепочки;
|
||||||
|
- `TECH_FORK` идёт сразу после этого префикса и является первым действительно новым Frame нового fork;
|
||||||
|
- если история сохранена полностью, `forkPointBlockNumber == parentTipBlockNumber` и `discardedBlocksCount == 0`;
|
||||||
|
- если сохраняется только genesis, новый fork содержит прежний `block 0`, а `block 1` является `TECH_FORK`;
|
||||||
|
- новый blockchain key в body не дублируется: он определяется owner/signature нового ANS-104 DataItem.
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|
||||||
- инициализация блокчейна;
|
- инициализация блокчейна;
|
||||||
- управление набором каналов пользователя.
|
- управление набором каналов пользователя;
|
||||||
|
- фиксация происхождения нового fork и причины ротации/rollback.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ TEXT-тип хранит сообщения, материалы и редакт
|
|||||||
|
|
||||||
3. `subType=20` — `TEXT_REPLY`
|
3. `subType=20` — `TEXT_REPLY`
|
||||||
- ответ на сообщение;
|
- ответ на сообщение;
|
||||||
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
|
- target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
|
||||||
|
|
||||||
4. `subType=21` — `TEXT_EDIT_REPLY`
|
4. `subType=21` — `TEXT_EDIT_REPLY`
|
||||||
- редактирование ответа;
|
- редактирование ответа;
|
||||||
@@ -23,7 +23,7 @@ TEXT-тип хранит сообщения, материалы и редакт
|
|||||||
|
|
||||||
5. `subType=30` — `TEXT_RATING`
|
5. `subType=30` — `TEXT_RATING`
|
||||||
- target-based отзыв на конкретный блок;
|
- target-based отзыв на конкретный блок;
|
||||||
- содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
|
- содержит target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
|
||||||
- не является сообщением линии канала.
|
- не является сообщением линии канала.
|
||||||
|
|
||||||
6. `subType=50` — `TEXT_REPOST`
|
6. `subType=50` — `TEXT_REPOST`
|
||||||
@@ -57,6 +57,20 @@ TEXT-тип хранит сообщения, материалы и редакт
|
|||||||
|
|
||||||
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
|
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
|
||||||
|
|
||||||
|
|
||||||
|
## Общий target-формат TEXT
|
||||||
|
|
||||||
|
Для `TEXT_EDIT_POST`, `TEXT_REPLY`, `TEXT_EDIT_REPLY`, `TEXT_RATING` и `TEXT_REPOST` ссылка на цель хранится как:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[1] toLoginLen (uint8)
|
||||||
|
[N] toLogin UTF-8
|
||||||
|
[4] toBlockGlobalNumber
|
||||||
|
[32] toBlockHash32
|
||||||
|
```
|
||||||
|
|
||||||
|
`blockchainName`/номер fork в подписываемые байты target не входит. Одинаковые `login + blockNumber + blockHash` считаются одной логической целью после перепубликации сохранённого префикса при fork.
|
||||||
|
|
||||||
## Правило для edit
|
## Правило для edit
|
||||||
|
|
||||||
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
||||||
|
|||||||
@@ -4,11 +4,25 @@
|
|||||||
|
|
||||||
1. `subType=1` — `REACTION_LIKE`
|
1. `subType=1` — `REACTION_LIKE`
|
||||||
- лайк на целевой блок;
|
- лайк на целевой блок;
|
||||||
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
- хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||||
2. `subType=2` — `REACTION_UNLIKE`
|
2. `subType=2` — `REACTION_UNLIKE`
|
||||||
- снятие лайка с целевого блока;
|
- снятие лайка с целевого блока;
|
||||||
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
- хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|
||||||
- реакция на текстовые сообщения (и потенциально другие target-блоки, если это разрешено бизнес-логикой).
|
- реакция на текстовые сообщения (и потенциально другие target-блоки, если это разрешено бизнес-логикой).
|
||||||
|
|
||||||
|
|
||||||
|
## Формат target
|
||||||
|
|
||||||
|
В подписанных байтах target больше не хранит имя fork/blockchain. Формат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[1] toLoginLen (uint8)
|
||||||
|
[N] toLogin UTF-8
|
||||||
|
[4] toBlockGlobalNumber
|
||||||
|
[32] toBlockHash32
|
||||||
|
```
|
||||||
|
|
||||||
|
Логическая идентичность цели: `toLogin + toBlockGlobalNumber + toBlockHash32`. Поэтому ссылка остаётся той же после fork, если номер и hash исходного блока сохранены.
|
||||||
|
|||||||
@@ -18,7 +18,18 @@ CONNECTION-тип описывает социальные связи и подп
|
|||||||
## Общий формат payload
|
## Общий формат payload
|
||||||
|
|
||||||
- line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`)
|
- line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`)
|
||||||
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`)
|
- target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`)
|
||||||
|
|
||||||
|
## Бинарный target
|
||||||
|
|
||||||
|
```text
|
||||||
|
[1] toLoginLen (uint8)
|
||||||
|
[N] toLogin UTF-8
|
||||||
|
[4] toBlockGlobalNumber
|
||||||
|
[32] toBlockHash32
|
||||||
|
```
|
||||||
|
|
||||||
|
Имя fork/blockchain в target не хранится.
|
||||||
|
|
||||||
## Правила target
|
## Правила target
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
Все `STATUS_ACTION` используют один и тот же бинарный body-формат:
|
Все `STATUS_ACTION` используют один и тот же бинарный body-формат:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
[1] toBlockchainNameLen (uint8)
|
[1] toLoginLen (uint8)
|
||||||
[N] toBlockchainName UTF-8
|
[N] toLogin UTF-8
|
||||||
[4] toBlockGlobalNumber
|
[4] toBlockGlobalNumber
|
||||||
[32] toBlockHash32
|
[32] toBlockHash32
|
||||||
[2] textLenBytes (uint16)
|
[2] textLenBytes (uint16)
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
Где:
|
Где:
|
||||||
|
|
||||||
- `toBlockchainName` — блокчейн, в котором находится целевой материал;
|
- `toLogin` — login владельца целевого блока; номер fork в target не хранится;
|
||||||
- `toBlockGlobalNumber` — номер целевого блока;
|
- `toBlockGlobalNumber` — номер целевого блока;
|
||||||
- `toBlockHash32` — хэш целевого блока;
|
- `toBlockHash32` — хэш целевого блока;
|
||||||
- `text` — опциональное пояснение пользователя к статусу.
|
- `text` — опциональное пояснение пользователя к статусу.
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
## 2026-09-27 — TECH_FORK v1
|
||||||
|
|
||||||
|
- Добавлен `type=0 / subType=2 / version=1` (`TECH_FORK`).
|
||||||
|
- `TECH_FORK` фиксирует parent blockchain key, точку сохранённой истории, старый tip, число отброшенных блоков, reason code и пользовательский комментарий.
|
||||||
|
- Новый blockchain key не дублируется в body: он определяется подписью нового ANS-104 DataItem.
|
||||||
|
- Зафиксированы четыре причины: обычная ротация, возможная компрометация, подтверждённая компрометация с rollback и recovery.
|
||||||
|
|
||||||
# История изменений документации блокчейна
|
# История изменений документации блокчейна
|
||||||
|
|
||||||
## 2026-09-23 — Turbo transport для individual ANS-104 DataItems
|
## 2026-09-23 — Turbo transport для individual ANS-104 DataItems
|
||||||
|
|||||||
+19
-19
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
> **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.**
|
> **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.**
|
||||||
> Этот файл описывает, как из пароля получается секрет и как из секрета выводятся
|
> Этот файл описывает, как из пароля получается секрет и как из секрета выводятся
|
||||||
> все ключи (root, blockchain, device/Solana, homeserver) — формулами, байт-в-байт.
|
> все ключи (root, blockchain, client, homeserver) — формулами, байт-в-байт.
|
||||||
> Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула
|
> Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула
|
||||||
> ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь
|
> ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь
|
||||||
> dev-ключ ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
|
> blockchain key ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
|
||||||
> Роли и назначение ключей описаны отдельно в `docs/Keys/README.md` (архитектура).
|
> Роли и назначение ключей описаны отдельно в `docs/Keys/README.md` (архитектура).
|
||||||
> Здесь — только механика. Документ намеренно краткий.
|
> Здесь — только механика. Документ намеренно краткий.
|
||||||
|
|
||||||
@@ -55,29 +55,29 @@ seed(32) = SHA-256(material)
|
|||||||
| Ключ | Суффикс | Назначение (кратко) |
|
| Ключ | Суффикс | Назначение (кратко) |
|
||||||
|------|---------|---------------------|
|
|------|---------|---------------------|
|
||||||
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
|
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
|
||||||
| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). |
|
| blockchain | `blockchain.key` | Подписывает пользовательские блоки/ANS-104 DataItem и является текущим Solana-wallet/fee payer. |
|
||||||
| device / **Solana** | `client.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(clientPub)`. См. §3. |
|
| client | `client.key` | Общий клиентский ключ для DM/устройств и derivation отдельного Arweave SAWD-кошелька; не является текущим Solana-wallet. |
|
||||||
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
|
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
|
||||||
|
|
||||||
Полные роли каждого ключа — в `docs/Keys/README.md`.
|
Полные роли каждого ключа — в `docs/Keys/README.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Solana-ключ
|
## 3. Solana-кошелёк и authority
|
||||||
|
|
||||||
Отдельного «солана-ключа» нет. На Solana работают два ключа:
|
Отдельного «solana.key» нет. В актуальной модели Solana-wallet пользователя — **активный `blockchain.key`**:
|
||||||
|
|
||||||
- **`client.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(clientPub)`.
|
- адрес кошелька = `base58(activeBlockchainPub)`;
|
||||||
Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`.
|
- им оплачивается регистрация (`create_user_pda`) и обычные `update_user_pda`;
|
||||||
Пополнять SOL нужно именно на этот адрес.
|
- обычное обновление PDA авторизуется активным blockchain key;
|
||||||
- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer.
|
- новый fork подписывает новое PDA новым blockchain key, а старый активный blockchain key разрешает обычный переход;
|
||||||
|
- полная смена пароля/ключей — особый recovery-переход: старый `root.key` дополнительно разрешает одно новое unsigned PDA state, а **новый blockchain key** подписывает тот же hash и становится новым wallet/authority.
|
||||||
|
|
||||||
Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1
|
`root.key` не является fee payer. Он используется как холодное дополнительное разрешение только для полной ротации root + client + blockchain fork.
|
||||||
(«create/update оплачиваются с `client_key`», «root_key — не fee payer»).
|
|
||||||
|
|
||||||
Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью
|
`client.key` больше не используется как Solana-wallet/fee payer. Он остаётся клиентским криптографическим ключом (DM/устройства) и входом в отдельный протокол derivation Arweave-кошелька.
|
||||||
(`create/update`) и через это можно заменить все остальные ключи; `client.key` — это **пополняемый
|
|
||||||
кошелёк и плательщик комиссий**. Полное описание ролей — `docs/Keys/README.md`.
|
При fork Solana-адрес меняется вместе с blockchain key. Перенос SOL со старого blockchain-wallet на новый является отдельным необязательным этапом ротации; в текущей первой реализации этот этап оставлен явной заглушкой.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -117,9 +117,9 @@ homeserver.key:home-b -> ключ B
|
|||||||
- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160).
|
- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160).
|
||||||
|
|
||||||
### Solana-ключ / адрес кошелька (UI)
|
### Solana-ключ / адрес кошелька (UI)
|
||||||
- `shine-UI/js/pages/registration-payment-view.js` — `deriveUserWalletAddress`: адрес = `base58(clientPub)` (~113).
|
- `shine-UI/js/pages/registration-payment-view.js` — адрес пополнения = `base58(blockchainPub)`.
|
||||||
- `shine-UI/js/pages/topup-view.js` — `clientWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.clientPair`.
|
- `shine-UI/js/pages/topup-view.js` — тот же адрес из `preGeneratedKeyBundle.blockchainPair`.
|
||||||
Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `client.key`, мимо `masterSecret`) удалён.
|
- `shine-UI/js/services/solana-wallet-service.js` — текущий пользовательский Solana-wallet загружается из сохранённого `blockchainKey`.
|
||||||
|
|
||||||
### Деривация ключей (прошивка ESP32)
|
### Деривация ключей (прошивка ESP32)
|
||||||
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
|
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
|
||||||
@@ -147,7 +147,7 @@ homeserver.key:home-b -> ключ B
|
|||||||
|
|
||||||
1. Этот документ — источник истины по деривации секрета и ключей.
|
1. Этот документ — источник истины по деривации секрета и ключей.
|
||||||
2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа,
|
2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа,
|
||||||
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь dev-ключ ↔ Solana-адрес —
|
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь blockchain key ↔ Solana-адрес —
|
||||||
**обязательно** отражать здесь в том же изменении.
|
**обязательно** отражать здесь в том же изменении.
|
||||||
3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма.
|
3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма.
|
||||||
4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости.
|
4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости.
|
||||||
|
|||||||
+11
-14
@@ -8,9 +8,9 @@
|
|||||||
|
|
||||||
В SHiNE у пользователя есть несколько уровней ключей:
|
В SHiNE у пользователя есть несколько уровней ключей:
|
||||||
|
|
||||||
- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `client key`).
|
- `root key` - холодный recovery-ключ: при полной ротации дополнительно разрешает замену root + client + blockchain fork. Это не кошелёк.
|
||||||
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
|
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн и текущий Solana-wallet/fee payer пользователя.
|
||||||
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей.
|
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков и DM; Solana-wallet им больше не является.
|
||||||
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
|
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
|
||||||
|
|
||||||
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
|
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
|
||||||
@@ -21,16 +21,13 @@
|
|||||||
|
|
||||||
Назначение:
|
Назначение:
|
||||||
|
|
||||||
- регистрация пользователя в Solana;
|
- холодное recovery-разрешение для полной смены ключей;
|
||||||
- создание и обновление пользовательской PDA-записи;
|
- подтверждение атомарной ротации `root + client + новый blockchain fork`;
|
||||||
- вызов критически важных Solana-функций;
|
- восстановительные сценарии повышенного уровня доверия.
|
||||||
- изменение главных настроек пользователя;
|
|
||||||
- управление остальными ключами;
|
|
||||||
- подтверждение операций, которые должны иметь максимальный уровень доверия.
|
|
||||||
|
|
||||||
`root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя.
|
Обычные PDA-update **не требуют root key**: их выполняет активный blockchain key. При полной ротации старый root подписывает тот же hash нового unsigned PDA state, который подписывает новый blockchain key. Так root разрешает переход, не становясь повседневным ключом.
|
||||||
|
|
||||||
Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `client key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `docs/Keys/DERIVATION.md`, §3.
|
Важно не путать recovery-authority и кошелёк: `root key` не является fee payer. Текущий Solana-wallet/fee payer — активный `blockchain key`. Подробнее — `docs/Keys/DERIVATION.md`, §3.
|
||||||
|
|
||||||
## `blockchain key`
|
## `blockchain key`
|
||||||
|
|
||||||
@@ -40,7 +37,8 @@
|
|||||||
|
|
||||||
- подпись записей в персональном блокчейне пользователя;
|
- подпись записей в персональном блокчейне пользователя;
|
||||||
- подтверждение действий, которые должны попасть в SHiNE-блокчейн;
|
- подтверждение действий, которые должны попасть в SHiNE-блокчейн;
|
||||||
- разделение полномочий между главным Solana-ключом и ключом ежедневной записи.
|
- обычные обновления пользовательской PDA;
|
||||||
|
- текущий Solana-wallet/fee payer (`base58(active blockchain public key)`).
|
||||||
|
|
||||||
У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи:
|
У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи:
|
||||||
|
|
||||||
@@ -59,7 +57,6 @@
|
|||||||
- повседневные входящие и исходящие личные сообщения;
|
- повседневные входящие и исходящие личные сообщения;
|
||||||
- звонки и связанные с ними сообщения;
|
- звонки и связанные с ними сообщения;
|
||||||
- self-messages, то есть внутренние сообщения пользователя самому себе;
|
- self-messages, то есть внутренние сообщения пользователя самому себе;
|
||||||
- мелкие Solana-расходы на текущие операции;
|
|
||||||
- derivation Arweave-кошелька;
|
- derivation Arweave-кошелька;
|
||||||
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
|
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
|
||||||
|
|
||||||
@@ -158,7 +155,7 @@ Self-message - это сообщение пользователя самому
|
|||||||
|
|
||||||
## Связанные документы
|
## Связанные документы
|
||||||
|
|
||||||
- `docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`client.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код).
|
- `docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`blockchain.key`/`client.key`/`homeserver.key:<имя>`, Solana-wallet, ссылки на код).
|
||||||
- `docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений.
|
- `docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений.
|
||||||
- `docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений.
|
- `docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений.
|
||||||
- `docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
|
- `docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
|
||||||
|
|||||||
@@ -584,3 +584,28 @@ SYNC_POLL_INTERVAL_SECONDS=300
|
|||||||
- server profile считается присутствующим, если опубликован один server address; в старые SQL-поля временно проецируется этот адрес.
|
- server profile считается присутствующим, если опубликован один server address; в старые SQL-поля временно проецируется этот адрес.
|
||||||
|
|
||||||
Create/update транзакции больше не реконструируются байт-в-байт из instruction args. После обнаружения изменения sync-модуль перечитывает фактическую текущую PDA через Solana RPC и декодирует её. Это исключает дублирование on-chain сериализации. `close_legacy_pda` не создаёт новое состояние PDA и для runtime-sync не является пользовательским update.
|
Create/update транзакции больше не реконструируются байт-в-байт из instruction args. После обнаружения изменения sync-модуль перечитывает фактическую текущую PDA через Solana RPC и декодирует её. Это исключает дублирование on-chain сериализации. `close_legacy_pda` не создаёт новое состояние PDA и для runtime-sync не является пользовательским update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server-local key rotation state (PostgreSQL v26)
|
||||||
|
|
||||||
|
Начиная с migration v26 сервер хранит локальное состояние длительной смены ключей отдельно от данных Solana PDA.
|
||||||
|
|
||||||
|
В `solana_user_pda_current` добавлены локальные поля:
|
||||||
|
|
||||||
|
- `rotation_status` — быстрый текущий статус (`NONE` в обычном режиме);
|
||||||
|
- `rotation_session_id` — ссылка на текущую запись `key_rotation_sessions`.
|
||||||
|
|
||||||
|
Эти поля **не являются частью PDA**, не приходят из Solana и не должны перезаписываться обычным Solana sync upsert-ом.
|
||||||
|
|
||||||
|
Подробный прогресс хранится в `key_rotation_sessions`: только публичные old/new root/blockchain/client keys, выбранная точка fork, старый tip, reason/comment, прогресс, ошибка/retry и статусы необязательных wallet/DM этапов. Пароли и приватные ключи в PostgreSQL не сохраняются.
|
||||||
|
|
||||||
|
Первая серверная запись создаётся сразу в `COPYING_CHAIN`; состояния `PREPARING` в БД нет. Завершённые `COMPLETE`/`ABORTED` sessions остаются как журнал, а `solana_user_pda_current.rotation_status` возвращается в `NONE`.
|
||||||
|
|
||||||
|
### Candidate blocks ротации (PostgreSQL v27)
|
||||||
|
|
||||||
|
Начиная с migration v27 будущая ветка во время `COPYING_CHAIN` хранится в отдельной таблице `key_rotation_candidate_blocks`. Она не является частью текущего materialized blockchain state и не должна попадать в обычную `blocks` до финального переключения fork.
|
||||||
|
|
||||||
|
Для каждого candidate DataItem сохраняются rotation session, login, candidate blockchain name, block number/hash, полный ANS-104 DataItem, DataItem id и статус публикации. Уникальность `(rotation_session_id, block_number)` запрещает две разные версии одного candidate-блока.
|
||||||
|
|
||||||
|
Arweave/Turbo publisher обрабатывает candidate-блоки приоритетно. `key_rotation_sessions.progress_current` отражает число DataItem, уже реально опубликованных publisher-ом, а не число принятых API-сервером.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
Основные данные:
|
Основные данные:
|
||||||
|
|
||||||
- `RootKeyBlock` — cold recovery authority;
|
- `RootKeyBlock` — cold recovery authority;
|
||||||
- `ClientKeyBlock` — клиентский/кошелёчный ключ;
|
- `ClientKeyBlock` — клиентский ключ для DM/устройств; Solana-wallet/fee payer — активный blockchain key;
|
||||||
- `BlockchainRegistryBlock` — append-only список fork: `blockchain_key[32] + created_at_ms:u64 + paid_limit_bytes:u32`; последний fork активен;
|
- `BlockchainRegistryBlock` — append-only список fork: `blockchain_key[32] + created_at_ms:u64 + paid_limit_bytes:u32`; последний fork активен;
|
||||||
- необязательный `ServerProfileBlock` — в 1.2 ровно один адрес сервера;
|
- необязательный `ServerProfileBlock` — в 1.2 ровно один адрес сервера;
|
||||||
- необязательный `AccessServersBlock` — в 1.2 максимум один access server.
|
- необязательный `AccessServersBlock` — в 1.2 максимум один access server.
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
# UI v3–v4: что нужно от сервера и что знать разработчику
|
|
||||||
|
|
||||||
Макет V3/V4 перенесён в настоящий код (v3) и доведён по экранам (v4): палитра «Сияние», лента канала, обсуждение,
|
|
||||||
личный чат, вход и регистрация, уведомления, настройки, просмотр фото. Всё сделано на текущем API без его изменений.
|
|
||||||
Там, где интерфейсу не хватает сервера, UI к этому готов: нет данных – элемент просто не появляется.
|
|
||||||
|
|
||||||
## Нужно от сервера
|
|
||||||
|
|
||||||
1. **Кто недавно ответил** – миниатюры рядом со счётчиком обсуждения в ленте канала.
|
|
||||||
Поле `recentRepliers: string[]` (до двух логинов, свежие первыми) у сообщений в `GetChannelMessages`
|
|
||||||
и `GetMessageThread`. UI: `channel-view.js`, `mapApiMessageToPost` → `recentRepliers`. Нет поля – нет миниатюр.
|
|
||||||
|
|
||||||
2. **«Не уведомлять об ответах» в обсуждении.** Пункт меню обсуждения сейчас запоминает выбор только на устройстве
|
|
||||||
(`localStorage`, ключ `shine-ui-thread-muted-v1`) и на уведомления не влияет. Нужна настройка пользователя
|
|
||||||
«без уведомлений об ответах в этом обсуждении» (по ссылке на исходную запись), учитываемая при создании
|
|
||||||
уведомлений `reply`. Удобно через существующий `UpsertUserSetting`.
|
|
||||||
|
|
||||||
3. **Ссылка на комментарий.** «Поделиться комментарием» даёт ссылку на обсуждение с якорем `#comment-<blockNumber>`.
|
|
||||||
Роутер якорь игнорирует. Нужно: при открытии обсуждения с таким якорем прокрутить к комментарию и подсветить
|
|
||||||
(в UI для этого есть `jumpTo` в `channel-thread-view.js`).
|
|
||||||
|
|
||||||
4. **Уведомления об ответах – на что ответили** (по желанию). В уведомлении есть только текст ответа (`sourceText`).
|
|
||||||
Чтобы показать «в ответ на: …», нужен короткий текст исходного сообщения (`targetText`) в `GetNotifications`.
|
|
||||||
|
|
||||||
5. **Стоимость публикации** (экран «Публикация · подтверждение» макета). Шага подтверждения в приложении нет –
|
|
||||||
запись уходит сразу. Если публикация станет платной или будет ждать Arweave, нужна цена до отправки.
|
|
||||||
|
|
||||||
## Нужно от разработчика
|
|
||||||
|
|
||||||
6. **Удалить `pages/key-storage-view.js` и маршрут `/key-storage`.** Экран никуда не подключён: в поля вписаны
|
|
||||||
описания ключей, «OK» вызывает `authorizeSession()` без данных. Настоящий выбор ключей – `registration-keys-view.js`
|
|
||||||
(переделан в v4). Вместе с ним можно убрать `.key-storage-option*` / `.key-storage-note*` в `developer-tools.css`.
|
|
||||||
|
|
||||||
## Что важно знать об изменениях v4
|
|
||||||
|
|
||||||
- **Второстепенные кнопки** – одна роль во всём приложении, включая экраны устройств и серверов: прозрачная, рамка, цвет `--action`.
|
|
||||||
- **Полоса прокрутки скрыта по умолчанию** (`scrollbar: 'hidden'` в режимах оболочки `app.js`) – как было у каналов,
|
|
||||||
ленты и обсуждения. Прокрутка работает, контент не прыгает при появлении полосы.
|
|
||||||
- **Один зазор под шапкой:** `--page-top-gap` в `main.css` (12 px). Его берут `app-shell.css`, каналы и экраны входа –
|
|
||||||
свои отступы сверху экранам не нужны.
|
|
||||||
- **Шапка корневых разделов** (Чаты, Каналы, Связи, Уведомления, Профиль) – заголовок по центру, 24 px (`.topbar--root`).
|
|
||||||
- **Переключатели `.tabs`** – высотой с поле ввода (44 px, кнопки 34 px); `.tabs--dense` – для 4–5 коротких пунктов
|
|
||||||
(фильтр чатов вместо чипов).
|
|
||||||
- **Новые общие компоненты:** `components/media-viewer.js` + `styles/components/media-viewer.css` (фото/видео на весь экран,
|
|
||||||
«1/3» и «Скачать» под фото; заменил `openAttachmentViewer` и кнопку скачивания на фото), `components/shine-wordmark.js`
|
|
||||||
(надпись «СИЯНИЕ» в контурах, шрифт Tenor Sans, OFL), переключатель `.switch` (перенесён из `settings.css` в `primitives.css`).
|
|
||||||
- **Иконки** – только из `ui-icon.js` (добавлены `eye`, `eyeOff`; `iconHtml(name, filled, className)`). Текстовые символы
|
|
||||||
вместо иконок («↻», «+», «✕») заменены.
|
|
||||||
- **«Дополнительные настройки»** теперь открываются пятью быстрыми нажатиями на заголовок «Настройки»
|
|
||||||
(логотип над настройками убран).
|
|
||||||
- **Стартовый экран:** знак по центру, кнопки внизу; сияние мандалы – два вращающихся слоя в `preauth.css`
|
|
||||||
(`.auth-logo-wrap::before/::after`), фаза случайная; при `prefers-reduced-motion` неподвижно.
|
|
||||||
- **Тексты:** в строках интерфейса только среднее тире с пробелами «–» (длинное «—» заменено везде, кроме комментариев).
|
|
||||||
- Уведомления – список как в чатах; «Настройки входа» – одна карточка серверов со статусом-пилюлей, проверка при открытии;
|
|
||||||
вход с другого устройства – код, таймер, три шага.
|
|
||||||
|
|
||||||
## Найденные ошибки (не наши)
|
|
||||||
|
|
||||||
7. `styles/components/avatar.css`: `url("../assets/glass_overlay_faithful.png")` ведёт в `styles/assets/…` – файла там нет,
|
|
||||||
стеклянный ободок аватара не загружается. Путь должен быть `../../assets/…`.
|
|
||||||
8. Обработчик ошибок в `index.html` показывал красную плашку «BOOT ERROR ResizeObserver loop…» – это предупреждение
|
|
||||||
браузера, не ошибка. В v3 отсеивается.
|
|
||||||
9. Пункты 1–2 из `ISSUES-for-dev.md` (подписки у логинов с заглавными, превью «Сообщение недоступно») по-прежнему актуальны.
|
|
||||||
10. Вход по паролю на `http://` по адресу в локальной сети не работает («Криптография браузера недоступна»): WebCrypto есть
|
|
||||||
только на https и localhost. Это ограничение браузера – для проверки с телефона нужен https.
|
|
||||||
@@ -90,14 +90,14 @@
|
|||||||
|
|
||||||
## Кто оплачивает create/update user_pda
|
## Кто оплачивает create/update user_pda
|
||||||
|
|
||||||
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`.
|
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с активного `blockchainKey`.
|
||||||
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя или сервера.
|
- В UI это означает, что Solana fee payer всегда берётся из текущего активного `blockchain key` пользователя или сервера.
|
||||||
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
|
- `rootKey` не оплачивает транзакцию; он дополнительно авторизует только полную ротацию ключей.
|
||||||
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`.
|
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес текущего `blockchainKey`.
|
||||||
|
|
||||||
## Важно
|
## Важно
|
||||||
|
|
||||||
- `init_users_economy_config` выполняется один раз на программу. Если PDA уже создан, повторный вызов вернёт ошибку `already initialized`.
|
- `init_users_economy_config` выполняется один раз на программу. Если PDA уже создан, повторный вызов вернёт ошибку `already initialized`.
|
||||||
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: транзакцию оплачивает `clientKey`, а содержимое записи подписывает `rootKey`.
|
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: транзакцию оплачивает `blockchainKey`. Обычное состояние PDA подписывает blockchain key; root дополнительно участвует только в полной ротации ключей.
|
||||||
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
|
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
|
||||||
- При новом devnet deploy планируется использовать те же program keypair, чтобы `program id` на devnet совпадали с mainnet.
|
- При новом devnet deploy планируется использовать те же program keypair, чтобы `program id` на devnet совпадали с mainnet.
|
||||||
|
|||||||
+14
-16
@@ -6,7 +6,7 @@
|
|||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual"
|
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual"
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#121419" />
|
<meta name="theme-color" content="#12141f" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="manifest" href="./manifest.webmanifest" />
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/jpeg" href="./img/logo.jpg" />
|
<link rel="icon" type="image/jpeg" href="./img/logo.jpg" />
|
||||||
@@ -45,7 +45,7 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
|
|||||||
<script>
|
<script>
|
||||||
(function attachStylesWithBuildHash() {
|
(function attachStylesWithBuildHash() {
|
||||||
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
||||||
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/chips.css', './styles/components/nav-list.css', './styles/components/palette-editor.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/compose.css', './styles/components/attachments.css', './styles/components/media-viewer.css', './styles/components/channel-editor.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/profile-page.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
|
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/chips.css', './styles/components/nav-list.css', './styles/components/palette-editor.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/attachments.css', './styles/components/channel-editor.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/profile-page.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
|
||||||
cssFiles.forEach((file) => {
|
cssFiles.forEach((file) => {
|
||||||
const link = document.createElement('link');
|
const link = document.createElement('link');
|
||||||
link.rel = 'stylesheet';
|
link.rel = 'stylesheet';
|
||||||
@@ -208,19 +208,19 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
|
|||||||
const formatReportText = (report) => {
|
const formatReportText = (report) => {
|
||||||
const lines = [
|
const lines = [
|
||||||
`Ошибка: ${report.title || report.kind || 'unknown'}`,
|
`Ошибка: ${report.title || report.kind || 'unknown'}`,
|
||||||
`Описание: ${report.message || '–'}`,
|
`Описание: ${report.message || '—'}`,
|
||||||
`Окно: ${report.pageTitle || '–'}`,
|
`Окно: ${report.pageTitle || '—'}`,
|
||||||
`Маршрут: ${report.route || '–'}`,
|
`Маршрут: ${report.route || '—'}`,
|
||||||
`URL: ${report.href || '–'}`,
|
`URL: ${report.href || '—'}`,
|
||||||
`Видимость: ${report.pageVisibility || '–'}`,
|
`Видимость: ${report.pageVisibility || '—'}`,
|
||||||
`Время: ${new Date(Number(report.clientTs || Date.now())).toISOString()}`,
|
`Время: ${new Date(Number(report.clientTs || Date.now())).toISOString()}`,
|
||||||
`UA: ${report.userAgent || '–'}`,
|
`UA: ${report.userAgent || '—'}`,
|
||||||
`Экран: ${report.viewport ? `${report.viewport.width}x${report.viewport.height} @${report.viewport.dpr || 1}x` : '–'}`,
|
`Экран: ${report.viewport ? `${report.viewport.width}x${report.viewport.height} @${report.viewport.dpr || 1}x` : '—'}`,
|
||||||
`Монитор: ${report.screenInfo ? `${report.screenInfo.width}x${report.screenInfo.height}` : '–'}`,
|
`Монитор: ${report.screenInfo ? `${report.screenInfo.width}x${report.screenInfo.height}` : '—'}`,
|
||||||
`Источник: ${report.sourceUrl || '–'}`,
|
`Источник: ${report.sourceUrl || '—'}`,
|
||||||
`Строка: ${Number.isFinite(report.lineNumber) ? report.lineNumber : '–'}`,
|
`Строка: ${Number.isFinite(report.lineNumber) ? report.lineNumber : '—'}`,
|
||||||
`Колонка: ${Number.isFinite(report.columnNumber) ? report.columnNumber : '–'}`,
|
`Колонка: ${Number.isFinite(report.columnNumber) ? report.columnNumber : '—'}`,
|
||||||
`Тип причины: ${report.reasonType || '–'}`,
|
`Тип причины: ${report.reasonType || '—'}`,
|
||||||
];
|
];
|
||||||
if (report.stack) {
|
if (report.stack) {
|
||||||
lines.push('Stack:', report.stack);
|
lines.push('Stack:', report.stack);
|
||||||
@@ -418,8 +418,6 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
|
|||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
window.addEventListener('error', (e) => {
|
window.addEventListener('error', (e) => {
|
||||||
// Предупреждение браузера о переносе замеров ResizeObserver на следующий кадр – не ошибка
|
|
||||||
if (/^ResizeObserver loop/.test(String(e?.message || ''))) return;
|
|
||||||
show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`, {
|
show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`, {
|
||||||
kind: 'window_error',
|
kind: 'window_error',
|
||||||
message: e?.message || '',
|
message: e?.message || '',
|
||||||
|
|||||||
+24
-3
@@ -16,6 +16,7 @@ import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'
|
|||||||
import { initPwaPush } from './services/pwa-push-service.js';
|
import { initPwaPush } from './services/pwa-push-service.js';
|
||||||
import { initCallUiOverlay } from './services/call-ui-service.js';
|
import { initCallUiOverlay } from './services/call-ui-service.js';
|
||||||
import { showToast } from './services/channels-ux.js';
|
import { showToast } from './services/channels-ux.js';
|
||||||
|
import { KeyRotationClient } from './services/key-rotation-service.js';
|
||||||
import {
|
import {
|
||||||
handleCallPushAction,
|
handleCallPushAction,
|
||||||
handleIncomingCallInvite,
|
handleIncomingCallInvite,
|
||||||
@@ -73,6 +74,8 @@ import * as profileEditView from './pages/profile-edit-view.js';
|
|||||||
import * as profilesView from './pages/profiles-view.js';
|
import * as profilesView from './pages/profiles-view.js';
|
||||||
import * as walletView from './pages/wallet-view.js?v=202609260900';
|
import * as walletView from './pages/wallet-view.js?v=202609260900';
|
||||||
import * as settingsView from './pages/settings-view.js';
|
import * as settingsView from './pages/settings-view.js';
|
||||||
|
import * as myBlockchainView from './pages/my-blockchain-view.js';
|
||||||
|
import * as keyRotationView from './pages/key-rotation-view.js';
|
||||||
import * as accessServersView from './pages/access-servers-view.js';
|
import * as accessServersView from './pages/access-servers-view.js';
|
||||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||||
import * as advancedSettingsView from './pages/advanced-settings-view.js';
|
import * as advancedSettingsView from './pages/advanced-settings-view.js';
|
||||||
@@ -142,6 +145,8 @@ const routes = {
|
|||||||
'profiles-view': profilesView,
|
'profiles-view': profilesView,
|
||||||
'wallet-view': walletView,
|
'wallet-view': walletView,
|
||||||
'settings-view': settingsView,
|
'settings-view': settingsView,
|
||||||
|
'my-blockchain-view': myBlockchainView,
|
||||||
|
'key-rotation-view': keyRotationView,
|
||||||
'access-servers-view': accessServersView,
|
'access-servers-view': accessServersView,
|
||||||
'developer-settings-view': developerSettingsView,
|
'developer-settings-view': developerSettingsView,
|
||||||
'advanced-settings-view': advancedSettingsView,
|
'advanced-settings-view': advancedSettingsView,
|
||||||
@@ -326,7 +331,7 @@ function syncViewportMetrics() {
|
|||||||
0,
|
0,
|
||||||
stableViewportHeightPx - Math.min(currentLayoutHeightPx, visualBottomPx),
|
stableViewportHeightPx - Math.min(currentLayoutHeightPx, visualBottomPx),
|
||||||
);
|
);
|
||||||
// Address bar Android обычно даёт небольшую дельту; клавиатура – заметно больше.
|
// Address bar Android обычно даёт небольшую дельту; клавиатура — заметно больше.
|
||||||
const keyboardOffsetPx = textEntryFocused && rawKeyboardOffsetPx >= 100
|
const keyboardOffsetPx = textEntryFocused && rawKeyboardOffsetPx >= 100
|
||||||
? rawKeyboardOffsetPx
|
? rawKeyboardOffsetPx
|
||||||
: 0;
|
: 0;
|
||||||
@@ -433,7 +438,7 @@ const DEFAULT_SHELL_MODE = Object.freeze({
|
|||||||
contentUnderTopbar: true,
|
contentUnderTopbar: true,
|
||||||
contentUnderBottom: false,
|
contentUnderBottom: false,
|
||||||
scrollContainer: 'screen',
|
scrollContainer: 'screen',
|
||||||
scrollbar: 'hidden', // как в мобильных приложениях: прокрутка есть, полосы нет и контент не прыгает
|
scrollbar: 'auto',
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizeShellMode(mode = {}, showAppChrome = true) {
|
function normalizeShellMode(mode = {}, showAppChrome = true) {
|
||||||
@@ -1273,7 +1278,7 @@ window.addEventListener('error', (event) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Кросс-ориджин ошибки приходят как «Script error.» без файла/строки/стека –
|
// Кросс-ориджин ошибки приходят как «Script error.» без файла/строки/стека —
|
||||||
// алертом такие не показываем (в лог и captureClientError выше уже записаны).
|
// алертом такие не показываем (в лог и captureClientError выше уже записаны).
|
||||||
const isOpaqueCrossOriginError = String(event.message || '') === 'Script error.'
|
const isOpaqueCrossOriginError = String(event.message || '') === 'Script error.'
|
||||||
&& !event.filename && !(event.error?.stack);
|
&& !event.filename && !(event.error?.stack);
|
||||||
@@ -1376,6 +1381,21 @@ function attachPageScrollToBottom(pageId, screen) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function redirectToActiveKeyRotationIfNeeded() {
|
||||||
|
if (!state.session.isAuthorized || state.session.isLocalDemo) return false;
|
||||||
|
try {
|
||||||
|
const rotation = await new KeyRotationClient(authService).status();
|
||||||
|
const status = String(rotation?.rotationStatus || 'NONE');
|
||||||
|
if (status !== 'NONE' && getRoute().pageId !== 'key-rotation-view') {
|
||||||
|
navigate('key-rotation-view');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[key-rotation] status check failed', error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function renderApp() {
|
function renderApp() {
|
||||||
syncTrackedRouteHistory(window.location.pathname || '/');
|
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||||
const route = getRoute();
|
const route = getRoute();
|
||||||
@@ -1566,6 +1586,7 @@ async function init() {
|
|||||||
setSessionAuthorizedHandler(() => {
|
setSessionAuthorizedHandler(() => {
|
||||||
void ensureSessionRuntimeStarted();
|
void ensureSessionRuntimeStarted();
|
||||||
void processPendingCallPushActionIfPossible();
|
void processPendingCallPushActionIfPossible();
|
||||||
|
void redirectToActiveKeyRotationIfNeeded();
|
||||||
});
|
});
|
||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js';
|
import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js';
|
||||||
import { escapeHtml, formatBytes, getAttachmentKind, normalizeAttachment } from '../services/attachment-format.js';
|
import { escapeHtml, formatBytes, getAttachmentKind, normalizeAttachment } from '../services/attachment-format.js';
|
||||||
import { getStoredSolanaWalletChoices } from '../services/solana-wallet-service.js';
|
import { getStoredSolanaWalletChoices } from '../services/solana-wallet-service.js';
|
||||||
import { iconHtml } from './ui-icon.js';
|
|
||||||
import {
|
import {
|
||||||
estimateTurboUploadPrice,
|
estimateTurboUploadPrice,
|
||||||
formatTurboCredits,
|
formatTurboCredits,
|
||||||
@@ -126,7 +125,7 @@ export function markArweaveAttachmentPlaced(login, attachment) {
|
|||||||
|
|
||||||
export function formatArweaveHistoryTime(ms) {
|
export function formatArweaveHistoryTime(ms) {
|
||||||
const value = Number(ms || 0);
|
const value = Number(ms || 0);
|
||||||
if (!Number.isFinite(value) || value <= 0) return '–';
|
if (!Number.isFinite(value) || value <= 0) return '—';
|
||||||
const diffMs = Date.now() - value;
|
const diffMs = Date.now() - value;
|
||||||
if (diffMs >= 0 && diffMs < RECENT_UPLOAD_MS) {
|
if (diffMs >= 0 && diffMs < RECENT_UPLOAD_MS) {
|
||||||
const minutes = Math.max(1, Math.floor(diffMs / 60000));
|
const minutes = Math.max(1, Math.floor(diffMs / 60000));
|
||||||
@@ -137,7 +136,7 @@ export function formatArweaveHistoryTime(ms) {
|
|||||||
|
|
||||||
export function formatArweaveHistoryDateTime(ms) {
|
export function formatArweaveHistoryDateTime(ms) {
|
||||||
const value = Number(ms || 0);
|
const value = Number(ms || 0);
|
||||||
if (!Number.isFinite(value) || value <= 0) return '–';
|
if (!Number.isFinite(value) || value <= 0) return '—';
|
||||||
return new Date(value).toLocaleString('ru-RU');
|
return new Date(value).toLocaleString('ru-RU');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +290,7 @@ export function openArweaveAttachmentManager({
|
|||||||
mode = 'attachment',
|
mode = 'attachment',
|
||||||
historyPurpose = '',
|
historyPurpose = '',
|
||||||
uploadTransport = 'turbo',
|
uploadTransport = 'turbo',
|
||||||
turboKeySource = 'client',
|
turboKeySource = 'blockchain',
|
||||||
dialogTitle = '',
|
dialogTitle = '',
|
||||||
uploadButtonLabel = '',
|
uploadButtonLabel = '',
|
||||||
initialFile = null,
|
initialFile = null,
|
||||||
@@ -407,20 +406,9 @@ export function openArweaveAttachmentManager({
|
|||||||
turboKeyChoices = await getStoredSolanaWalletChoices({
|
turboKeyChoices = await getStoredSolanaWalletChoices({
|
||||||
login: cleanLogin,
|
login: cleanLogin,
|
||||||
storagePwd: cleanStoragePwd,
|
storagePwd: cleanStoragePwd,
|
||||||
includeRoot: true,
|
|
||||||
});
|
|
||||||
turboKeyChoices.sort((a, b) => {
|
|
||||||
if (a?.keySource === b?.keySource) return 0;
|
|
||||||
if (a?.keySource === 'client') return -1;
|
|
||||||
if (b?.keySource === 'client') return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
});
|
||||||
if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) {
|
if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) {
|
||||||
selectedTurboKeySource = String(
|
selectedTurboKeySource = String(turboKeyChoices[0]?.keySource || 'blockchain');
|
||||||
turboKeyChoices.find((item) => item.keySource === 'client')?.keySource
|
|
||||||
|| turboKeyChoices[0]?.keySource
|
|
||||||
|| 'client'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
|
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -431,10 +419,10 @@ export function openArweaveAttachmentManager({
|
|||||||
const renderTurboMeta = (mainMetaEl, previewMetaEl) => {
|
const renderTurboMeta = (mainMetaEl, previewMetaEl) => {
|
||||||
if (!mainMetaEl || !previewMetaEl) return;
|
if (!mainMetaEl || !previewMetaEl) return;
|
||||||
const turboAddress = String(balanceInfo?.address || priceInfo?.address || '').trim();
|
const turboAddress = String(balanceInfo?.address || priceInfo?.address || '').trim();
|
||||||
const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '–';
|
const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '—';
|
||||||
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '–';
|
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—';
|
||||||
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '–';
|
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—';
|
||||||
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '–';
|
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—';
|
||||||
const isFreeSize = selectedFile && combinedUploadSize() <= getFreeTurboUploadBytesLimit();
|
const isFreeSize = selectedFile && combinedUploadSize() <= getFreeTurboUploadBytesLimit();
|
||||||
const summary = selectedFile
|
const summary = selectedFile
|
||||||
? `Размер ${escapeHtml(fileSize)} · ${isFreeSize
|
? `Размер ${escapeHtml(fileSize)} · ${isFreeSize
|
||||||
@@ -445,10 +433,10 @@ export function openArweaveAttachmentManager({
|
|||||||
${summary ? `<div class="ar-attachment-summary">${summary}</div>` : ''}
|
${summary ? `<div class="ar-attachment-summary">${summary}</div>` : ''}
|
||||||
<details class="ar-attachment-details">
|
<details class="ar-attachment-details">
|
||||||
<summary>Подробнее</summary>
|
<summary>Подробнее</summary>
|
||||||
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '–'))}</div>
|
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
|
||||||
<div>Баланс Turbo: ${escapeHtml(turboCredits)}</div>
|
<div>Баланс Turbo: ${escapeHtml(turboCredits)}</div>
|
||||||
<div>SOL на этом ключе: ${solBalance}</div>
|
<div>SOL на этом ключе: ${solBalance}</div>
|
||||||
<div>SHA-256: ${escapeHtml(selectedSha256 || '–')}</div>
|
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
|
||||||
<div>Цена Turbo: ${escapeHtml(uploadPrice)}</div>
|
<div>Цена Turbo: ${escapeHtml(uploadPrice)}</div>
|
||||||
<div>Бесплатно до ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))}</div>
|
<div>Бесплатно до ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))}</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -459,8 +447,8 @@ export function openArweaveAttachmentManager({
|
|||||||
<div><strong>Файл превью</strong></div>
|
<div><strong>Файл превью</strong></div>
|
||||||
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
|
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
|
||||||
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
|
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
|
||||||
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '–')}</div>
|
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}</div>
|
||||||
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '–')}</div>
|
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '—')}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -521,7 +509,7 @@ export function openArweaveAttachmentManager({
|
|||||||
mainMetaEl.innerHTML = `
|
mainMetaEl.innerHTML = `
|
||||||
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile?.name || 'file')}</div>`}
|
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile?.name || 'file')}</div>`}
|
||||||
<div>Размер: ${escapeHtml(formatBytes(selectedFile?.size || 0))}</div>
|
<div>Размер: ${escapeHtml(formatBytes(selectedFile?.size || 0))}</div>
|
||||||
<div>SHA-256: ${escapeHtml(selectedSha256 || '–')}</div>
|
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
|
||||||
<div>Цена: ${escapeHtml(Number(priceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
|
<div>Цена: ${escapeHtml(Number(priceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
|
||||||
<div>Баланс кошелька: ${escapeHtml(Number(balanceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
|
<div>Баланс кошелька: ${escapeHtml(Number(balanceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
|
||||||
`;
|
`;
|
||||||
@@ -531,8 +519,8 @@ export function openArweaveAttachmentManager({
|
|||||||
<div><strong>Файл превью</strong></div>
|
<div><strong>Файл превью</strong></div>
|
||||||
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
|
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
|
||||||
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
|
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
|
||||||
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '–')}</div>
|
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}</div>
|
||||||
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${Number(selectedPreviewPriceInfo.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 })} AR` : '–')}</div>
|
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${Number(selectedPreviewPriceInfo.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 })} AR` : '—')}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -625,7 +613,7 @@ export function openArweaveAttachmentManager({
|
|||||||
const showUpload = async () => {
|
const showUpload = async () => {
|
||||||
const turboMode = isTurboUpload();
|
const turboMode = isTurboUpload();
|
||||||
const titleText = effectiveDialogTitle
|
const titleText = effectiveDialogTitle
|
||||||
|| (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'));
|
|| (turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')));
|
||||||
const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить');
|
const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить');
|
||||||
const canShowHistory = allowHistorySelection;
|
const canShowHistory = allowHistorySelection;
|
||||||
const canShowExisting = allowExistingTxInput;
|
const canShowExisting = allowExistingTxInput;
|
||||||
@@ -634,11 +622,11 @@ export function openArweaveAttachmentManager({
|
|||||||
<div class="modal-card stack ar-attachment-manager-card">
|
<div class="modal-card stack ar-attachment-manager-card">
|
||||||
<div class="ar-attachment-manager-head">
|
<div class="ar-attachment-manager-head">
|
||||||
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
|
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
|
||||||
<button class="icon-btn ar-attachment-manager-close" type="button" data-action="cancel" aria-label="Закрыть">${iconHtml('close')}</button>
|
<button class="icon-btn ar-attachment-manager-close" type="button" data-action="cancel" aria-label="Закрыть">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tabs tabs--auto" role="radiogroup" aria-label="Способ загрузки">
|
<div class="tabs tabs--auto" role="radiogroup" aria-label="Способ загрузки">
|
||||||
<button class="tab-btn" role="radio" aria-checked="${turboMode}" type="button" data-action="switch-turbo">Turbo</button>
|
<button class="tab-btn" role="radio" aria-checked="${turboMode}" type="button" data-action="switch-turbo">Turbo</button>
|
||||||
<button class="tab-btn" role="radio" aria-checked="${!turboMode}" type="button" data-action="switch-arweave">Свой кошелёк</button>
|
<button class="tab-btn" role="radio" aria-checked="${!turboMode}" type="button" data-action="switch-arweave">Свой кошелёк Arweave</button>
|
||||||
</div>
|
</div>
|
||||||
${turboMode
|
${turboMode
|
||||||
? `
|
? `
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export function openChannelEditor({
|
|||||||
submitLabel = 'Ответить',
|
submitLabel = 'Ответить',
|
||||||
placeholder = 'Напишите ответ',
|
placeholder = 'Напишите ответ',
|
||||||
context = null,
|
context = null,
|
||||||
destination = '',
|
|
||||||
key = 'reply',
|
key = 'reply',
|
||||||
extraControl = null,
|
extraControl = null,
|
||||||
initialText = '',
|
initialText = '',
|
||||||
@@ -39,7 +38,7 @@ export function openChannelEditor({
|
|||||||
isActive = () => true,
|
isActive = () => true,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!state.session.isAuthorized) {
|
if (!state.session.isAuthorized) {
|
||||||
openAuthRequiredModal({ title: 'Нужен аккаунт', text: 'Чтобы публиковать и отвечать, войдите или зарегистрируйтесь.' });
|
openAuthRequiredModal({ title: 'Войдите, чтобы написать', text: 'Для публикации и ответа нужен активный профиль.' });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
@@ -78,15 +77,11 @@ export function openChannelEditor({
|
|||||||
closeButton.type = 'button';
|
closeButton.type = 'button';
|
||||||
closeButton.className = 'icon-btn channel-editor__close';
|
closeButton.className = 'icon-btn channel-editor__close';
|
||||||
closeButton.setAttribute('aria-label', 'Закрыть редактор');
|
closeButton.setAttribute('aria-label', 'Закрыть редактор');
|
||||||
closeButton.innerHTML = iconHtml('close');
|
closeButton.textContent = '×';
|
||||||
const heading = document.createElement('h2');
|
const heading = document.createElement('h2');
|
||||||
heading.id = `${id}-title`;
|
heading.id = `${id}-title`;
|
||||||
heading.textContent = title;
|
heading.textContent = title;
|
||||||
// Закрытие не теряет текст: черновик хранится до отправки
|
header.append(closeButton, heading, document.createElement('span'));
|
||||||
const draftMark = document.createElement('span');
|
|
||||||
draftMark.className = 'channel-editor__draft';
|
|
||||||
draftMark.textContent = 'Черновик';
|
|
||||||
header.append(closeButton, heading, draftMark);
|
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'channel-editor__body';
|
body.className = 'channel-editor__body';
|
||||||
@@ -135,17 +130,9 @@ export function openChannelEditor({
|
|||||||
const author = document.createElement('div');
|
const author = document.createElement('div');
|
||||||
author.className = 'channel-editor__author';
|
author.className = 'channel-editor__author';
|
||||||
author.append(renderUserAvatar({ login: login || 'guest', className: 'avatar-plain', size: 'md' }));
|
author.append(renderUserAvatar({ login: login || 'guest', className: 'avatar-plain', size: 'md' }));
|
||||||
const authorText = document.createElement('span');
|
|
||||||
authorText.className = 'channel-editor__author-text';
|
|
||||||
const authorName = document.createElement('strong');
|
const authorName = document.createElement('strong');
|
||||||
authorName.textContent = login || 'Гость';
|
authorName.textContent = login || 'Гость';
|
||||||
authorText.append(authorName);
|
author.append(authorName);
|
||||||
if (destination) {
|
|
||||||
const where = document.createElement('small');
|
|
||||||
where.textContent = destination;
|
|
||||||
authorText.append(where);
|
|
||||||
}
|
|
||||||
author.append(authorText);
|
|
||||||
body.append(author);
|
body.append(author);
|
||||||
body.append(textarea, attachmentsBox, error, clearButton);
|
body.append(textarea, attachmentsBox, error, clearButton);
|
||||||
|
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
// Полноэкранный просмотр фото и видео из вложений: листание, «Скачать».
|
|
||||||
// Закрывается крестиком, Esc, смахиванием вниз или переходом назад.
|
|
||||||
import { iconHtml } from './ui-icon.js';
|
|
||||||
|
|
||||||
let activeClose = null;
|
|
||||||
|
|
||||||
function safeFileName(name, kind) {
|
|
||||||
const clean = String(name || '').trim().replace(/[\\/:*?"<>|]+/g, '_');
|
|
||||||
if (clean) return clean;
|
|
||||||
return kind === 'video' ? 'video.mp4' : 'image.jpg';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arweave отдаёт файл с другого адреса: атрибут download там не работает, поэтому скачиваем через blob.
|
|
||||||
async function downloadMedia(item) {
|
|
||||||
const name = safeFileName(item.name, item.kind);
|
|
||||||
try {
|
|
||||||
const response = await fetch(item.url);
|
|
||||||
if (!response.ok) throw new Error(String(response.status));
|
|
||||||
const blobUrl = URL.createObjectURL(await response.blob());
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = name;
|
|
||||||
document.body.append(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30000);
|
|
||||||
} catch {
|
|
||||||
window.open(item.url, '_blank', 'noopener');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* items: [{ url, kind: 'image' | 'video', name }], index – с какого начать.
|
|
||||||
*/
|
|
||||||
export function openMediaViewer(items = [], index = 0) {
|
|
||||||
const list = items.filter((item) => item?.url && (item.kind === 'image' || item.kind === 'video'));
|
|
||||||
if (!list.length) return;
|
|
||||||
activeClose?.();
|
|
||||||
|
|
||||||
let current = Math.max(0, Math.min(list.length - 1, index));
|
|
||||||
const root = document.getElementById('modal-root') || document.body;
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.className = 'media-viewer';
|
|
||||||
overlay.setAttribute('role', 'dialog');
|
|
||||||
overlay.setAttribute('aria-modal', 'true');
|
|
||||||
overlay.setAttribute('aria-label', 'Просмотр');
|
|
||||||
// Сверху – только «закрыть»; счётчик и «Скачать» – сразу под фото, по центру.
|
|
||||||
overlay.innerHTML = `
|
|
||||||
<div class="media-viewer__bar">
|
|
||||||
<button class="media-viewer__btn" type="button" data-action="close" aria-label="Закрыть">${iconHtml('close')}</button>
|
|
||||||
</div>
|
|
||||||
<div class="media-viewer__stage">
|
|
||||||
<div class="media-viewer__frame"></div>
|
|
||||||
<div class="media-viewer__controls">
|
|
||||||
<span class="media-viewer__counter"></span>
|
|
||||||
<button class="media-viewer__btn media-viewer__btn--round" type="button" data-action="download" aria-label="Скачать" title="Скачать">${iconHtml('download')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="media-viewer__nav media-viewer__nav--prev" type="button" data-action="prev" aria-label="Предыдущее">${iconHtml('back')}</button>
|
|
||||||
<button class="media-viewer__nav media-viewer__nav--next" type="button" data-action="next" aria-label="Следующее">${iconHtml('open')}</button>
|
|
||||||
`;
|
|
||||||
const stage = overlay.querySelector('.media-viewer__stage');
|
|
||||||
const frame = overlay.querySelector('.media-viewer__frame');
|
|
||||||
const counter = overlay.querySelector('.media-viewer__counter');
|
|
||||||
const prevBtn = overlay.querySelector('[data-action="prev"]');
|
|
||||||
const nextBtn = overlay.querySelector('[data-action="next"]');
|
|
||||||
|
|
||||||
const render = () => {
|
|
||||||
stage.querySelectorAll('video').forEach((video) => video.pause());
|
|
||||||
const item = list[current];
|
|
||||||
let media;
|
|
||||||
if (item.kind === 'video') {
|
|
||||||
media = document.createElement('video');
|
|
||||||
media.controls = true;
|
|
||||||
media.autoplay = true;
|
|
||||||
media.playsInline = true;
|
|
||||||
} else {
|
|
||||||
media = document.createElement('img');
|
|
||||||
media.alt = item.name || '';
|
|
||||||
media.decoding = 'async';
|
|
||||||
}
|
|
||||||
media.className = 'media-viewer__media';
|
|
||||||
media.src = item.url;
|
|
||||||
frame.replaceChildren(media);
|
|
||||||
counter.textContent = list.length > 1 ? `${current + 1}/${list.length}` : '';
|
|
||||||
counter.hidden = list.length <= 1;
|
|
||||||
prevBtn.hidden = current <= 0;
|
|
||||||
nextBtn.hidden = current >= list.length - 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const go = (delta) => {
|
|
||||||
const next = current + delta;
|
|
||||||
if (next < 0 || next >= list.length) return;
|
|
||||||
current = next;
|
|
||||||
render();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKey = (event) => {
|
|
||||||
if (event.key === 'Escape') close();
|
|
||||||
else if (event.key === 'ArrowLeft') go(-1);
|
|
||||||
else if (event.key === 'ArrowRight') go(1);
|
|
||||||
};
|
|
||||||
const onPopState = () => close();
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
if (activeClose !== close) return;
|
|
||||||
activeClose = null;
|
|
||||||
document.removeEventListener('keydown', onKey);
|
|
||||||
window.removeEventListener('popstate', onPopState);
|
|
||||||
stage.querySelectorAll('video').forEach((video) => video.pause());
|
|
||||||
overlay.remove();
|
|
||||||
};
|
|
||||||
|
|
||||||
let swiped = false;
|
|
||||||
overlay.addEventListener('click', (event) => {
|
|
||||||
// Клик, который браузер присылает после смахивания, не должен прятать панель или закрывать просмотр.
|
|
||||||
if (swiped) { swiped = false; return; }
|
|
||||||
const action = event.target.closest('[data-action]')?.dataset.action;
|
|
||||||
if (action === 'close') close();
|
|
||||||
else if (action === 'prev') go(-1);
|
|
||||||
else if (action === 'next') go(1);
|
|
||||||
else if (action === 'download') void downloadMedia(list[current]);
|
|
||||||
else if (event.target.closest('.media-viewer__media')?.tagName === 'IMG') overlay.classList.toggle('is-bare');
|
|
||||||
else if (event.target === stage || event.target === frame || event.target === overlay) close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Смахивание: вбок – листать, вниз – закрыть.
|
|
||||||
let startX = 0;
|
|
||||||
let startY = 0;
|
|
||||||
let tracking = false;
|
|
||||||
stage.addEventListener('pointerdown', (event) => {
|
|
||||||
if (event.target.tagName === 'VIDEO') return;
|
|
||||||
tracking = true;
|
|
||||||
startX = event.clientX;
|
|
||||||
startY = event.clientY;
|
|
||||||
});
|
|
||||||
stage.addEventListener('pointermove', (event) => {
|
|
||||||
if (!tracking) return;
|
|
||||||
const dy = event.clientY - startY;
|
|
||||||
if (dy > 0 && Math.abs(dy) > Math.abs(event.clientX - startX)) {
|
|
||||||
stage.style.transform = `translateY(${dy}px)`;
|
|
||||||
overlay.style.setProperty('--viewer-dim', String(Math.max(0.4, 1 - dy / 400)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const endSwipe = (event) => {
|
|
||||||
if (!tracking) return;
|
|
||||||
tracking = false;
|
|
||||||
const dx = event.clientX - startX;
|
|
||||||
const dy = event.clientY - startY;
|
|
||||||
swiped = Math.hypot(dx, dy) > 10;
|
|
||||||
stage.style.transform = '';
|
|
||||||
overlay.style.removeProperty('--viewer-dim');
|
|
||||||
if (dy > 90 && dy > Math.abs(dx)) close();
|
|
||||||
else if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) go(dx < 0 ? 1 : -1);
|
|
||||||
};
|
|
||||||
stage.addEventListener('pointerup', endSwipe);
|
|
||||||
stage.addEventListener('pointercancel', endSwipe);
|
|
||||||
|
|
||||||
document.addEventListener('keydown', onKey);
|
|
||||||
window.addEventListener('popstate', onPopState);
|
|
||||||
activeClose = close;
|
|
||||||
render();
|
|
||||||
root.append(overlay);
|
|
||||||
overlay.querySelector('[data-action="close"]')?.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { createDropdownMenu } from './dropdown-menu.js';
|
import { createDropdownMenu } from './dropdown-menu.js';
|
||||||
import { iconHtml } from './ui-icon.js';
|
|
||||||
|
|
||||||
export function attachMessageMenu(card, head, items) {
|
export function attachMessageMenu(card, head, items) {
|
||||||
const trigger = document.createElement('button');
|
const trigger = document.createElement('button');
|
||||||
trigger.type = 'button';
|
trigger.type = 'button';
|
||||||
trigger.className = 'icon-btn channel-message-more';
|
trigger.className = 'icon-btn channel-message-more';
|
||||||
trigger.innerHTML = iconHtml('more');
|
trigger.textContent = '⋯';
|
||||||
trigger.setAttribute('aria-label', 'Действия сообщения');
|
trigger.setAttribute('aria-label', 'Действия сообщения');
|
||||||
head.append(trigger);
|
head.append(trigger);
|
||||||
const menu = createDropdownMenu({ anchorEl: trigger, items });
|
const menu = createDropdownMenu({ anchorEl: trigger, items });
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function statusChip({ kind, label, value, active }) {
|
|||||||
const n = Number(value || 0);
|
const n = Number(value || 0);
|
||||||
return `
|
return `
|
||||||
<button type="button" class="pf-status${active ? ' is-active' : ''}" data-profile-list="${esc(kind)}"
|
<button type="button" class="pf-status${active ? ' is-active' : ''}" data-profile-list="${esc(kind)}"
|
||||||
aria-label="${esc(label)}: подтверждений ${n}" title="Статус «${esc(label)}». Число – сколько людей его подтвердили. Нажмите, чтобы увидеть кто.">
|
aria-label="${esc(label)}: подтверждений ${n}" title="Статус «${esc(label)}». Число — сколько людей его подтвердили. Нажмите, чтобы увидеть кто.">
|
||||||
<span class="pf-status-dot" aria-hidden="true"></span>${esc(label)}${n > 0 ? `<b>${n}</b>` : ''}
|
<span class="pf-status-dot" aria-hidden="true"></span>${esc(label)}${n > 0 ? `<b>${n}</b>` : ''}
|
||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { openSheet } from './sheet.js';
|
|
||||||
import { iconHtml } from './ui-icon.js';
|
|
||||||
import { showToast } from '../services/channels-ux.js';
|
|
||||||
|
|
||||||
// «Поделиться записью / комментарием»: что именно отправляется, ссылка и «Скопировать ссылку».
|
|
||||||
// Системное меню «Поделиться» — вторым действием, если браузер его умеет.
|
|
||||||
export function openShareSheet({ title = 'Поделиться', kicker = '', text = '', url = '' } = {}) {
|
|
||||||
return openSheet({
|
|
||||||
title,
|
|
||||||
className: 'share-sheet',
|
|
||||||
content: (sheet) => {
|
|
||||||
const wrap = document.createElement('div');
|
|
||||||
wrap.className = 'share-sheet__content';
|
|
||||||
|
|
||||||
const preview = document.createElement('div');
|
|
||||||
preview.className = 'share-sheet__preview';
|
|
||||||
const k = document.createElement('strong');
|
|
||||||
k.textContent = kicker;
|
|
||||||
const t = document.createElement('p');
|
|
||||||
t.textContent = text;
|
|
||||||
preview.append(k, t);
|
|
||||||
|
|
||||||
const label = document.createElement('label');
|
|
||||||
label.className = 'share-sheet__label';
|
|
||||||
label.textContent = 'Ссылка';
|
|
||||||
const field = document.createElement('input');
|
|
||||||
field.className = 'share-sheet__link';
|
|
||||||
field.readOnly = true;
|
|
||||||
field.value = url;
|
|
||||||
field.addEventListener('focus', () => field.select());
|
|
||||||
label.append(field);
|
|
||||||
|
|
||||||
const copy = document.createElement('button');
|
|
||||||
copy.type = 'button';
|
|
||||||
copy.className = 'primary-btn share-sheet__copy';
|
|
||||||
copy.innerHTML = `${iconHtml('copy')}<span>Скопировать ссылку</span>`;
|
|
||||||
copy.addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(url);
|
|
||||||
showToast('Ссылка скопирована');
|
|
||||||
sheet.close();
|
|
||||||
} catch {
|
|
||||||
field.focus();
|
|
||||||
field.select();
|
|
||||||
showToast('Выделил ссылку – скопируйте её вручную', { kind: 'error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
wrap.append(preview, label, copy);
|
|
||||||
|
|
||||||
if (navigator.share) {
|
|
||||||
const system = document.createElement('button');
|
|
||||||
system.type = 'button';
|
|
||||||
system.className = 'secondary-btn share-sheet__system';
|
|
||||||
system.innerHTML = `${iconHtml('share')}<span>Другое приложение…</span>`;
|
|
||||||
system.addEventListener('click', async () => {
|
|
||||||
try { await navigator.share({ title: kicker, text, url }); sheet.close(); } catch { /* отменили */ }
|
|
||||||
});
|
|
||||||
wrap.append(system);
|
|
||||||
}
|
|
||||||
return wrap;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { iconHtml } from './ui-icon.js';
|
|
||||||
|
|
||||||
// Шторка снизу (на широком экране — окно по центру) с заголовком и «✕».
|
|
||||||
// Закрывается по «✕», Escape и тапу мимо; фокус возвращается туда, откуда открыли.
|
|
||||||
// Возвращает { root, body, footer, close }. content — Node или функция (sheet) => Node.
|
|
||||||
export function openSheet({ title = '', content = null, className = '', onClose = null } = {}) {
|
|
||||||
const opener = document.activeElement;
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = `modal sheet${className ? ` ${className}` : ''}`;
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'modal-card sheet__card';
|
|
||||||
card.setAttribute('role', 'dialog');
|
|
||||||
card.setAttribute('aria-modal', 'true');
|
|
||||||
|
|
||||||
const head = document.createElement('div');
|
|
||||||
head.className = 'sheet__head';
|
|
||||||
const heading = document.createElement('h2');
|
|
||||||
heading.className = 'modal-title sheet__title';
|
|
||||||
heading.textContent = title;
|
|
||||||
heading.id = `sheet-title-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
card.setAttribute('aria-labelledby', heading.id);
|
|
||||||
const closeBtn = document.createElement('button');
|
|
||||||
closeBtn.type = 'button';
|
|
||||||
closeBtn.className = 'icon-btn sheet__close';
|
|
||||||
closeBtn.setAttribute('aria-label', 'Закрыть');
|
|
||||||
closeBtn.innerHTML = iconHtml('close');
|
|
||||||
head.append(heading, closeBtn);
|
|
||||||
|
|
||||||
const body = document.createElement('div');
|
|
||||||
body.className = 'sheet__body';
|
|
||||||
const footer = document.createElement('div');
|
|
||||||
footer.className = 'sheet__footer';
|
|
||||||
footer.hidden = true;
|
|
||||||
card.append(head, body, footer);
|
|
||||||
modal.append(card);
|
|
||||||
|
|
||||||
let closed = false;
|
|
||||||
const close = () => {
|
|
||||||
if (closed) return;
|
|
||||||
closed = true;
|
|
||||||
document.removeEventListener('keydown', onKeydown, true);
|
|
||||||
modal.remove();
|
|
||||||
opener?.focus?.();
|
|
||||||
onClose?.();
|
|
||||||
};
|
|
||||||
const onKeydown = (event) => {
|
|
||||||
if (event.key === 'Escape') { event.stopPropagation(); close(); }
|
|
||||||
};
|
|
||||||
modal.addEventListener('click', (event) => { if (event.target === modal) close(); });
|
|
||||||
closeBtn.addEventListener('click', close);
|
|
||||||
document.addEventListener('keydown', onKeydown, true);
|
|
||||||
|
|
||||||
const sheet = { root: modal, body, footer, close };
|
|
||||||
const node = typeof content === 'function' ? content(sheet) : content;
|
|
||||||
if (node instanceof Node) body.append(node);
|
|
||||||
if (footer.childElementCount) footer.hidden = false;
|
|
||||||
document.body.append(modal);
|
|
||||||
(card.querySelector('[autofocus]') || closeBtn).focus();
|
|
||||||
return sheet;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// Надпись «СИЯНИЕ» вразрядку: Tenor Sans (OFL) в контурах — шрифт не скачивается.
|
|
||||||
// Цвет — currentColor, поэтому надпись берёт цвет текста темы.
|
|
||||||
const NS = 'http://www.w3.org/2000/svg';
|
|
||||||
const WIDTH = 7336;
|
|
||||||
const PATH = 'M721 -129Q619 -33 479 -33Q401 -33 341 -60Q281 -87 241 -132Q201 -177 180.5 -236Q160 -295 160 -359Q160 -427 181.5 -483.5Q203 -540 242 -581Q281 -622 334.5 -645Q388 -668 451 -668Q494 -668 529.5 -658Q565 -648 592.5 -632Q620 -616 637.5 -595.5Q655 -575 662 -554H736Q721 -588 697.5 -617Q674 -646 639 -667Q604 -688 557 -700Q510 -712 450 -712Q363 -712 291.5 -687Q220 -662 169 -615.5Q118 -569 90 -503.5Q62 -438 62 -357Q62 -287 84.5 -220.5Q107 -154 154 -102.5Q201 -51 274 -19.5Q347 12 448 12Q512 12 562.5 0Q613 -12 650.5 -29.5Q688 -47 713 -67Q738 -87 751 -102ZM1542 -82V-700H1451V0H1542L1957 -625V0H2048V-700H1957ZM3316 -700V0H3225V-309H3181Q3153 -309 3134.5 -304Q3116 -299 3101 -287Q3086 -275 3073 -256Q3060 -237 3042 -209L2963 -92Q2948 -69 2933.5 -50Q2919 -31 2902 -17.5Q2885 -4 2863.5 4Q2842 12 2812 12Q2790 12 2776 10Q2762 8 2748 0V-37Q2753 -36 2758 -35.5Q2763 -35 2768 -35Q2789 -35 2805 -41Q2821 -47 2835 -58.5Q2849 -70 2862 -86.5Q2875 -103 2889 -124Q2931 -185 2954 -221.5Q2977 -258 2990.5 -278Q3004 -298 3012.5 -307Q3021 -316 3033 -322L3043 -327V-331Q2995 -334 2960.5 -352Q2926 -370 2904 -397Q2882 -424 2872 -455.5Q2862 -487 2862 -517Q2862 -544 2872.5 -576Q2883 -608 2908.5 -635.5Q2934 -663 2978 -681.5Q3022 -700 3089 -700ZM3225 -656H3124Q3086 -656 3055.5 -645Q3025 -634 3003 -614.5Q2981 -595 2969.5 -568Q2958 -541 2958 -509Q2958 -482 2968.5 -454.5Q2979 -427 3000 -404.5Q3021 -382 3051.5 -368Q3082 -354 3122 -354H3225ZM4197 -348H4616V0H4707V-700H4616V-394H4197V-700H4106V0H4197ZM5588 -82V-700H5497V0H5588L6003 -625V0H6094V-700H6003ZM6884 -700V0H7336V-55H6975V-346H7285V-394H6975V-645H7336V-700Z';
|
|
||||||
|
|
||||||
export function createShineWordmark({ className = '' } = {}) {
|
|
||||||
const svg = document.createElementNS(NS, 'svg');
|
|
||||||
svg.setAttribute('viewBox', `52 -722 ${WIDTH - 42} 744`);
|
|
||||||
svg.setAttribute('class', `shine-wordmark ${className}`.trim());
|
|
||||||
svg.setAttribute('role', 'img');
|
|
||||||
svg.setAttribute('aria-label', 'Сияние');
|
|
||||||
const path = document.createElementNS(NS, 'path');
|
|
||||||
path.setAttribute('d', PATH);
|
|
||||||
path.setAttribute('fill', 'currentColor');
|
|
||||||
svg.append(path);
|
|
||||||
return svg;
|
|
||||||
}
|
|
||||||
@@ -96,22 +96,22 @@ function navigateWithGuestRules(pageId, navigate) {
|
|||||||
}
|
}
|
||||||
if (pageId === 'messages-list') {
|
if (pageId === 'messages-list') {
|
||||||
openAuthRequiredModal({
|
openAuthRequiredModal({
|
||||||
title: 'Нужен аккаунт',
|
title: 'Личные сообщения недоступны',
|
||||||
text: 'Чтобы писать личные сообщения, войдите или зарегистрируйтесь.',
|
text: 'Вы не авторизованы. Для личных сообщений сначала войдите в систему.',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pageId === 'profile-view') {
|
if (pageId === 'profile-view') {
|
||||||
openAuthRequiredModal({
|
openAuthRequiredModal({
|
||||||
title: 'Нужен аккаунт',
|
title: 'Профиль недоступен',
|
||||||
text: 'Чтобы открыть профиль, войдите или зарегистрируйтесь.',
|
text: 'Вы не авторизованы. Для профиля сначала войдите в систему.',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pageId === 'notifications-view') {
|
if (pageId === 'notifications-view') {
|
||||||
openAuthRequiredModal({
|
openAuthRequiredModal({
|
||||||
title: 'Нужен аккаунт',
|
title: 'Уведомления недоступны',
|
||||||
text: 'Чтобы получать уведомления, войдите или зарегистрируйтесь.',
|
text: 'Вы не авторизованы. Для уведомлений сначала войдите в систему.',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ function createActionButton(action = {}, cleanupFns) {
|
|||||||
button.append(action.iconNode);
|
button.append(action.iconNode);
|
||||||
} else if (action.icon) {
|
} else if (action.icon) {
|
||||||
button.innerHTML = iconHtml(action.icon);
|
button.innerHTML = iconHtml(action.icon);
|
||||||
} else if (action.label === '⋮' || action.label === '⋯') {
|
|
||||||
// Меню экрана везде рисуется одной иконкой «⋯»
|
|
||||||
button.innerHTML = iconHtml('more');
|
|
||||||
} else {
|
} else {
|
||||||
button.textContent = String(action.label ?? '');
|
button.textContent = String(action.label ?? '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,11 @@
|
|||||||
const paths = {
|
const paths = {
|
||||||
heart: '<path d="M12 21S2.3 15.5 2.3 8.7C2.3 3.6 8.1 1.9 12 6.5c3.9-4.6 9.7-2.9 9.7 2.2C21.7 15.5 12 21 12 21Z"/>',
|
heart: '<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.6a5.5 5.5 0 0 0 0-7.8Z"/>',
|
||||||
message: '<path d="M5.2 2.35h13.6a2.7 2.7 0 0 1 2.7 2.7v9.7a2.7 2.7 0 0 1-2.7 2.7h-7.1L7.2 21v-3.55h-2a2.7 2.7 0 0 1-2.7-2.7v-9.7a2.7 2.7 0 0 1 2.7-2.7Z"/>',
|
message: '<path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2v-9.5A8.5 8.5 0 0 1 10.5 4H13a8 8 0 0 1 8 7.5Z"/>',
|
||||||
more: '<circle cx="4" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="20" cy="12" r="1.6" fill="currentColor" stroke="none"/>',
|
|
||||||
forward: '<path d="m14.4 3.1 7.2 7.1-7.2 6.6v-4.4C8.5 12.4 5 15.1 2.4 20c.4-7.6 4.4-11.7 12-12.4Z"/>',
|
|
||||||
download: '<path d="M12 4v11M7 10.5l5 5 5-5M5 20h14"/>',
|
|
||||||
reply: '<path d="M9 7 4 12l5 5"/><path d="M4 12h9a7 7 0 0 1 7 7"/>',
|
|
||||||
copy: '<rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/>',
|
|
||||||
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5.5M12 7.6v.1"/>',
|
|
||||||
trash: '<path d="M4 7h16M9.5 7V4.8h5V7M6.5 7l.9 12.2a1.9 1.9 0 0 0 1.9 1.8h5.4a1.9 1.9 0 0 0 1.9-1.8L17.5 7M10 11v6M14 11v6"/>',
|
|
||||||
'bell-off': '<path d="M8.5 4.2A6 6 0 0 1 18 9c0 4.3 1.1 6.2 2 7.2M18 17H3c0-2 3-2 3-9M10 21h4M3 3l18 18"/>',
|
|
||||||
phone: '<path d="M5 3.5h3.2l1.6 4.2-2.1 1.4a11 11 0 0 0 5.2 5.2l1.4-2.1 4.2 1.6V17a2.5 2.5 0 0 1-2.7 2.5A16 16 0 0 1 2.5 6.2 2.5 2.5 0 0 1 5 3.5Z"/>',
|
|
||||||
video: '<rect x="2.5" y="6" width="13" height="12" rx="2.5"/><path d="m15.5 10.5 6-3.5v10l-6-3.5"/>',
|
|
||||||
emoji: '<circle cx="12" cy="12" r="9"/><path d="M8.3 14.2a4.6 4.6 0 0 0 7.4 0"/><circle cx="9" cy="9.8" r=".9" fill="currentColor" stroke="none"/><circle cx="15" cy="9.8" r=".9" fill="currentColor" stroke="none"/>',
|
|
||||||
send: '<path d="M12 19V5M5.5 11.5 12 5l6.5 6.5"/>',
|
|
||||||
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/>',
|
|
||||||
image: '<rect x="3" y="4.5" width="18" height="15" rx="2.5"/><circle cx="8.5" cy="9.5" r="1.6"/><path d="m21 16-4.7-4.7L7 20.5"/>',
|
|
||||||
list: '<path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r=".9" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r=".9" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r=".9" fill="currentColor" stroke="none"/>',
|
|
||||||
doc: '<path d="M6.5 3h7.5L19 8v11.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5 19.5v-15A1.5 1.5 0 0 1 6.5 3Z"/><path d="M14 3v5h5M8.5 13h7M8.5 16.5h5"/>',
|
|
||||||
close: '<path d="M6 6l12 12M18 6 6 18"/>',
|
|
||||||
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
|
|
||||||
alert: '<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.4v.1"/>',
|
|
||||||
'check-double': '<path d="m2.5 12.5 4 4L15 8"/><path d="m10.5 16 1 .5L21 8"/>',
|
|
||||||
compose: '<path d="M13 4H6.5A2.5 2.5 0 0 0 4 6.5v11A2.5 2.5 0 0 0 6.5 20h11a2.5 2.5 0 0 0 2.5-2.5V11"/><path d="m18.5 3.8 1.7 1.7a1.3 1.3 0 0 1 0 1.8L12.4 15H9v-3.4l7.7-7.8a1.3 1.3 0 0 1 1.8 0Z"/>',
|
|
||||||
channels: '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
|
channels: '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
|
||||||
network: '<circle cx="12" cy="5" r="3"/><circle cx="5" cy="18" r="3"/><circle cx="19" cy="18" r="3"/><path d="m10 8-4 7m8-7 4 7M8 18h8"/>',
|
network: '<circle cx="12" cy="5" r="3"/><circle cx="5" cy="18" r="3"/><circle cx="19" cy="18" r="3"/><path d="m10 8-4 7m8-7 4 7M8 18h8"/>',
|
||||||
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"/>',
|
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"/>',
|
||||||
profile: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
|
profile: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
|
||||||
share: '<path d="m14.4 3.1 7.2 7.1-7.2 6.6v-4.4C8.5 12.4 5 15.1 2.4 20c.4-7.6 4.4-11.7 12-12.4Z"/>',
|
share: '<path d="m8 12 8-8M9 4h7v7M20 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
|
||||||
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
|
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
|
||||||
link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3.2-3.2a4.5 4.5 0 0 0-6.4-6.4L12 5.6"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3.2 3.2a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/>',
|
link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3.2-3.2a4.5 4.5 0 0 0-6.4-6.4L12 5.6"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3.2 3.2a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/>',
|
||||||
gift: '<rect x="3" y="8" width="18" height="5" rx="1"/><path d="M5 13v7a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-7M12 8v13M12 8S10.5 3 8 3.5 7 8 12 8Zm0 0s1.5-5 4-4.5S17 8 12 8Z"/>',
|
gift: '<rect x="3" y="8" width="18" height="5" rx="1"/><path d="M5 13v7a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-7M12 8v13M12 8S10.5 3 8 3.5 7 8 12 8Zm0 0s1.5-5 4-4.5S17 8 12 8Z"/>',
|
||||||
@@ -41,11 +20,9 @@ const paths = {
|
|||||||
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
|
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
|
||||||
'user-plus': '<circle cx="10" cy="8" r="3.5"/><path d="M3.5 20a6.5 6.5 0 0 1 13 0M19 8v6M16 11h6"/>',
|
'user-plus': '<circle cx="10" cy="8" r="3.5"/><path d="M3.5 20a6.5 6.5 0 0 1 13 0M19 8v6M16 11h6"/>',
|
||||||
refresh: '<path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v4.4h-4.4"/>',
|
refresh: '<path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v4.4h-4.4"/>',
|
||||||
eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="3"/>',
|
|
||||||
eyeOff: '<path d="M10.2 5.7A9.6 9.6 0 0 1 12 5.5c6 0 9.5 6.5 9.5 6.5a16.4 16.4 0 0 1-2.6 3.4M6.4 7A16.2 16.2 0 0 0 2.5 12S6 18.5 12 18.5a9 9 0 0 0 4.9-1.4"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2M3.5 3.5l17 17"/>',
|
|
||||||
clip: '<path d="m20.5 11.5-8.2 8.2a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/>',
|
clip: '<path d="m20.5 11.5-8.2 8.2a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/>',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function iconHtml(name, filled = false, className = '') {
|
export function iconHtml(name, filled = false) {
|
||||||
return `<svg${className ? ` class="${className}"` : ''} viewBox="0 0 24 24" fill="${filled ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.message}</svg>`;
|
return `<svg viewBox="0 0 24 24" fill="${filled ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.message}</svg>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,14 +177,14 @@ function createPasswordModal() {
|
|||||||
<div class="stack" style="gap:0.45rem;">
|
<div class="stack" style="gap:0.45rem;">
|
||||||
<label class="checkbox-row">
|
<label class="checkbox-row">
|
||||||
<input type="radio" name="access-servers-key-mode" value="once" checked />
|
<input type="radio" name="access-servers-key-mode" value="once" checked />
|
||||||
<span>Использовать главный ключ только сейчас</span>
|
<span>Использовать blockchain key только сейчас</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox-row">
|
<label class="checkbox-row">
|
||||||
<input type="radio" name="access-servers-key-mode" value="save" />
|
<input type="radio" name="access-servers-key-mode" value="save" />
|
||||||
<span>Сохранить главный ключ на этом устройстве</span>
|
<span>Сохранить blockchain key на этом устройстве</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
|
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, blockchain key попадёт в зашифрованный контейнер устройства.</p>
|
||||||
<div class="form-actions-grid">
|
<div class="form-actions-grid">
|
||||||
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button>
|
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button>
|
||||||
<button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button>
|
<button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button>
|
||||||
@@ -247,7 +247,7 @@ function createPasswordModal() {
|
|||||||
const mode = root.querySelector('input[name="access-servers-key-mode"]:checked');
|
const mode = root.querySelector('input[name="access-servers-key-mode"]:checked');
|
||||||
close({
|
close({
|
||||||
password,
|
password,
|
||||||
saveRoot: mode instanceof HTMLInputElement && mode.value === 'save',
|
saveBlockchain: mode instanceof HTMLInputElement && mode.value === 'save',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
window.setTimeout(() => inputEl.focus(), 0);
|
window.setTimeout(() => inputEl.focus(), 0);
|
||||||
@@ -482,48 +482,49 @@ export function render({navigate, chrome}) {
|
|||||||
saved = null;
|
saved = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedRoot = String(saved?.rootKey || '').trim();
|
const savedBlockchain = String(saved?.blockchainKey || '').trim();
|
||||||
const savedClient = String(saved?.clientKey || '').trim();
|
const savedClient = String(saved?.clientKey || '').trim();
|
||||||
if (savedRoot && savedClient) {
|
if (savedBlockchain && savedClient) {
|
||||||
return {
|
return {
|
||||||
rootPrivatePkcs8B64: savedRoot,
|
blockchainPrivatePkcs8B64: savedBlockchain,
|
||||||
clientPrivatePkcs8B64: savedClient,
|
clientPrivatePkcs8B64: savedClient,
|
||||||
clientAddress: await clientAddressFromPrivatePkcs8(savedClient),
|
payerAddress: await clientAddressFromPrivatePkcs8(savedBlockchain),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordResult = await passwordModal?.open({
|
const passwordResult = await passwordModal?.open({
|
||||||
title: 'Нужен пароль для обновления серверов доступа',
|
title: 'Нужен пароль для обновления серверов доступа',
|
||||||
text: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
text: 'Чтобы изменить сервер доступа, нужно подписать обновление PDA текущим blockchain key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||||
note: savedClient
|
note: savedClient
|
||||||
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.'
|
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен blockchain key.'
|
||||||
: 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
|
: 'На устройстве не хватает blockchain key и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
|
||||||
});
|
});
|
||||||
if (!passwordResult) {
|
if (!passwordResult) {
|
||||||
throw new Error('Операция отменена пользователем.');
|
throw new Error('Операция отменена пользователем.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password);
|
const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password);
|
||||||
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64);
|
const derivedBlockchainPublic = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
|
||||||
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
|
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
|
||||||
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
|
const activeBlockchain = currentPda.forks?.at(-1)?.blockchainKey;
|
||||||
throw new Error('Пароль не подходит: главный ключ не совпал с записью аккаунта.');
|
if (!activeBlockchain || !equalBytes(derivedBlockchainPublic, activeBlockchain)) {
|
||||||
|
throw new Error('Пароль не подходит: blockchain key не совпал с активным fork аккаунта.');
|
||||||
}
|
}
|
||||||
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
|
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
|
||||||
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
|
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passwordResult.saveRoot) {
|
if (passwordResult.saveBlockchain) {
|
||||||
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
|
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
|
||||||
saveRoot: true,
|
saveRoot: false,
|
||||||
saveBlockchain: false,
|
saveBlockchain: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
|
blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
|
||||||
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
|
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
|
||||||
clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64),
|
payerAddress: clientAddressFromPublicB64(keyBundle.blockchainPair.publicKeyB64),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -550,7 +551,7 @@ export function render({navigate, chrome}) {
|
|||||||
const tx = await updateShineUserPdaOnSolana({
|
const tx = await updateShineUserPdaOnSolana({
|
||||||
login: sessionLogin,
|
login: sessionLogin,
|
||||||
solanaEndpoint,
|
solanaEndpoint,
|
||||||
rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64,
|
blockchainPrivatePkcs8B64: signingMaterial.blockchainPrivatePkcs8B64,
|
||||||
clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64,
|
clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64,
|
||||||
accessServers: normalizedList,
|
accessServers: normalizedList,
|
||||||
});
|
});
|
||||||
@@ -562,7 +563,7 @@ export function render({navigate, chrome}) {
|
|||||||
refreshAddButton();
|
refreshAddButton();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isInsufficientFundsForRentError(error)) {
|
if (isInsufficientFundsForRentError(error)) {
|
||||||
showTopupRequiredStatus(target, signingMaterial?.clientAddress);
|
showTopupRequiredStatus(target, signingMaterial?.payerAddress);
|
||||||
} else {
|
} else {
|
||||||
target.textContent = error?.message || 'Не удалось обновить сервер доступа.';
|
target.textContent = error?.message || 'Не удалось обновить сервер доступа.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function createToggleRow({ id, title, hint, checked }) {
|
|||||||
<strong class="settings-feature-toggle-title">${title}</strong>
|
<strong class="settings-feature-toggle-title">${title}</strong>
|
||||||
<span class="settings-feature-toggle-hint">${hint}</span>
|
<span class="settings-feature-toggle-hint">${hint}</span>
|
||||||
</span>
|
</span>
|
||||||
<input class="switch" id="${id}" type="checkbox" ${checked ? 'checked' : ''} />
|
<input class="settings-feature-switch" id="${id}" type="checkbox" ${checked ? 'checked' : ''} />
|
||||||
`;
|
`;
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ export function render({ navigate, chrome }) {
|
|||||||
const intro = document.createElement('div');
|
const intro = document.createElement('div');
|
||||||
intro.className = 'settings-intro';
|
intro.className = 'settings-intro';
|
||||||
intro.innerHTML = `
|
intro.innerHTML = `
|
||||||
<p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по заголовку «Настройки».</p>
|
<p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по логотипу в настройках.</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const developerRow = createToggleRow({
|
const developerRow = createToggleRow({
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function render({navigate, chrome}) {
|
|||||||
back: { onClick: () => navigate('settings-view') },
|
back: { onClick: () => navigate('settings-view') },
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
icon: 'plus',
|
label: '+',
|
||||||
title: 'Добавить файл',
|
title: 'Добавить файл',
|
||||||
ariaLabel: 'Добавить файл',
|
ariaLabel: 'Добавить файл',
|
||||||
className: 'arweave-uploads-add',
|
className: 'arweave-uploads-add',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
formatSol,
|
formatSol,
|
||||||
getBalanceSol,
|
getBalanceSol,
|
||||||
getSolanaWalletFromStoredSecret,
|
getSolanaWalletFromStoredSecret,
|
||||||
getWalletFromStoredClientKey,
|
getWalletFromStoredBlockchainKey,
|
||||||
getWalletFromStoredRootKey,
|
getWalletFromStoredRootKey,
|
||||||
solanaAddressFromPublicKeyBase64,
|
solanaAddressFromPublicKeyBase64,
|
||||||
transferSol,
|
transferSol,
|
||||||
@@ -138,9 +138,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
|||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
|
||||||
const recipientChoices = [
|
const recipientChoices = [
|
||||||
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
|
|
||||||
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
|
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
|
||||||
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
@@ -163,18 +161,15 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
|||||||
<select class="select" id="channel-support-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
|
<select class="select" id="channel-support-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
|
||||||
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
|
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
<div class="channel-support-address" id="channel-support-recipient-address">–</div>
|
<div class="channel-support-address" id="channel-support-recipient-address">—</div>
|
||||||
|
|
||||||
<label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label>
|
<label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label>
|
||||||
<select class="select" id="channel-support-sender-key">
|
<div class="channel-support-address" id="channel-support-sender-key">Blockchain key</div>
|
||||||
<option value="client-key">Client key</option>
|
<div class="channel-support-address" id="channel-support-sender-address">—</div>
|
||||||
<option value="root-key">Root key</option>
|
|
||||||
</select>
|
|
||||||
<div class="channel-support-address" id="channel-support-sender-address">–</div>
|
|
||||||
|
|
||||||
<div class="channel-support-balance-row">
|
<div class="channel-support-balance-row">
|
||||||
<span class="meta-muted">Баланс</span>
|
<span class="meta-muted">Баланс</span>
|
||||||
<strong id="channel-support-balance">–</strong>
|
<strong id="channel-support-balance">—</strong>
|
||||||
<button class="secondary-btn channel-support-refresh" id="channel-support-refresh" type="button">Обновить</button>
|
<button class="secondary-btn channel-support-refresh" id="channel-support-refresh" type="button">Обновить</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -247,31 +242,13 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveSenderWallet = async () => {
|
const resolveSenderWallet = async () => {
|
||||||
const keyId = String(senderSelect?.value || 'client-key');
|
const keyId = 'blockchain-key';
|
||||||
if (walletCache.has(keyId)) return walletCache.get(keyId);
|
if (walletCache.has(keyId)) return walletCache.get(keyId);
|
||||||
|
|
||||||
const login = String(state.session.login || '').trim();
|
const login = String(state.session.login || '').trim();
|
||||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
||||||
|
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
|
||||||
let wallet;
|
|
||||||
if (keyId === 'root-key') {
|
|
||||||
try {
|
|
||||||
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
|
|
||||||
} catch {
|
|
||||||
const password = window.prompt(
|
|
||||||
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
if (password == null) throw new Error('Операция отменена.');
|
|
||||||
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
|
||||||
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
|
|
||||||
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
|
|
||||||
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
|
|
||||||
}
|
|
||||||
|
|
||||||
walletCache.set(keyId, wallet);
|
walletCache.set(keyId, wallet);
|
||||||
return wallet;
|
return wallet;
|
||||||
@@ -281,7 +258,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
|||||||
if (busy || closed) return;
|
if (busy || closed) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatus('Загрузка баланса…');
|
setStatus('Загрузка баланса…');
|
||||||
if (balanceEl) balanceEl.textContent = '–';
|
if (balanceEl) balanceEl.textContent = '—';
|
||||||
try {
|
try {
|
||||||
const wallet = await resolveSenderWallet();
|
const wallet = await resolveSenderWallet();
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
@@ -295,7 +272,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
|||||||
setStatus('');
|
setStatus('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
if (senderAddressEl) senderAddressEl.textContent = '–';
|
if (senderAddressEl) senderAddressEl.textContent = '—';
|
||||||
setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error');
|
setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
if (!closed) setBusy(false);
|
if (!closed) setBusy(false);
|
||||||
@@ -304,8 +281,8 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
|||||||
|
|
||||||
recipientSelect?.addEventListener('change', updateRecipientAddress);
|
recipientSelect?.addEventListener('change', updateRecipientAddress);
|
||||||
senderSelect?.addEventListener('change', () => {
|
senderSelect?.addEventListener('change', () => {
|
||||||
if (senderAddressEl) senderAddressEl.textContent = '–';
|
if (senderAddressEl) senderAddressEl.textContent = '—';
|
||||||
if (balanceEl) balanceEl.textContent = '–';
|
if (balanceEl) balanceEl.textContent = '—';
|
||||||
setStatus('');
|
setStatus('');
|
||||||
void refreshBalance();
|
void refreshBalance();
|
||||||
});
|
});
|
||||||
@@ -494,13 +471,13 @@ export function render({ navigate, route, chrome }) {
|
|||||||
<div class="channel-about-hero">
|
<div class="channel-about-hero">
|
||||||
<div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div>
|
<div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div>
|
||||||
<h2 class="channel-about-title">${escapeHtml(cleanName)}</h2>
|
<h2 class="channel-about-title">${escapeHtml(cleanName)}</h2>
|
||||||
<div class="channel-about-subline">@${escapeHtml(channelName || ownerLogin)} · ${escapeHtml(statsText(subscribersCount))} ${escapeHtml(subscribersWord(subscribersCount))}</div>
|
<div class="channel-about-subline">@${escapeHtml(ownerLogin)} · ${escapeHtml(statsText(subscribersCount))} ${escapeHtml(subscribersWord(subscribersCount))}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="channel-about-tiles">
|
<div class="channel-about-tiles">
|
||||||
${cameFromChannel ? '' : `<button class="channel-about-tile" id="channel-about-open" type="button">${iconHtml('open')}<span>Открыть</span></button>`}
|
${cameFromChannel ? '' : `<button class="channel-about-tile" id="channel-about-open" type="button">${iconHtml('open')}<span>Открыть</span></button>`}
|
||||||
${subscriptionTile}
|
${subscriptionTile}
|
||||||
${isOwnChannel ? '' : `<button class="channel-about-tile" id="channel-about-support" type="button">${iconHtml('gift')}<span>Поддержать автора</span></button>`}
|
<button class="channel-about-tile" id="channel-about-support" type="button">${iconHtml('gift')}<span>Донат</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="channel-about-list">
|
<div class="channel-about-list">
|
||||||
@@ -521,7 +498,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
<span class="channel-about-row-label">Ссылка</span>
|
<span class="channel-about-row-label">Ссылка</span>
|
||||||
<span class="channel-about-link">${escapeHtml(shortLink)}</span>
|
<span class="channel-about-link">${escapeHtml(shortLink)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="channel-about-row-chevron" aria-hidden="true">${iconHtml('copy')}</span>
|
<span class="channel-about-row-chevron" aria-hidden="true">${iconHtml('link')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
formatSol,
|
formatSol,
|
||||||
getBalanceSol,
|
getBalanceSol,
|
||||||
getSolanaWalletFromStoredSecret,
|
getSolanaWalletFromStoredSecret,
|
||||||
getWalletFromStoredClientKey,
|
getWalletFromStoredBlockchainKey,
|
||||||
getWalletFromStoredRootKey,
|
getWalletFromStoredRootKey,
|
||||||
solanaAddressFromPublicKeyBase64,
|
solanaAddressFromPublicKeyBase64,
|
||||||
transferSol,
|
transferSol,
|
||||||
@@ -140,9 +140,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
|
const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
|
||||||
const channelName = String(channel?.channelName || selector?.channelName || '').trim();
|
const channelName = String(channel?.channelName || selector?.channelName || '').trim();
|
||||||
const recipientChoices = [
|
const recipientChoices = [
|
||||||
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
|
|
||||||
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
|
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
|
||||||
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
@@ -164,20 +162,17 @@ export function render({ navigate, route, chrome }) {
|
|||||||
<select class="select" id="channel-donate-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
|
<select class="select" id="channel-donate-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
|
||||||
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
|
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
<div class="channel-donate-address" id="channel-donate-recipient-address">–</div>
|
<div class="channel-donate-address" id="channel-donate-recipient-address">—</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="channel-donate-section">
|
<section class="channel-donate-section">
|
||||||
<label class="field-label" for="channel-donate-sender-key">Перевести с моего счёта</label>
|
<label class="field-label" for="channel-donate-sender-key">Перевести с моего счёта</label>
|
||||||
<select class="select" id="channel-donate-sender-key">
|
<div class="channel-donate-address" id="channel-donate-sender-key">Blockchain key</div>
|
||||||
<option value="client-key">Client key</option>
|
<div class="channel-donate-address" id="channel-donate-sender-address">—</div>
|
||||||
<option value="root-key">Root key</option>
|
|
||||||
</select>
|
|
||||||
<div class="channel-donate-address" id="channel-donate-sender-address">–</div>
|
|
||||||
|
|
||||||
<div class="channel-donate-balance-row">
|
<div class="channel-donate-balance-row">
|
||||||
<span class="meta-muted">Баланс</span>
|
<span class="meta-muted">Баланс</span>
|
||||||
<strong id="channel-donate-balance">–</strong>
|
<strong id="channel-donate-balance">—</strong>
|
||||||
<button class="secondary-btn channel-donate-refresh" id="channel-donate-refresh" type="button">Обновить</button>
|
<button class="secondary-btn channel-donate-refresh" id="channel-donate-refresh" type="button">Обновить</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -233,31 +228,13 @@ export function render({ navigate, route, chrome }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveSenderWallet = async () => {
|
const resolveSenderWallet = async () => {
|
||||||
const keyId = String(senderSelect?.value || 'client-key');
|
const keyId = 'blockchain-key';
|
||||||
if (walletCache.has(keyId)) return walletCache.get(keyId);
|
if (walletCache.has(keyId)) return walletCache.get(keyId);
|
||||||
|
|
||||||
const login = String(state.session.login || '').trim();
|
const login = String(state.session.login || '').trim();
|
||||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
||||||
|
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
|
||||||
let wallet;
|
|
||||||
if (keyId === 'root-key') {
|
|
||||||
try {
|
|
||||||
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
|
|
||||||
} catch {
|
|
||||||
const password = window.prompt(
|
|
||||||
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
if (password == null) throw new Error('Операция отменена.');
|
|
||||||
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
|
||||||
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
|
|
||||||
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
|
|
||||||
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
|
|
||||||
}
|
|
||||||
|
|
||||||
walletCache.set(keyId, wallet);
|
walletCache.set(keyId, wallet);
|
||||||
return wallet;
|
return wallet;
|
||||||
@@ -267,7 +244,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
if (busy || disposed) return;
|
if (busy || disposed) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatus('Загрузка баланса…');
|
setStatus('Загрузка баланса…');
|
||||||
if (balanceEl) balanceEl.textContent = '–';
|
if (balanceEl) balanceEl.textContent = '—';
|
||||||
try {
|
try {
|
||||||
const wallet = await resolveSenderWallet();
|
const wallet = await resolveSenderWallet();
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
@@ -281,7 +258,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
setStatus('');
|
setStatus('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (senderAddressEl) senderAddressEl.textContent = '–';
|
if (senderAddressEl) senderAddressEl.textContent = '—';
|
||||||
setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error');
|
setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
if (!disposed) setBusy(false);
|
if (!disposed) setBusy(false);
|
||||||
@@ -290,8 +267,8 @@ export function render({ navigate, route, chrome }) {
|
|||||||
|
|
||||||
recipientSelect?.addEventListener('change', updateRecipientAddress);
|
recipientSelect?.addEventListener('change', updateRecipientAddress);
|
||||||
senderSelect?.addEventListener('change', () => {
|
senderSelect?.addEventListener('change', () => {
|
||||||
if (senderAddressEl) senderAddressEl.textContent = '–';
|
if (senderAddressEl) senderAddressEl.textContent = '—';
|
||||||
if (balanceEl) balanceEl.textContent = '–';
|
if (balanceEl) balanceEl.textContent = '—';
|
||||||
setStatus('');
|
setStatus('');
|
||||||
void refreshBalance();
|
void refreshBalance();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { toUserMessage } from '../services/ui-error-texts.js';
|
|||||||
import {
|
import {
|
||||||
animatePress,
|
animatePress,
|
||||||
createSkeletonCard,
|
createSkeletonCard,
|
||||||
formatPostStamp,
|
|
||||||
formatRelativeTime,
|
formatRelativeTime,
|
||||||
longPressFeel,
|
longPressFeel,
|
||||||
shareOrCopyLink,
|
shareOrCopyLink,
|
||||||
@@ -19,9 +18,6 @@ import {
|
|||||||
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js';
|
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { openChannelEditor } from '../components/channel-editor.js';
|
import { openChannelEditor } from '../components/channel-editor.js';
|
||||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
|
||||||
import { createEmojiPicker } from '../components/emoji-picker.js';
|
|
||||||
import { openShareSheet } from '../components/share-sheet.js';
|
|
||||||
import {
|
import {
|
||||||
composeMessageWithAttachments,
|
composeMessageWithAttachments,
|
||||||
createAttachmentCarouselElement,
|
createAttachmentCarouselElement,
|
||||||
@@ -470,7 +466,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
|||||||
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
|
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
|
||||||
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
|
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
|
||||||
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
|
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
|
||||||
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '–')}</code>
|
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}</code>
|
||||||
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
|
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
|
||||||
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
|
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -627,7 +623,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
|
|||||||
const text = String(item?.text || '').trim() || 'удалено';
|
const text = String(item?.text || '').trim() || 'удалено';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<strong>Версия ${index + 1}</strong>
|
<strong>Версия ${index + 1}</strong>
|
||||||
<div class="meta-muted">${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '–'}</div>
|
<div class="meta-muted">${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '—'}</div>
|
||||||
<p class="channel-message-body">${text}</p>
|
<p class="channel-message-body">${text}</p>
|
||||||
`;
|
`;
|
||||||
list.append(row);
|
list.append(row);
|
||||||
@@ -719,7 +715,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
}
|
}
|
||||||
const timestamp = document.createElement('div');
|
const timestamp = document.createElement('div');
|
||||||
timestamp.className = 'channel-message-time';
|
timestamp.className = 'channel-message-time';
|
||||||
timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '–';
|
timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '—';
|
||||||
if (node?.createdAtMs) timestamp.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
|
if (node?.createdAtMs) timestamp.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
|
||||||
authorBlock.append(title, timestamp);
|
authorBlock.append(title, timestamp);
|
||||||
authorTile.append(avatar, authorBlock);
|
authorTile.append(avatar, authorBlock);
|
||||||
@@ -965,176 +961,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Обсуждение в раскладке V4: контекст записи, плоский список комментариев, строка ввода =====
|
|
||||||
|
|
||||||
// Дерево ответов → плоский список в порядке чтения; у каждого — на что он отвечает
|
|
||||||
function flattenDescendants(items, parent = null, out = []) {
|
|
||||||
(Array.isArray(items) ? items : []).forEach((branch) => {
|
|
||||||
if (!branch?.node) return;
|
|
||||||
out.push({ node: branch.node, parent });
|
|
||||||
flattenDescendants(branch.children, branch.node, out);
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeKey(node) {
|
|
||||||
return messageRefKey(buildTargetFromNode(node));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Исходная запись сверху: две строки, «Развернуть» — только если текст правда обрезан
|
|
||||||
function renderThreadContext(node, { onOpenPost }) {
|
|
||||||
const parsed = parseMessageAttachments(resolveNodeText(node));
|
|
||||||
const box = document.createElement('section');
|
|
||||||
box.className = 'thread-context';
|
|
||||||
const text = document.createElement('p');
|
|
||||||
text.className = 'thread-context__text';
|
|
||||||
text.textContent = parsed.text || (parsed.attachments.length ? 'Запись с вложением' : '');
|
|
||||||
const meta = document.createElement('div');
|
|
||||||
meta.className = 'thread-context__meta';
|
|
||||||
const time = document.createElement('span');
|
|
||||||
time.textContent = formatPostStamp(node?.createdAtMs);
|
|
||||||
const expand = document.createElement('button');
|
|
||||||
expand.type = 'button';
|
|
||||||
expand.className = 'text-btn thread-context__expand';
|
|
||||||
expand.textContent = 'Развернуть';
|
|
||||||
expand.hidden = true;
|
|
||||||
expand.addEventListener('click', () => {
|
|
||||||
const open = box.classList.toggle('is-expanded');
|
|
||||||
expand.textContent = open ? 'Свернуть' : 'Развернуть';
|
|
||||||
});
|
|
||||||
const toPost = document.createElement('button');
|
|
||||||
toPost.type = 'button';
|
|
||||||
toPost.className = 'text-btn thread-context__open';
|
|
||||||
toPost.innerHTML = `<span>${parsed.attachments.length ? 'Запись с фото' : 'К записи'}</span>${iconHtml('open')}`;
|
|
||||||
toPost.addEventListener('click', onOpenPost);
|
|
||||||
meta.append(time, expand, toPost);
|
|
||||||
box.append(text, meta);
|
|
||||||
// обрезан ли текст — видно только после вставки в документ
|
|
||||||
requestAnimationFrame(() => { expand.hidden = text.scrollHeight <= text.clientHeight + 1; });
|
|
||||||
return box;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderComment({ node, parent, focus, channelOwner, handlers }) {
|
|
||||||
const target = buildTargetFromNode(node);
|
|
||||||
const key = messageRefKey(target);
|
|
||||||
const author = String(node?.authorLogin || 'автор');
|
|
||||||
const parsed = parseMessageAttachments(resolveNodeText(node));
|
|
||||||
const versions = Array.isArray(node?.versions) ? node.versions : [];
|
|
||||||
const isOwn = Boolean(state.session.login) && author.toLowerCase() === String(state.session.login).toLowerCase();
|
|
||||||
|
|
||||||
const item = document.createElement('article');
|
|
||||||
item.className = 'thread-comment channel-message-card';
|
|
||||||
if (key) item.dataset.messageKey = key;
|
|
||||||
|
|
||||||
const person = document.createElement('div');
|
|
||||||
person.className = 'thread-comment__person';
|
|
||||||
const who = document.createElement('button');
|
|
||||||
who.type = 'button';
|
|
||||||
who.className = 'thread-comment__who';
|
|
||||||
who.append(renderUserAvatar({ login: author, size: 'xs', className: 'thread-comment__avatar' }));
|
|
||||||
const name = document.createElement('span');
|
|
||||||
name.className = 'thread-comment__name';
|
|
||||||
name.textContent = author;
|
|
||||||
who.append(name);
|
|
||||||
who.addEventListener('click', () => handlers.navigate(makeProfileRoute(author)));
|
|
||||||
person.append(who);
|
|
||||||
if (channelOwner && author.toLowerCase() === channelOwner.toLowerCase()) {
|
|
||||||
const badge = document.createElement('span');
|
|
||||||
badge.className = 'thread-comment__badge';
|
|
||||||
badge.textContent = 'Автор';
|
|
||||||
person.append(badge);
|
|
||||||
}
|
|
||||||
const time = document.createElement('time');
|
|
||||||
time.textContent = formatPostStamp(node?.createdAtMs);
|
|
||||||
if (node?.createdAtMs) time.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
|
|
||||||
person.append(time);
|
|
||||||
item.append(person);
|
|
||||||
|
|
||||||
// Ответ на другой комментарий — цитата в две строки; нажатие ведёт к оригиналу
|
|
||||||
if (parent && parent !== focus) {
|
|
||||||
const quote = document.createElement('button');
|
|
||||||
quote.type = 'button';
|
|
||||||
quote.className = 'thread-quote';
|
|
||||||
const qa = document.createElement('strong');
|
|
||||||
qa.textContent = String(parent.authorLogin || '');
|
|
||||||
const qt = document.createElement('span');
|
|
||||||
qt.textContent = parseMessageAttachments(resolveNodeText(parent)).text || 'Вложение';
|
|
||||||
quote.append(qa, qt);
|
|
||||||
quote.setAttribute('aria-label', `Ответ на комментарий ${parent.authorLogin || ''}. Перейти к нему`);
|
|
||||||
quote.addEventListener('click', () => handlers.jumpTo(nodeKey(parent), key));
|
|
||||||
item.append(quote);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.attachments.length) {
|
|
||||||
item.append(createAttachmentCarouselElement(parsed.attachments, { gateway: state.entrySettings.arweaveServer, messageTimestampMs: node?.createdAtMs }));
|
|
||||||
}
|
|
||||||
const text = document.createElement('p');
|
|
||||||
text.className = 'thread-comment__text';
|
|
||||||
text.textContent = parsed.text;
|
|
||||||
item.append(text);
|
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
|
||||||
actions.className = 'thread-comment__actions';
|
|
||||||
const reply = document.createElement('button');
|
|
||||||
reply.type = 'button';
|
|
||||||
reply.className = 'thread-comment__reply';
|
|
||||||
reply.textContent = 'Ответить';
|
|
||||||
reply.addEventListener('click', () => handlers.setReplyTo(node));
|
|
||||||
|
|
||||||
setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked');
|
|
||||||
const liked = getMessageReactionState(target) === 'liked';
|
|
||||||
const likes = Number(node?.likesCount || 0);
|
|
||||||
const like = document.createElement('button');
|
|
||||||
like.type = 'button';
|
|
||||||
like.className = `channel-action-item thread-comment__like${liked ? ' is-liked' : ''}`;
|
|
||||||
like.innerHTML = `${iconHtml('heart', liked)}<span>${likes || ''}</span>`;
|
|
||||||
like.setAttribute('aria-pressed', String(liked));
|
|
||||||
like.setAttribute('aria-label', `${liked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`);
|
|
||||||
like.addEventListener('click', async () => {
|
|
||||||
like.disabled = true;
|
|
||||||
try { await handlers.onToggleLike(target, liked ? 'unlike' : 'like'); }
|
|
||||||
catch (error) { handlers.onActionError(error, liked ? 'unlike' : 'like'); }
|
|
||||||
finally { if (like.isConnected) like.disabled = false; }
|
|
||||||
});
|
|
||||||
|
|
||||||
const more = document.createElement('button');
|
|
||||||
more.type = 'button';
|
|
||||||
more.className = 'channel-action-item thread-comment__more';
|
|
||||||
more.innerHTML = iconHtml('more');
|
|
||||||
more.setAttribute('aria-label', `Действия с комментарием ${author}`);
|
|
||||||
const menuItems = [
|
|
||||||
{ label: 'Ответить', iconHtml: iconHtml('reply'), action: () => handlers.setReplyTo(node) },
|
|
||||||
{ label: 'Поделиться комментарием', iconHtml: iconHtml('share'), action: () => handlers.shareComment(node) },
|
|
||||||
{ label: 'Скопировать текст', iconHtml: iconHtml('copy'), action: async () => {
|
|
||||||
try { await navigator.clipboard.writeText(parsed.text); showToast('Текст скопирован'); }
|
|
||||||
catch { showToast('Не удалось скопировать', { kind: 'error' }); }
|
|
||||||
} },
|
|
||||||
{ label: 'Данные публикации', iconHtml: iconHtml('info'), action: () => openBlockchainDetailsModal(buildBlockchainDetails({ target, authorLogin: author, timestampMs: node?.createdAtMs, text: resolveNodeText(node), raw: node, localNumber: 0, msgSubType: Number(node?.msgSubType || 0) }), { isActive: handlers.isActive }) },
|
|
||||||
];
|
|
||||||
if (versions.length > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions }) });
|
|
||||||
if (isOwn) {
|
|
||||||
menuItems.splice(1, 0, { label: 'Редактировать', iconHtml: iconHtml('edit'), action: () => openEditMessageModal({
|
|
||||||
isActive: handlers.isActive,
|
|
||||||
draftKey: `edit:${key}`,
|
|
||||||
initialText: parsed.text,
|
|
||||||
allowEmptyText: parsed.attachments.length > 0,
|
|
||||||
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsed.attachments), { isChannelPost: false }),
|
|
||||||
onDelete: async () => handlers.onEdit(target, '', { isChannelPost: false, isDelete: true }),
|
|
||||||
}) });
|
|
||||||
menuItems.push({ divider: true }, { label: 'Удалить', danger: true, iconHtml: iconHtml('trash'), action: async () => {
|
|
||||||
if (!await confirmDialog({ title: 'Удалить комментарий?', text: 'Он скроется из обсуждения. Прежние версии останутся в блокчейне.', confirmLabel: 'Удалить', danger: true })) return;
|
|
||||||
try { await handlers.onEdit(target, '', { isChannelPost: false, isDelete: true }); }
|
|
||||||
catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить комментарий.')); }
|
|
||||||
} });
|
|
||||||
}
|
|
||||||
const menu = createDropdownMenu({ anchorEl: more, items: menuItems });
|
|
||||||
item.cleanup = () => menu.destroy();
|
|
||||||
|
|
||||||
actions.append(reply, like, more);
|
|
||||||
item.append(actions);
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDescendants(items, handlers, nextNumber, depth = 0, parent = null) {
|
function renderDescendants(items, handlers, nextNumber, depth = 0, parent = null) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'stack';
|
wrap.className = 'stack';
|
||||||
@@ -1199,124 +1025,6 @@ function renderSkeleton(screen) {
|
|||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// «Не уведомлять об ответах» — пока только на этом устройстве (серверной настройки нет, см. docs/UI-Design)
|
|
||||||
const THREAD_MUTE_KEY = 'shine-ui-thread-muted-v1';
|
|
||||||
function readMuted() { try { return JSON.parse(localStorage.getItem(THREAD_MUTE_KEY) || '{}') || {}; } catch { return {}; } }
|
|
||||||
function isThreadMuted(key) { return Boolean(readMuted()[key]); }
|
|
||||||
function setThreadMuted(key, muted) {
|
|
||||||
const all = readMuted();
|
|
||||||
if (muted) all[key] = Date.now(); else delete all[key];
|
|
||||||
try { localStorage.setItem(THREAD_MUTE_KEY, JSON.stringify(all)); } catch { /* приватный режим */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Черновик и адресат ответа принадлежат конкретному обсуждению
|
|
||||||
const threadDrafts = new Map();
|
|
||||||
|
|
||||||
function createThreadComposer({ draftKey, isAuthorized, onLogin, onSend }) {
|
|
||||||
const root = document.createElement('div');
|
|
||||||
root.className = 'channel-composer thread-composer';
|
|
||||||
if (!isAuthorized) {
|
|
||||||
const login = document.createElement('button');
|
|
||||||
login.type = 'button';
|
|
||||||
login.className = 'primary-btn channel-main-action';
|
|
||||||
login.textContent = 'Войти и ответить';
|
|
||||||
login.addEventListener('click', onLogin);
|
|
||||||
root.append(login);
|
|
||||||
return { root, setReplyTo() {} };
|
|
||||||
}
|
|
||||||
const draft = threadDrafts.get(draftKey) || { text: '', replyTo: null };
|
|
||||||
threadDrafts.set(draftKey, draft);
|
|
||||||
|
|
||||||
const replying = document.createElement('div');
|
|
||||||
replying.className = 'thread-replying';
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'compose-row';
|
|
||||||
const field = document.createElement('div');
|
|
||||||
field.className = 'compose-field';
|
|
||||||
const input = document.createElement('textarea');
|
|
||||||
input.rows = 1;
|
|
||||||
input.className = 'compose-field__input';
|
|
||||||
input.value = draft.text;
|
|
||||||
const emojiBtn = document.createElement('button');
|
|
||||||
emojiBtn.type = 'button';
|
|
||||||
emojiBtn.className = 'compose-field__emoji';
|
|
||||||
emojiBtn.setAttribute('aria-label', 'Эмодзи');
|
|
||||||
emojiBtn.innerHTML = iconHtml('emoji');
|
|
||||||
field.append(input, emojiBtn);
|
|
||||||
const send = document.createElement('button');
|
|
||||||
send.type = 'button';
|
|
||||||
send.className = 'compose-send';
|
|
||||||
send.setAttribute('aria-label', 'Отправить');
|
|
||||||
send.innerHTML = iconHtml('send');
|
|
||||||
row.append(field, send);
|
|
||||||
const emojiSlot = document.createElement('div');
|
|
||||||
emojiSlot.className = 'thread-composer__emoji';
|
|
||||||
emojiSlot.hidden = true;
|
|
||||||
root.append(replying, row, emojiSlot);
|
|
||||||
|
|
||||||
const sync = () => {
|
|
||||||
draft.text = input.value;
|
|
||||||
send.disabled = !input.value.trim();
|
|
||||||
input.placeholder = draft.replyTo ? `Ответить ${draft.replyTo.authorLogin || ''}…` : 'Написать комментарий…';
|
|
||||||
replying.replaceChildren();
|
|
||||||
replying.hidden = !draft.replyTo;
|
|
||||||
if (draft.replyTo) {
|
|
||||||
const quote = document.createElement('div');
|
|
||||||
quote.className = 'thread-quote';
|
|
||||||
const a = document.createElement('strong');
|
|
||||||
a.textContent = String(draft.replyTo.authorLogin || '');
|
|
||||||
const t = document.createElement('span');
|
|
||||||
t.textContent = parseMessageAttachments(resolveNodeText(draft.replyTo)).text;
|
|
||||||
quote.append(a, t);
|
|
||||||
const cancel = document.createElement('button');
|
|
||||||
cancel.type = 'button';
|
|
||||||
cancel.className = 'thread-replying__cancel';
|
|
||||||
cancel.setAttribute('aria-label', 'Не отвечать на комментарий');
|
|
||||||
cancel.innerHTML = iconHtml('close');
|
|
||||||
cancel.addEventListener('click', () => { draft.replyTo = null; sync(); });
|
|
||||||
replying.append(quote, cancel);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
input.addEventListener('input', sync);
|
|
||||||
emojiBtn.addEventListener('click', () => {
|
|
||||||
if (!emojiSlot.childElementCount) {
|
|
||||||
emojiSlot.append(createEmojiPicker({ onSelect: (emoji) => {
|
|
||||||
const at = input.selectionStart ?? input.value.length;
|
|
||||||
input.value = `${input.value.slice(0, at)}${emoji}${input.value.slice(at)}`;
|
|
||||||
sync();
|
|
||||||
} }));
|
|
||||||
}
|
|
||||||
emojiSlot.hidden = !emojiSlot.hidden;
|
|
||||||
});
|
|
||||||
const submit = async () => {
|
|
||||||
const text = input.value.trim();
|
|
||||||
if (!text) return;
|
|
||||||
send.disabled = true;
|
|
||||||
try {
|
|
||||||
await onSend(text, draft.replyTo);
|
|
||||||
draft.text = '';
|
|
||||||
draft.replyTo = null;
|
|
||||||
input.value = '';
|
|
||||||
emojiSlot.hidden = true;
|
|
||||||
} catch (error) {
|
|
||||||
showToast(toUserMessage(error, 'Комментарий не отправлен. Текст сохранён.'), { kind: 'error' });
|
|
||||||
} finally {
|
|
||||||
sync();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
send.addEventListener('click', submit);
|
|
||||||
bindSubmitOnPlainEnter(input, submit);
|
|
||||||
sync();
|
|
||||||
return {
|
|
||||||
root,
|
|
||||||
setReplyTo(node) {
|
|
||||||
draft.replyTo = node;
|
|
||||||
sync();
|
|
||||||
input.focus();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function render({ navigate, route, chrome }) {
|
export function render({ navigate, route, chrome }) {
|
||||||
let selector = parseThreadSelector(route);
|
let selector = parseThreadSelector(route);
|
||||||
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
||||||
@@ -1334,31 +1042,19 @@ export function render({ navigate, route, chrome }) {
|
|||||||
|
|
||||||
const threadHeaderButton = document.createElement('button');
|
const threadHeaderButton = document.createElement('button');
|
||||||
threadHeaderButton.type = 'button';
|
threadHeaderButton.type = 'button';
|
||||||
threadHeaderButton.className = 'topbar-identity channel-header-route-btn';
|
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||||
const threadHeaderText = document.createElement('span');
|
|
||||||
threadHeaderText.className = 'topbar-identity__text';
|
|
||||||
const threadHeaderTitle = document.createElement('span');
|
const threadHeaderTitle = document.createElement('span');
|
||||||
threadHeaderTitle.className = 'topbar-identity__title';
|
threadHeaderTitle.className = 'channel-header-title';
|
||||||
threadHeaderTitle.textContent = 'Обсуждение';
|
threadHeaderTitle.textContent = 'Обсуждение';
|
||||||
const threadHeaderChannel = document.createElement('span');
|
const threadHeaderChannel = document.createElement('span');
|
||||||
threadHeaderChannel.className = 'topbar-identity__sub';
|
threadHeaderChannel.className = 'channel-header-owner';
|
||||||
threadHeaderChannel.textContent = '…';
|
threadHeaderChannel.textContent = '…';
|
||||||
threadHeaderText.append(threadHeaderTitle, threadHeaderChannel);
|
threadHeaderButton.append(threadHeaderTitle, threadHeaderChannel);
|
||||||
threadHeaderButton.append(threadHeaderText);
|
|
||||||
threadHeaderButton.disabled = true;
|
threadHeaderButton.disabled = true;
|
||||||
|
|
||||||
// Меню обсуждения (V4): к записи, уведомления об ответах, поделиться записью
|
|
||||||
let threadMenuActions = null;
|
|
||||||
const header = createTopBar({
|
const header = createTopBar({
|
||||||
center: threadHeaderButton,
|
center: threadHeaderButton,
|
||||||
className: 'topbar--identity',
|
|
||||||
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||||
actions: [{
|
|
||||||
label: '⋯',
|
|
||||||
ariaLabel: 'Меню обсуждения',
|
|
||||||
className: 'thread-menu-btn',
|
|
||||||
menu: { minWidth: 240, items: () => threadMenuActions?.() || [] },
|
|
||||||
}],
|
|
||||||
});
|
});
|
||||||
header.classList.add('channel-thread-topbar');
|
header.classList.add('channel-thread-topbar');
|
||||||
chrome?.setTopbar(header);
|
chrome?.setTopbar(header);
|
||||||
@@ -1700,7 +1396,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
||||||
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
||||||
if (threadHeaderButton) {
|
if (threadHeaderButton) {
|
||||||
threadHeaderChannel.textContent = channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle;
|
threadHeaderChannel.textContent = `в канале «${channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle}»`;
|
||||||
threadHeaderButton.title = 'Открыть канал';
|
threadHeaderButton.title = 'Открыть канал';
|
||||||
threadHeaderButton.disabled = false;
|
threadHeaderButton.disabled = false;
|
||||||
threadHeaderButton.onclick = (event) => {
|
threadHeaderButton.onclick = (event) => {
|
||||||
@@ -1713,114 +1409,74 @@ export function render({ navigate, route, chrome }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearContent();
|
clearContent();
|
||||||
const channelOwner = extractLoginFromBlockchainName(selector?.channel?.ownerBlockchainName || focus?.channelInfo?.ownerBlockchainName || '');
|
let localSeq = 0;
|
||||||
const openPost = () => {
|
const nextNumber = () => {
|
||||||
const routeToChannel = buildChannelRouteFromThread(selector, resolvedChannelLabel);
|
localSeq += 1;
|
||||||
if (routeToChannel) navigate(routeToChannel);
|
return localSeq;
|
||||||
};
|
|
||||||
const focusTarget = focus ? buildTargetFromNode(focus) : null;
|
|
||||||
threadMenuActions = () => {
|
|
||||||
const muted = isThreadMuted(routeKey);
|
|
||||||
return [
|
|
||||||
{ label: 'Перейти к записи', iconHtml: iconHtml('doc'), action: openPost },
|
|
||||||
{ label: muted ? 'Уведомлять об ответах' : 'Не уведомлять об ответах', iconHtml: iconHtml(muted ? 'bell' : 'bell-off'), action: () => {
|
|
||||||
setThreadMuted(routeKey, !muted);
|
|
||||||
showToast(muted ? 'Уведомления об ответах включены' : 'Ответы в этом обсуждении не будут приходить в уведомления');
|
|
||||||
} },
|
|
||||||
{ label: 'Поделиться записью', iconHtml: iconHtml('share'), action: () => {
|
|
||||||
if (!focusTarget) return;
|
|
||||||
openShareSheet({
|
|
||||||
title: 'Поделиться записью',
|
|
||||||
kicker: `${resolvedChannelTitle} · запись`,
|
|
||||||
text: parseMessageAttachments(resolveNodeText(focus)).text,
|
|
||||||
url: buildAbsoluteRouteUrl(buildThreadRouteFromTarget(focusTarget, selector)),
|
|
||||||
});
|
|
||||||
} },
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let ancestorsWrap = null;
|
||||||
if (ancestors.length) {
|
if (ancestors.length) {
|
||||||
const ancestorsWrap = document.createElement('div');
|
ancestorsWrap = document.createElement('div');
|
||||||
ancestorsWrap.className = 'thread-block thread-block--ancestors';
|
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
|
||||||
ancestors.forEach((node) => ancestorsWrap.append(renderThreadContext(node, { onOpenPost: openPost })));
|
ancestors.forEach((node, index) => {
|
||||||
screen.append(ancestorsWrap);
|
const heading = index === 0 ? resolveChannelHeadingFromNode(node) : '';
|
||||||
}
|
ancestorsWrap.append(renderNodeCard(node, heading, handlers, nextNumber()));
|
||||||
if (focus) {
|
|
||||||
const focusWrap = document.createElement('div');
|
|
||||||
focusWrap.className = 'thread-block thread-block--focus';
|
|
||||||
focusWrap.append(renderThreadContext(focus, { onOpenPost: openPost }));
|
|
||||||
screen.append(focusWrap);
|
|
||||||
}
|
|
||||||
|
|
||||||
const comments = flattenDescendants(descendants);
|
|
||||||
const listWrap = document.createElement('div');
|
|
||||||
listWrap.className = 'thread-block thread-block--replies';
|
|
||||||
const listTitle = document.createElement('h3');
|
|
||||||
listTitle.className = 'thread-section-title';
|
|
||||||
listTitle.textContent = `Комментарии · ${comments.length}`;
|
|
||||||
listWrap.append(listTitle);
|
|
||||||
|
|
||||||
// Возврат к ответу после перехода по цитате
|
|
||||||
const returnBtn = document.createElement('button');
|
|
||||||
returnBtn.type = 'button';
|
|
||||||
returnBtn.className = 'thread-return';
|
|
||||||
returnBtn.hidden = true;
|
|
||||||
returnBtn.textContent = 'Вернуться к ответу';
|
|
||||||
let returnKey = '';
|
|
||||||
const highlight = (el) => {
|
|
||||||
screen.querySelectorAll('.thread-comment.is-highlight').forEach((x) => x.classList.remove('is-highlight'));
|
|
||||||
el?.classList.add('is-highlight');
|
|
||||||
};
|
|
||||||
returnBtn.addEventListener('click', () => {
|
|
||||||
const el = listWrap.querySelector(`[data-message-key="${CSS.escape(returnKey)}"]`);
|
|
||||||
el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
||||||
highlight(el);
|
|
||||||
returnBtn.hidden = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const commentHandlers = {
|
|
||||||
...handlers,
|
|
||||||
jumpTo: (key, fromKey) => {
|
|
||||||
const el = listWrap.querySelector(`[data-message-key="${CSS.escape(key)}"]`);
|
|
||||||
if (!el) return;
|
|
||||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
||||||
highlight(el);
|
|
||||||
returnKey = fromKey;
|
|
||||||
returnBtn.hidden = false;
|
|
||||||
},
|
|
||||||
setReplyTo: (node) => composerApi?.setReplyTo(node),
|
|
||||||
shareComment: (node) => openShareSheet({
|
|
||||||
title: 'Поделиться комментарием',
|
|
||||||
kicker: `${node.authorLogin} · комментарий`,
|
|
||||||
text: parseMessageAttachments(resolveNodeText(node)).text,
|
|
||||||
url: `${buildAbsoluteRouteUrl(buildThreadRouteFromTarget(focusTarget, selector))}#comment-${node?.messageRef?.blockNumber ?? ''}`,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (comments.length) {
|
|
||||||
comments.forEach(({ node, parent }) => {
|
|
||||||
try { listWrap.append(renderComment({ node, parent, focus, channelOwner, handlers: commentHandlers })); }
|
|
||||||
catch (error) { logThreadRuntimeError('render_comment', error); }
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let focusWrap = null;
|
||||||
|
if (focus) {
|
||||||
|
focusWrap = document.createElement('div');
|
||||||
|
focusWrap.className = 'stack thread-block thread-block--focus';
|
||||||
|
const focusTitle = document.createElement('h3');
|
||||||
|
focusTitle.className = 'section-title';
|
||||||
|
focusTitle.textContent = 'Исходное сообщение';
|
||||||
|
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
|
||||||
|
const composer = document.createElement('div');
|
||||||
|
composer.className = 'channel-composer';
|
||||||
|
const reply = document.createElement('button');
|
||||||
|
reply.type = 'button';
|
||||||
|
reply.className = 'channel-compose-bar';
|
||||||
|
reply.innerHTML = `<span class="channel-compose-bar__text">${state.session.isAuthorized ? 'Написать ответ…' : 'Войти и ответить'}</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
|
||||||
|
reply.addEventListener('click', () => {
|
||||||
|
const parsed = parseMessageAttachments(resolveNodeText(focus));
|
||||||
|
openReplyModal({
|
||||||
|
draftKey: `message:${messageRefKey(buildTargetFromNode(focus))}`,
|
||||||
|
context: { author: focus.authorLogin, text: parsed.text, attachmentLabel: parsed.attachments[0]?.name },
|
||||||
|
isActive: () => !disposed,
|
||||||
|
onSubmit: (text) => handlers.onReply(buildTargetFromNode(focus), text),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
composer.append(reply);
|
||||||
|
chrome?.setComposer(composer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const descendantsWrap = document.createElement('div');
|
||||||
|
descendantsWrap.className = 'stack thread-block thread-block--replies';
|
||||||
|
const descendantsTitle = document.createElement('h3');
|
||||||
|
descendantsTitle.className = 'section-title';
|
||||||
|
descendantsTitle.textContent = `Ответы · ${Math.max(0, Number(focus?.repliesCount || descendants.length))}`;
|
||||||
|
descendantsWrap.append(descendantsTitle);
|
||||||
|
|
||||||
|
if (descendants.length) {
|
||||||
|
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
|
||||||
} else {
|
} else {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'empty-note thread-empty';
|
empty.className = 'empty-note';
|
||||||
empty.textContent = 'Пока нет комментариев. Можно начать разговор.';
|
empty.textContent = 'Пока нет ответов. Начните обсуждение.';
|
||||||
listWrap.append(empty);
|
descendantsWrap.append(empty);
|
||||||
}
|
}
|
||||||
screen.append(listWrap, returnBtn);
|
|
||||||
|
|
||||||
let composerApi = null;
|
if (ancestorsWrap) {
|
||||||
if (focus) {
|
screen.append(ancestorsWrap);
|
||||||
composerApi = createThreadComposer({
|
const divider = document.createElement('div');
|
||||||
draftKey: routeKey,
|
divider.className = 'thread-history-divider';
|
||||||
isAuthorized: state.session.isAuthorized,
|
screen.append(divider);
|
||||||
onLogin: () => { state.authReturnHash = window.location.pathname || '/channels'; navigate('login-view'); },
|
|
||||||
onSend: async (text, replyTo) => handlers.onReply(replyTo ? buildTargetFromNode(replyTo) : focusTarget, text),
|
|
||||||
});
|
|
||||||
chrome?.setComposer(composerApi.root);
|
|
||||||
}
|
}
|
||||||
const focusWrap = screen.querySelector('.thread-block--focus');
|
|
||||||
|
if (focusWrap) screen.append(focusWrap);
|
||||||
|
screen.append(descendantsWrap);
|
||||||
|
|
||||||
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
|
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
|
||||||
const hasPendingScroll = pendingThreadScroll.has(routeKey);
|
const hasPendingScroll = pendingThreadScroll.has(routeKey);
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
animatePress,
|
animatePress,
|
||||||
createSkeletonCard,
|
createSkeletonCard,
|
||||||
dayKey,
|
dayKey,
|
||||||
|
formatClockTime,
|
||||||
formatDayLabel,
|
formatDayLabel,
|
||||||
formatPostStamp,
|
|
||||||
formatRelativeTime,
|
formatRelativeTime,
|
||||||
longPressFeel,
|
longPressFeel,
|
||||||
shareOrCopyLink,
|
shareOrCopyLink,
|
||||||
@@ -644,7 +644,7 @@ function openChannelMetaDetailsModal({
|
|||||||
: 'Не установлен';
|
: 'Не установлен';
|
||||||
const changedAtLabel = changedAtMs
|
const changedAtLabel = changedAtMs
|
||||||
? new Date(Number(changedAtMs)).toLocaleString('ru-RU')
|
? new Date(Number(changedAtMs)).toLocaleString('ru-RU')
|
||||||
: '–';
|
: '—';
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" id="about-channel-modal">
|
<div class="modal" id="about-channel-modal">
|
||||||
@@ -658,7 +658,7 @@ function openChannelMetaDetailsModal({
|
|||||||
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
|
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
|
||||||
<span>Владелец</span><strong>${escapeHtml(channel.ownerName || 'автор')}</strong>
|
<span>Владелец</span><strong>${escapeHtml(channel.ownerName || 'автор')}</strong>
|
||||||
<span>Системное имя</span><code>${escapeHtml(channel.name || 'channel')}</code>
|
<span>Системное имя</span><code>${escapeHtml(channel.name || 'channel')}</code>
|
||||||
<span>Название</span><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name || '–')}</strong>
|
<span>Название</span><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name || '—')}</strong>
|
||||||
<span>Описание</span><span>${escapeHtml(channel.description || 'Описание не задано.')}</span>
|
<span>Описание</span><span>${escapeHtml(channel.description || 'Описание не задано.')}</span>
|
||||||
<span>Аватар</span><span>${escapeHtml(avatarState)}</span>
|
<span>Аватар</span><span>${escapeHtml(avatarState)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -840,7 +840,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
|||||||
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
|
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
|
||||||
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
|
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
|
||||||
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
|
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
|
||||||
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '–')}</code>
|
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}</code>
|
||||||
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
|
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
|
||||||
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
|
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -1079,7 +1079,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
|
|||||||
item.className = 'ui-button entrypoint-history-item';
|
item.className = 'ui-button entrypoint-history-item';
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<strong>${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')}</strong>
|
<strong>${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')}</strong>
|
||||||
<span>#${escapeHtml(post.localNumber || '–')}</span>
|
<span>#${escapeHtml(post.localNumber || '—')}</span>
|
||||||
<p>${escapeHtml(String(parsed.text || '').trim() || 'Без текста')}</p>
|
<p>${escapeHtml(String(parsed.text || '').trim() || 'Без текста')}</p>
|
||||||
`;
|
`;
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
@@ -1171,7 +1171,7 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
|
|||||||
if (textEl) textEl.focus();
|
if (textEl) textEl.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddMessageModal({ channelName, channelTitle = '', onSubmit, navigate, isActive = () => true }) {
|
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
|
||||||
const typeWrap = document.createElement('label');
|
const typeWrap = document.createElement('label');
|
||||||
typeWrap.className = 'channel-editor__type';
|
typeWrap.className = 'channel-editor__type';
|
||||||
typeWrap.innerHTML = '<span class="sr-only">Тип сообщения</span>';
|
typeWrap.innerHTML = '<span class="sr-only">Тип сообщения</span>';
|
||||||
@@ -1188,8 +1188,7 @@ function openAddMessageModal({ channelName, channelTitle = '', onSubmit, navigat
|
|||||||
id: 'channel-message-modal',
|
id: 'channel-message-modal',
|
||||||
title: 'Новая запись',
|
title: 'Новая запись',
|
||||||
submitLabel: 'Опубликовать',
|
submitLabel: 'Опубликовать',
|
||||||
placeholder: 'Чем хотите поделиться?',
|
placeholder: 'Напишите сообщение',
|
||||||
destination: channelTitle ? `В канал «${channelTitle}»` : '',
|
|
||||||
key: `channel-post:${channelName}`,
|
key: `channel-post:${channelName}`,
|
||||||
extraControl: typeWrap,
|
extraControl: typeWrap,
|
||||||
isActive,
|
isActive,
|
||||||
@@ -1224,7 +1223,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
|
|||||||
const text = String(item?.text || '').trim() || 'удалено';
|
const text = String(item?.text || '').trim() || 'удалено';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<strong>Версия ${index + 1}</strong>
|
<strong>Версия ${index + 1}</strong>
|
||||||
<div class="meta-muted">${ts > 0 ? formatRelativeTime(ts) : '–'}</div>
|
<div class="meta-muted">${ts > 0 ? formatRelativeTime(ts) : '—'}</div>
|
||||||
<p class="channel-message-body">${text}</p>
|
<p class="channel-message-body">${text}</p>
|
||||||
`;
|
`;
|
||||||
list.append(row);
|
list.append(row);
|
||||||
@@ -1276,8 +1275,6 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
|||||||
primaryLikesCount: Number(message?.primaryLikesCount || 0),
|
primaryLikesCount: Number(message?.primaryLikesCount || 0),
|
||||||
shiningLikesCount: Number(message?.shiningLikesCount || 0),
|
shiningLikesCount: Number(message?.shiningLikesCount || 0),
|
||||||
repliesCount: Number(message?.repliesCount || 0),
|
repliesCount: Number(message?.repliesCount || 0),
|
||||||
// Кто недавно ответил — для миниатюр у счётчика обсуждения. Сервер пока не отдаёт (см. docs/UI-Design).
|
|
||||||
recentRepliers: Array.isArray(message?.recentRepliers) ? message.recentRepliers.slice(0, 2) : [],
|
|
||||||
ratingsCount: Number(message?.ratingsCount || 0),
|
ratingsCount: Number(message?.ratingsCount || 0),
|
||||||
timestampMs: resolveMessageTimestampMs(message),
|
timestampMs: resolveMessageTimestampMs(message),
|
||||||
messageRef,
|
messageRef,
|
||||||
@@ -1510,7 +1507,6 @@ async function loadFromApi(route, channelId) {
|
|||||||
avaSha256: String(payload.channel?.avaSha256 || '').trim(),
|
avaSha256: String(payload.channel?.avaSha256 || '').trim(),
|
||||||
avaSize: Number(payload.channel?.avaSize || 0),
|
avaSize: Number(payload.channel?.avaSize || 0),
|
||||||
metaUpdatedAtMs: Number(payload.channel?.metaUpdatedAtMs || 0),
|
metaUpdatedAtMs: Number(payload.channel?.metaUpdatedAtMs || 0),
|
||||||
subscribersCount: Number(payload.channel?.subscribersCount || 0),
|
|
||||||
ownerName: ownerLogin || 'неизвестно',
|
ownerName: ownerLogin || 'неизвестно',
|
||||||
},
|
},
|
||||||
posts,
|
posts,
|
||||||
@@ -1866,7 +1862,7 @@ function renderPostCard(post, {
|
|||||||
|
|
||||||
const timestamp = document.createElement('div');
|
const timestamp = document.createElement('div');
|
||||||
timestamp.className = 'channel-message-time';
|
timestamp.className = 'channel-message-time';
|
||||||
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '–';
|
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
||||||
if (post.timestampMs) timestamp.title = new Date(post.timestampMs).toLocaleString('ru-RU');
|
if (post.timestampMs) timestamp.title = new Date(post.timestampMs).toLocaleString('ru-RU');
|
||||||
if (versionsTotal > 1) {
|
if (versionsTotal > 1) {
|
||||||
const editedMarker = document.createElement('span');
|
const editedMarker = document.createElement('span');
|
||||||
@@ -2038,12 +2034,6 @@ function renderPostCard(post, {
|
|||||||
<span class="channel-action-label">Обсуждение</span>
|
<span class="channel-action-label">Обсуждение</span>
|
||||||
<span class="channel-action-counter">${post.repliesCount || ''}</span>
|
<span class="channel-action-counter">${post.repliesCount || ''}</span>
|
||||||
`;
|
`;
|
||||||
if (post.recentRepliers.length) {
|
|
||||||
const faces = document.createElement('span');
|
|
||||||
faces.className = 'channel-action-faces';
|
|
||||||
post.recentRepliers.forEach((login) => faces.append(renderUserAvatar({ login, size: 'xs', className: 'channel-action-face' })));
|
|
||||||
discussionButton.append(faces);
|
|
||||||
}
|
|
||||||
setActionTitle(discussionButton, `Открыть обсуждение, ответов: ${post.repliesCount || 0}`);
|
setActionTitle(discussionButton, `Открыть обсуждение, ответов: ${post.repliesCount || 0}`);
|
||||||
discussionButton.addEventListener('click', (event) => {
|
discussionButton.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -2077,6 +2067,8 @@ function renderPostCard(post, {
|
|||||||
// Rating/opinion action is intentionally hidden from UI for now.
|
// Rating/opinion action is intentionally hidden from UI for now.
|
||||||
// Backend/subtype/counters remain supported so the feature can be restored later.
|
// Backend/subtype/counters remain supported so the feature can be restored later.
|
||||||
|
|
||||||
|
actions.append(likeButton, discussionButton);
|
||||||
|
|
||||||
const shareButton = document.createElement('button');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
shareButton.className = 'ui-button channel-action-item channel-action-share';
|
shareButton.className = 'ui-button channel-action-item channel-action-share';
|
||||||
@@ -2092,9 +2084,8 @@ function renderPostCard(post, {
|
|||||||
await onShare(route);
|
await onShare(route);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Порядок как в макете V4: лайк → поделиться → обсуждение; дата и «⋯» — справа
|
actions.append(shareButton);
|
||||||
actions.append(likeButton, shareButton, discussionButton);
|
menuItems.unshift({ label: 'Ответить', action: () => replyButton.click() });
|
||||||
menuItems.unshift({ label: 'Ответить', iconHtml: iconHtml('reply'), action: () => replyButton.click() });
|
|
||||||
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
|
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
|
||||||
const originalBtn = document.createElement('button');
|
const originalBtn = document.createElement('button');
|
||||||
originalBtn.type = 'button';
|
originalBtn.type = 'button';
|
||||||
@@ -2136,7 +2127,7 @@ function renderPostCard(post, {
|
|||||||
msgSubType: post.msgSubType,
|
msgSubType: post.msgSubType,
|
||||||
}), { isActive });
|
}), { isActive });
|
||||||
});
|
});
|
||||||
menuItems.push({ label: 'Данные публикации', iconHtml: iconHtml('info'), action: () => detailsButton.click() });
|
menuItems.push({ label: 'Данные блокчейна', action: () => detailsButton.click() });
|
||||||
if (post.isOwnMessage) {
|
if (post.isOwnMessage) {
|
||||||
const editButton = document.createElement('button');
|
const editButton = document.createElement('button');
|
||||||
editButton.type = 'button';
|
editButton.type = 'button';
|
||||||
@@ -2158,9 +2149,8 @@ function renderPostCard(post, {
|
|||||||
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
|
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
menuItems.unshift({ label: 'Редактировать', iconHtml: iconHtml('edit'), action: () => editButton.click() });
|
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
|
||||||
menuItems.push({ divider: true });
|
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
|
||||||
menuItems.push({ label: 'Удалить', danger: true, iconHtml: iconHtml('trash'), action: async () => {
|
|
||||||
if (!await confirmDialog({
|
if (!await confirmDialog({
|
||||||
title: 'Удалить сообщение?',
|
title: 'Удалить сообщение?',
|
||||||
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
|
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
|
||||||
@@ -2174,7 +2164,7 @@ function renderPostCard(post, {
|
|||||||
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
|
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
|
||||||
attachMessageMenu(card, headRow, menuItems);
|
attachMessageMenu(card, headRow, menuItems);
|
||||||
if (isOwnerPost) {
|
if (isOwnerPost) {
|
||||||
if (post.timestampMs) timestamp.textContent = formatPostStamp(post.timestampMs);
|
if (post.timestampMs) timestamp.textContent = formatClockTime(post.timestampMs);
|
||||||
const meta = document.createElement('div');
|
const meta = document.createElement('div');
|
||||||
meta.className = 'channel-message-meta';
|
meta.className = 'channel-message-meta';
|
||||||
const moreButton = headRow.querySelector('.channel-message-more');
|
const moreButton = headRow.querySelector('.channel-message-more');
|
||||||
@@ -2203,12 +2193,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
const actionButton = document.createElement('button');
|
const actionButton = document.createElement('button');
|
||||||
actionButton.type = 'button';
|
actionButton.type = 'button';
|
||||||
actionButton.className = 'primary-btn channel-main-action';
|
actionButton.className = 'primary-btn channel-main-action';
|
||||||
actionButton.innerHTML = `${iconHtml('bell')}<span>${state.session.isAuthorized ? 'Подписаться' : 'Войти и подписаться'}</span>`;
|
actionButton.textContent = state.session.isAuthorized ? 'Подписаться на канал' : 'Войти и подписаться';
|
||||||
|
|
||||||
const addMessageButton = document.createElement('button');
|
const addMessageButton = document.createElement('button');
|
||||||
addMessageButton.type = 'button';
|
addMessageButton.type = 'button';
|
||||||
addMessageButton.className = 'channel-new-post';
|
addMessageButton.className = 'channel-compose-bar';
|
||||||
addMessageButton.innerHTML = `${iconHtml('compose')}<span>Новая запись</span>`;
|
addMessageButton.setAttribute('aria-label', 'Написать в канал');
|
||||||
|
addMessageButton.innerHTML = `<span class="channel-compose-bar__text">Написать в канал…</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
|
||||||
addMessageButton.addEventListener('click', (event) => {
|
addMessageButton.addEventListener('click', (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
handlers.onAddMessage();
|
handlers.onAddMessage();
|
||||||
@@ -2245,8 +2236,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
const itemDayKey = dayKey(item.timestampMs);
|
const itemDayKey = dayKey(item.timestampMs);
|
||||||
const startsNewDay = !!itemDayKey && itemDayKey !== lastDayKey;
|
const startsNewDay = !!itemDayKey && itemDayKey !== lastDayKey;
|
||||||
if (startsNewDay) lastDayKey = itemDayKey;
|
if (startsNewDay) lastDayKey = itemDayKey;
|
||||||
// В ленте канала дата стоит у каждой записи; плашки дней остаются только в дневнике
|
if (startsNewDay && item.type !== 'meta') {
|
||||||
if (startsNewDay && item.type !== 'meta' && channelData.isDiary) {
|
|
||||||
const separator = document.createElement('div');
|
const separator = document.createElement('div');
|
||||||
separator.className = 'channel-day-separator';
|
separator.className = 'channel-day-separator';
|
||||||
separator.innerHTML = `<span>${escapeHtml(formatDayLabel(item.timestampMs))}</span>`;
|
separator.innerHTML = `<span>${escapeHtml(formatDayLabel(item.timestampMs))}</span>`;
|
||||||
@@ -2310,11 +2300,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
composer.append(addMessageButton);
|
composer.append(addMessageButton);
|
||||||
handlers.chrome?.setComposer(composer);
|
handlers.chrome?.setComposer(composer);
|
||||||
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
|
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
|
||||||
screen.append(feed);
|
screen.append(actionButton, feed);
|
||||||
const composer = document.createElement('div');
|
|
||||||
composer.className = 'channel-composer';
|
|
||||||
composer.append(actionButton);
|
|
||||||
handlers.chrome?.setComposer(composer);
|
|
||||||
} else {
|
} else {
|
||||||
screen.append(feed);
|
screen.append(feed);
|
||||||
}
|
}
|
||||||
@@ -2426,18 +2412,17 @@ export function render({ navigate, route, chrome }) {
|
|||||||
|
|
||||||
let activeChannelData = null;
|
let activeChannelData = null;
|
||||||
let activeOpenEntrypointHistory = null;
|
let activeOpenEntrypointHistory = null;
|
||||||
let activeAddMessage = null;
|
|
||||||
|
|
||||||
const channelHeaderButton = document.createElement('button');
|
const channelHeaderButton = document.createElement('button');
|
||||||
channelHeaderButton.type = 'button';
|
channelHeaderButton.type = 'button';
|
||||||
channelHeaderButton.className = 'topbar-identity channel-header-route-btn';
|
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||||
channelHeaderButton.innerHTML = '<span class="topbar-identity__text"><span class="topbar-identity__title">Канал</span><span class="topbar-identity__sub">Загрузка…</span></span>';
|
channelHeaderButton.innerHTML = '<span class="channel-header-title">Канал</span><span class="channel-header-owner">Загрузка…</span>';
|
||||||
channelHeaderButton.disabled = true;
|
channelHeaderButton.disabled = true;
|
||||||
|
|
||||||
const header = createTopBar({
|
const header = createTopBar({
|
||||||
center: channelHeaderButton,
|
center: channelHeaderButton,
|
||||||
back: { onClick: () => navigate('channels-list') },
|
back: { onClick: () => navigate('channels-list') },
|
||||||
className: 'channel-view-topbar topbar--identity',
|
className: 'channel-view-topbar',
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: '⋮',
|
label: '⋮',
|
||||||
@@ -2457,20 +2442,16 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const aboutRoute = makeShineChannelAboutRoute(routeArgs);
|
const aboutRoute = makeShineChannelAboutRoute(routeArgs);
|
||||||
const donateRoute = makeShineChannelDonateRoute(routeArgs);
|
const donateRoute = makeShineChannelDonateRoute(routeArgs);
|
||||||
const items = [];
|
const items = [];
|
||||||
items.push({ label: 'О канале', iconHtml: iconHtml('info'), action: () => { if (aboutRoute) navigate(aboutRoute); } });
|
items.push({ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } });
|
||||||
if (apiData?.isOwnChannel && !apiData?.isDiary) {
|
|
||||||
items.push({ label: 'Новая запись', iconHtml: iconHtml('compose'), action: () => activeAddMessage?.() });
|
|
||||||
}
|
|
||||||
items.push({
|
items.push({
|
||||||
label: 'Оглавление',
|
label: 'Оглавление',
|
||||||
iconHtml: iconHtml('list'),
|
|
||||||
action: () => {
|
action: () => {
|
||||||
if (activeOpenEntrypointHistory) activeOpenEntrypointHistory();
|
if (activeOpenEntrypointHistory) activeOpenEntrypointHistory();
|
||||||
else showToast('В этом канале пока нет оглавления');
|
else showToast('В этом канале пока нет оглавления');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!apiData?.isOwnChannel) {
|
if (!apiData?.isOwnChannel) {
|
||||||
items.push({ label: 'Поддержать автора', iconHtml: iconHtml('gift'), action: () => { if (donateRoute) navigate(donateRoute); } });
|
items.push({ label: 'Поддержать автора', action: () => { if (donateRoute) navigate(donateRoute); } });
|
||||||
}
|
}
|
||||||
if (!apiData?.isOwnChannel && !isStoriesChannel(apiData?.channel)) {
|
if (!apiData?.isOwnChannel && !isStoriesChannel(apiData?.channel)) {
|
||||||
if (apiData?.isSubscribed) {
|
if (apiData?.isSubscribed) {
|
||||||
@@ -2887,21 +2868,15 @@ export function render({ navigate, route, chrome }) {
|
|||||||
};
|
};
|
||||||
activeOpenEntrypointHistory = openEntrypointHistory;
|
activeOpenEntrypointHistory = openEntrypointHistory;
|
||||||
if (channelHeaderButton) {
|
if (channelHeaderButton) {
|
||||||
// Короткий адрес канала и число подписчиков; владелец и полный путь — в «О канале»
|
const ownerLabel = String(apiData?.channel?.ownerName || '').trim();
|
||||||
const slug = String(apiData?.channel?.name || '').trim();
|
|
||||||
const subs = Number(apiData?.channel?.subscribersCount || 0);
|
|
||||||
channelHeaderButton.replaceChildren();
|
channelHeaderButton.replaceChildren();
|
||||||
const text = document.createElement('span');
|
|
||||||
text.className = 'topbar-identity__text';
|
|
||||||
const titleNode = document.createElement('span');
|
const titleNode = document.createElement('span');
|
||||||
titleNode.className = 'topbar-identity__title';
|
titleNode.className = 'channel-header-title';
|
||||||
titleNode.textContent = titleLabel;
|
titleNode.textContent = titleLabel;
|
||||||
const subNode = document.createElement('span');
|
const ownerNode = document.createElement('span');
|
||||||
subNode.className = 'topbar-identity__sub';
|
ownerNode.className = 'channel-header-owner';
|
||||||
subNode.textContent = apiData?.isDiary ? 'Только вам' : [slug ? `@${slug}` : '', subs ? String(subs) : ''].filter(Boolean).join(' · ');
|
ownerNode.textContent = ownerLabel ? `@${ownerLabel}` : 'О канале';
|
||||||
text.append(titleNode, subNode);
|
channelHeaderButton.append(titleNode, ownerNode);
|
||||||
channelHeaderButton.append(createChannelAvatarElement(apiData?.channel, 40), text);
|
|
||||||
channelHeaderButton.setAttribute('aria-label', `${titleLabel}. О канале`);
|
|
||||||
channelHeaderButton.disabled = false;
|
channelHeaderButton.disabled = false;
|
||||||
channelHeaderButton.onclick = (event) => {
|
channelHeaderButton.onclick = (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
@@ -2935,10 +2910,9 @@ export function render({ navigate, route, chrome }) {
|
|||||||
restorePosition,
|
restorePosition,
|
||||||
showStatus,
|
showStatus,
|
||||||
isActive: () => !disposed,
|
isActive: () => !disposed,
|
||||||
onAddMessage: activeAddMessage = () => {
|
onAddMessage: () => {
|
||||||
openAddMessageModal({
|
openAddMessageModal({
|
||||||
channelName: apiData?.channel?.name || '',
|
channelName: apiData?.channel?.name || '',
|
||||||
channelTitle: apiData?.channel?.displayTitle || '',
|
|
||||||
navigate,
|
navigate,
|
||||||
isActive: () => !disposed,
|
isActive: () => !disposed,
|
||||||
onSubmit: async ({ text: bodyText, msgSubType }) => {
|
onSubmit: async ({ text: bodyText, msgSubType }) => {
|
||||||
|
|||||||
@@ -934,7 +934,7 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
|
|||||||
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
|
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="channel-row-controls">
|
<div class="channel-row-controls">
|
||||||
<span class="channel-row-time">–</span>
|
<span class="channel-row-time">—</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
row.prepend(createChannelAvatar(channel));
|
row.prepend(createChannelAvatar(channel));
|
||||||
@@ -1163,7 +1163,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
className: 'topbar--root',
|
className: 'topbar--root',
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
icon: 'plus',
|
label: '+',
|
||||||
title: 'Создать канал',
|
title: 'Создать канал',
|
||||||
ariaLabel: 'Создать канал',
|
ariaLabel: 'Создать канал',
|
||||||
className: 'channels-create-btn',
|
className: 'channels-create-btn',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { iconHtml } from '../components/ui-icon.js';
|
|
||||||
import { createTopBar } from '../components/topbar.js';
|
import { createTopBar } from '../components/topbar.js';
|
||||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
@@ -99,18 +98,18 @@ function createChatHeaderParts(login, navigate) {
|
|||||||
|
|
||||||
const identityButton = document.createElement('button');
|
const identityButton = document.createElement('button');
|
||||||
identityButton.type = 'button';
|
identityButton.type = 'button';
|
||||||
identityButton.className = 'topbar-identity chat-header-peer-btn';
|
identityButton.className = 'chat-header-peer-btn';
|
||||||
identityButton.title = `Меню ${cleanLogin}`;
|
identityButton.title = `Меню ${cleanLogin}`;
|
||||||
identityButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
|
identityButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
|
||||||
|
|
||||||
const avatarSlot = document.createElement('span');
|
const avatarSlot = document.createElement('span');
|
||||||
avatarSlot.className = 'chat-header-avatar-slot';
|
avatarSlot.className = 'chat-header-avatar-slot';
|
||||||
const textWrap = document.createElement('span');
|
const textWrap = document.createElement('span');
|
||||||
textWrap.className = 'topbar-identity__text chat-header-peer-text';
|
textWrap.className = 'chat-header-peer-text';
|
||||||
const nameEl = document.createElement('span');
|
const nameEl = document.createElement('span');
|
||||||
nameEl.className = 'topbar-identity__title chat-header-peer-name';
|
nameEl.className = 'chat-header-peer-name';
|
||||||
const metaEl = document.createElement('span');
|
const metaEl = document.createElement('span');
|
||||||
metaEl.className = 'topbar-identity__sub chat-header-peer-meta';
|
metaEl.className = 'chat-header-peer-meta';
|
||||||
textWrap.append(nameEl, metaEl);
|
textWrap.append(nameEl, metaEl);
|
||||||
identityButton.append(avatarSlot, textWrap);
|
identityButton.append(avatarSlot, textWrap);
|
||||||
|
|
||||||
@@ -120,9 +119,7 @@ function createChatHeaderParts(login, navigate) {
|
|||||||
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||||
nameEl.textContent = fullName || cleanLogin;
|
nameEl.textContent = fullName || cleanLogin;
|
||||||
const relation = chatRelationLabel(currentPeer?.relationType);
|
const relation = chatRelationLabel(currentPeer?.relationType);
|
||||||
// Статус аккаунта, а не «в сети»: присутствие сервер не сообщает, выдумывать его нельзя
|
metaEl.textContent = [fullName ? `@${cleanLogin}` : '', relation].filter(Boolean).join(' · ');
|
||||||
const shining = String(currentPeer?.shineStatus || '').toLowerCase() === 'shining' ? '✧ сияющий аккаунт' : '';
|
|
||||||
metaEl.textContent = [fullName ? `@${cleanLogin}` : '', shining, relation].filter(Boolean).join(' · ');
|
|
||||||
metaEl.hidden = !metaEl.textContent;
|
metaEl.hidden = !metaEl.textContent;
|
||||||
const avatar = renderUserAvatar({
|
const avatar = renderUserAvatar({
|
||||||
login: cleanLogin,
|
login: cleanLogin,
|
||||||
@@ -771,14 +768,6 @@ function resolveDeliveryStatus(messages, msg) {
|
|||||||
return '…';
|
return '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Статус сообщения — иконкой и словом для экранного чтения (цвет не единственный носитель смысла)
|
|
||||||
const DELIVERY_STATUS_VIEW = {
|
|
||||||
'✓✓': { icon: 'check-double', label: 'Прочитано' },
|
|
||||||
'✓': { icon: 'check', label: 'Отправлено' },
|
|
||||||
'!': { icon: 'alert', label: 'Не отправлено' },
|
|
||||||
'…': { icon: 'clock', label: 'Отправляется' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveDeliveryTone(messages, msg) {
|
function resolveDeliveryTone(messages, msg) {
|
||||||
if (resolveEffectiveReadState(messages, msg).isRead) return 'read';
|
if (resolveEffectiveReadState(messages, msg).isRead) return 'read';
|
||||||
const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase();
|
const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase();
|
||||||
@@ -1210,11 +1199,7 @@ function renderLog(
|
|||||||
if (status) {
|
if (status) {
|
||||||
const statusNode = document.createElement('span');
|
const statusNode = document.createElement('span');
|
||||||
statusNode.className = `bubble-status bubble-status--${resolveDeliveryTone(messages, msg)}`;
|
statusNode.className = `bubble-status bubble-status--${resolveDeliveryTone(messages, msg)}`;
|
||||||
const statusView = DELIVERY_STATUS_VIEW[status] || DELIVERY_STATUS_VIEW['…'];
|
statusNode.textContent = status;
|
||||||
statusNode.innerHTML = iconHtml(statusView.icon);
|
|
||||||
statusNode.setAttribute('role', 'img');
|
|
||||||
statusNode.setAttribute('aria-label', statusView.label);
|
|
||||||
statusNode.title = statusView.label;
|
|
||||||
metaNode.append(statusNode);
|
metaNode.append(statusNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1444,7 +1429,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
|
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
|
||||||
const chatHeader = createTopBar({
|
const chatHeader = createTopBar({
|
||||||
center: chatHeaderParts.center,
|
center: chatHeaderParts.center,
|
||||||
className: 'topbar--identity',
|
|
||||||
back: { label: '←', onClick: () => navigate('messages-list') },
|
back: { label: '←', onClick: () => navigate('messages-list') },
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ export function render({navigate, chrome}) {
|
|||||||
<div class="stack" style="gap:6px;">
|
<div class="stack" style="gap:6px;">
|
||||||
<p class="meta-muted">пользователь выбирает ключи для передачи</p>
|
<p class="meta-muted">пользователь выбирает ключи для передачи</p>
|
||||||
<p class="meta-muted">передать можно только существующие ключи</p>
|
<p class="meta-muted">передать можно только существующие ключи</p>
|
||||||
<p class="meta-muted">если ключа нет – он недоступен</p>
|
<p class="meta-muted">если ключа нет — он недоступен</p>
|
||||||
<p class="meta-muted">blockchain key – можно передать или нет</p>
|
<p class="meta-muted">blockchain key — можно передать или нет</p>
|
||||||
<p class="meta-muted">root key – только если существует</p>
|
<p class="meta-muted">root key — только если существует</p>
|
||||||
<p class="meta-muted">client key передаётся всегда</p>
|
<p class="meta-muted">client key передаётся всегда</p>
|
||||||
<p class="meta-muted">подключение происходит напрямую через QR</p>
|
<p class="meta-muted">подключение происходит напрямую через QR</p>
|
||||||
<p class="meta-muted">сервер не используется</p>
|
<p class="meta-muted">сервер не используется</p>
|
||||||
|
|||||||
@@ -209,9 +209,9 @@ function showClientUpdateHelp() {
|
|||||||
'Если UI не обновился:\n\n'
|
'Если UI не обновился:\n\n'
|
||||||
+ '1) Закройте вкладки с Сиянием.\n'
|
+ '1) Закройте вкладки с Сиянием.\n'
|
||||||
+ `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n`
|
+ `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n`
|
||||||
+ '3) Если приложение установлено как PWA – удалите его с устройства.\n'
|
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n'
|
||||||
+ `4) Откройте ${defaultServerHttp} заново и выполните вход.\n`
|
+ `4) Откройте ${defaultServerHttp} заново и выполните вход.\n`
|
||||||
+ '5) Если всё ещё старая версия – откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
|
+ '5) Если всё ещё старая версия — откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ export function render({navigate, chrome}) {
|
|||||||
};
|
};
|
||||||
const syncPwaButtonLabel = () => {
|
const syncPwaButtonLabel = () => {
|
||||||
if (isStandalonePwaMode()) {
|
if (isStandalonePwaMode()) {
|
||||||
setPwaLabel('Приложение установлено – проверить уведомления');
|
setPwaLabel('Приложение установлено — проверить уведомления');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (canInstallPwa()) {
|
if (canInstallPwa()) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createTopBar } from '../components/topbar.js';
|
import { createTopBar } from '../components/topbar.js';
|
||||||
import { iconHtml } from '../components/ui-icon.js';
|
|
||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
@@ -59,7 +58,7 @@ function requestCardHtml(request) {
|
|||||||
const shortCode = formatPairingShortCode(request?.shortCode || '');
|
const shortCode = formatPairingShortCode(request?.shortCode || '');
|
||||||
const client = String(request?.requesterClientPlatform || 'unknown');
|
const client = String(request?.requesterClientPlatform || 'unknown');
|
||||||
const requesterSessionType = Number(request?.requesterSessionType || 0);
|
const requesterSessionType = Number(request?.requesterSessionType || 0);
|
||||||
const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '–';
|
const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '—';
|
||||||
const sessionOnly = requesterSessionType === SESSION_TYPE_WALLET;
|
const sessionOnly = requesterSessionType === SESSION_TYPE_WALLET;
|
||||||
const sessionKind = pairingSessionKindLabel(requesterSessionType);
|
const sessionKind = pairingSessionKindLabel(requesterSessionType);
|
||||||
return `
|
return `
|
||||||
@@ -126,8 +125,19 @@ async function runPairingOpWithSessionRestore(runAction) {
|
|||||||
|
|
||||||
function makePasswordToggleIcons() {
|
function makePasswordToggleIcons() {
|
||||||
return {
|
return {
|
||||||
eye: iconHtml('eye', false, 'key-toggle-icon'),
|
eye: `
|
||||||
eyeOff: iconHtml('eyeOff', false, 'key-toggle-icon'),
|
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M2.4 12s3.6-6.5 9.6-6.5S21.6 12 21.6 12s-3.6 6.5-9.6 6.5S2.4 12 2.4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||||
|
<circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
eyeOff: `
|
||||||
|
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M3 4l18 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
<path d="M2.4 12s3.6-6.5 9.6-6.5c2.4 0 4.5.8 6.1 1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||||
|
<path d="M21.6 12s-3.6 6.5-9.6 6.5c-2.4 0-4.5-.8-6.1-1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import { checkServerAvailabilityByKey, resolveAndCheckShineServerLogin } from '.
|
|||||||
export const pageMeta = { id: 'entry-settings-view', title: 'Настройки входа', showAppChrome: false };
|
export const pageMeta = { id: 'entry-settings-view', title: 'Настройки входа', showAppChrome: false };
|
||||||
|
|
||||||
const SERVER_FIELDS = [
|
const SERVER_FIELDS = [
|
||||||
{ key: 'solanaServer', label: 'Сервер Solana' },
|
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
|
||||||
{ key: 'shineServerLogin', label: 'Сервер Сияния' },
|
{ key: 'shineServerLogin', label: 'Сервер Сияния' },
|
||||||
{ key: 'arweaveServer', label: 'Сервер Arweave' },
|
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack entry-settings';
|
screen.className = 'stack';
|
||||||
|
|
||||||
const draft = {
|
const draft = {
|
||||||
language: state.entrySettings.language,
|
language: state.entrySettings.language,
|
||||||
@@ -30,79 +30,62 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const timers = new Map();
|
const timers = new Map();
|
||||||
|
|
||||||
let disposed = false;
|
|
||||||
const checks = [];
|
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'entry-servers';
|
body.className = 'card stack';
|
||||||
|
|
||||||
SERVER_FIELDS.forEach((field) => {
|
SERVER_FIELDS.forEach((field) => {
|
||||||
const block = document.createElement('div');
|
const block = document.createElement('div');
|
||||||
block.className = 'entry-server';
|
block.className = 'stack';
|
||||||
|
|
||||||
const head = document.createElement('div');
|
|
||||||
head.className = 'entry-server__head';
|
|
||||||
|
|
||||||
const title = document.createElement('label');
|
const title = document.createElement('label');
|
||||||
title.className = 'field-label';
|
title.className = 'field-label';
|
||||||
title.htmlFor = `entry-${field.key}`;
|
|
||||||
title.textContent = field.label;
|
title.textContent = field.label;
|
||||||
|
|
||||||
// Статус — он же кнопка повторной проверки.
|
|
||||||
const checkButton = document.createElement('button');
|
|
||||||
checkButton.className = 'text-btn entry-status';
|
|
||||||
checkButton.type = 'button';
|
|
||||||
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.className = 'input';
|
input.className = 'input';
|
||||||
input.id = `entry-${field.key}`;
|
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.autocapitalize = 'off';
|
|
||||||
input.spellcheck = false;
|
|
||||||
input.value = draft[field.key];
|
input.value = draft[field.key];
|
||||||
|
|
||||||
const hint = document.createElement('p');
|
const controls = document.createElement('div');
|
||||||
hint.className = 'entry-server__hint';
|
controls.className = 'row wrap-row';
|
||||||
|
|
||||||
|
const checkButton = document.createElement('button');
|
||||||
|
checkButton.className = 'secondary-btn server-check-btn';
|
||||||
|
checkButton.type = 'button';
|
||||||
|
checkButton.textContent = 'Проверить';
|
||||||
|
|
||||||
|
const status = document.createElement('span');
|
||||||
|
status.className = 'status-line';
|
||||||
|
|
||||||
const applyStatus = (value, exactAddress = '') => {
|
const applyStatus = (value, exactAddress = '') => {
|
||||||
if (disposed) return;
|
draft.statuses[field.key] = value;
|
||||||
if (value !== 'checking') draft.statuses[field.key] = value;
|
checkButton.classList.remove('is-available', 'is-unavailable');
|
||||||
checkButton.classList.remove('is-available', 'is-unavailable', 'is-checking');
|
status.classList.remove('is-available', 'is-unavailable');
|
||||||
checkButton.disabled = false;
|
|
||||||
|
|
||||||
if (value === 'available') {
|
if (value === 'available') {
|
||||||
checkButton.textContent = 'Доступен';
|
status.textContent = field.key === 'shineServerLogin' && exactAddress
|
||||||
|
? `Доступен: ${exactAddress}`
|
||||||
|
: 'Доступен';
|
||||||
checkButton.classList.add('is-available');
|
checkButton.classList.add('is-available');
|
||||||
|
status.classList.add('is-available');
|
||||||
} else if (value === 'unavailable') {
|
} else if (value === 'unavailable') {
|
||||||
checkButton.textContent = 'Недоступен';
|
status.textContent = 'Недоступен';
|
||||||
checkButton.classList.add('is-unavailable');
|
checkButton.classList.add('is-unavailable');
|
||||||
} else if (value === 'checking') {
|
status.classList.add('is-unavailable');
|
||||||
checkButton.textContent = 'Проверка…';
|
|
||||||
checkButton.classList.add('is-checking');
|
|
||||||
checkButton.disabled = true;
|
|
||||||
} else {
|
} else {
|
||||||
checkButton.textContent = 'Проверить';
|
status.textContent = field.key === 'shineServerLogin' && draft.shineServerHttp
|
||||||
}
|
? `Текущий адрес: ${draft.shineServerHttp}`
|
||||||
checkButton.setAttribute('aria-label', `${field.label}: ${checkButton.textContent}. Проверить ещё раз`);
|
: 'Статус не проверен';
|
||||||
|
|
||||||
if (field.key === 'shineServerLogin') {
|
|
||||||
const address = exactAddress || draft.shineServerHttp;
|
|
||||||
hint.textContent = address || '';
|
|
||||||
hint.hidden = !address;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let checkSeq = 0;
|
|
||||||
const runCheck = async () => {
|
const runCheck = async () => {
|
||||||
draft[field.key] = input.value.trim();
|
draft[field.key] = input.value.trim();
|
||||||
window.clearTimeout(timers.get(field.key));
|
checkButton.disabled = true;
|
||||||
// Результат устаревшей проверки (адрес успели поменять) не должен перезаписать новый.
|
checkButton.textContent = 'Проверка...';
|
||||||
const seq = ++checkSeq;
|
|
||||||
applyStatus('checking');
|
|
||||||
try {
|
try {
|
||||||
if (field.key === 'shineServerLogin') {
|
if (field.key === 'shineServerLogin') {
|
||||||
const resolved = await resolveAndCheckShineServerLogin(input.value, draft.solanaServer);
|
const resolved = await resolveAndCheckShineServerLogin(input.value, draft.solanaServer);
|
||||||
if (seq !== checkSeq) return;
|
|
||||||
draft.shineServerHttp = resolved.httpBase;
|
draft.shineServerHttp = resolved.httpBase;
|
||||||
draft.shineServer = resolved.wsUrl;
|
draft.shineServer = resolved.wsUrl;
|
||||||
applyStatus(resolved.status, resolved.httpBase);
|
applyStatus(resolved.status, resolved.httpBase);
|
||||||
@@ -110,29 +93,26 @@ export function render({ navigate }) {
|
|||||||
const next = await checkServerAvailabilityByKey(field.key, input.value, {
|
const next = await checkServerAvailabilityByKey(field.key, input.value, {
|
||||||
solanaEndpoint: draft.solanaServer,
|
solanaEndpoint: draft.solanaServer,
|
||||||
});
|
});
|
||||||
if (seq !== checkSeq) return;
|
|
||||||
applyStatus(next);
|
applyStatus(next);
|
||||||
}
|
}
|
||||||
} catch {
|
} finally {
|
||||||
if (seq === checkSeq) applyStatus('unavailable');
|
checkButton.disabled = false;
|
||||||
|
checkButton.textContent = 'Проверить';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
applyStatus(draft.statuses[field.key]);
|
applyStatus(draft.statuses[field.key]);
|
||||||
hint.hidden = field.key !== 'shineServerLogin' || !hint.textContent;
|
|
||||||
checks.push(runCheck);
|
|
||||||
|
|
||||||
checkButton.addEventListener('click', runCheck);
|
checkButton.addEventListener('click', runCheck);
|
||||||
input.addEventListener('input', () => {
|
input.addEventListener('input', () => {
|
||||||
draft[field.key] = input.value;
|
draft[field.key] = input.value;
|
||||||
checkSeq += 1;
|
|
||||||
applyStatus('idle');
|
applyStatus('idle');
|
||||||
window.clearTimeout(timers.get(field.key));
|
window.clearTimeout(timers.get(field.key));
|
||||||
timers.set(field.key, window.setTimeout(() => {
|
timers.set(field.key, window.setTimeout(() => {
|
||||||
void runCheck();
|
void runCheck();
|
||||||
}, 3000));
|
}, 3000));
|
||||||
});
|
});
|
||||||
input.addEventListener('change', () => {
|
input.addEventListener('blur', () => {
|
||||||
void runCheck();
|
void runCheck();
|
||||||
});
|
});
|
||||||
input.addEventListener('keydown', (event) => {
|
input.addEventListener('keydown', (event) => {
|
||||||
@@ -142,17 +122,17 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
head.append(title, checkButton);
|
controls.append(checkButton, status);
|
||||||
block.append(head, input, hint);
|
block.append(title, input, controls);
|
||||||
body.append(block);
|
body.append(block);
|
||||||
});
|
});
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'entry-settings__actions';
|
actions.className = 'auth-footer-actions';
|
||||||
|
|
||||||
if (isLocalDemoAvailable()) {
|
if (isLocalDemoAvailable()) {
|
||||||
const localDemoButton = document.createElement('button');
|
const localDemoButton = document.createElement('button');
|
||||||
localDemoButton.className = 'secondary-btn';
|
localDemoButton.className = 'secondary-btn preauth-local-demo-btn';
|
||||||
localDemoButton.type = 'button';
|
localDemoButton.type = 'button';
|
||||||
localDemoButton.textContent = 'Открыть локальный тестовый режим';
|
localDemoButton.textContent = 'Открыть локальный тестовый режим';
|
||||||
localDemoButton.addEventListener('click', () => {
|
localDemoButton.addEventListener('click', () => {
|
||||||
@@ -165,12 +145,18 @@ export function render({ navigate }) {
|
|||||||
const serverUiButton = document.createElement('button');
|
const serverUiButton = document.createElement('button');
|
||||||
serverUiButton.className = 'secondary-btn';
|
serverUiButton.className = 'secondary-btn';
|
||||||
serverUiButton.type = 'button';
|
serverUiButton.type = 'button';
|
||||||
serverUiButton.textContent = 'Настроить свой сервер ↗';
|
serverUiButton.textContent = 'Настроить свой сервер';
|
||||||
serverUiButton.addEventListener('click', () => {
|
serverUiButton.addEventListener('click', () => {
|
||||||
const url = new URL('server-ui.html', window.location.href);
|
const url = new URL('server-ui.html', window.location.href);
|
||||||
window.open(url.toString(), '_blank', 'noopener');
|
window.open(url.toString(), '_blank', 'noopener');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cancelButton = document.createElement('button');
|
||||||
|
cancelButton.className = 'secondary-btn';
|
||||||
|
cancelButton.type = 'button';
|
||||||
|
cancelButton.textContent = 'Отмена';
|
||||||
|
cancelButton.addEventListener('click', () => navigate('start-view'));
|
||||||
|
|
||||||
const saveButton = document.createElement('button');
|
const saveButton = document.createElement('button');
|
||||||
saveButton.className = 'primary-btn';
|
saveButton.className = 'primary-btn';
|
||||||
saveButton.type = 'button';
|
saveButton.type = 'button';
|
||||||
@@ -184,8 +170,7 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.prepend(saveButton);
|
actions.append(serverUiButton, cancelButton, saveButton);
|
||||||
actions.append(serverUiButton);
|
|
||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
createTopBar({
|
createTopBar({
|
||||||
@@ -197,12 +182,8 @@ export function render({ navigate }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
disposed = true;
|
|
||||||
timers.forEach((timerId) => window.clearTimeout(timerId));
|
timers.forEach((timerId) => window.clearTimeout(timerId));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Серверы проверяются сразу — зелёный «Доступен» видно без лишних нажатий.
|
|
||||||
checks.forEach((check) => { void check(); });
|
|
||||||
|
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { createTopBar } from '../components/topbar.js';
|
||||||
|
import { authService, state } from '../state.js';
|
||||||
|
import { bytesToBase64 } from '../services/crypto-utils.js';
|
||||||
|
import { readShineUserPda } from '../services/shine-user-pda-service.js';
|
||||||
|
import { KeyRotationClient, KEY_ROTATION_REASONS } from '../services/key-rotation-service.js';
|
||||||
|
|
||||||
|
export const pageMeta = { id:'key-rotation-view', title:'Смена ключей', hideToolbar:true };
|
||||||
|
|
||||||
|
const POLL_MS=1800;
|
||||||
|
function h(v){return String(v??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||||
|
function short(v){const s=String(v||'');return s.length>20?`${s.slice(0,10)}…${s.slice(-8)}`:s;}
|
||||||
|
function stageText(s){return ({COPYING_CHAIN:'Копирование новой цепочки',CHAIN_READY:'Новая цепочка готова',ROTATING_PDA:'Ожидание Solana',PDA_ROTATED:'PDA обновлена',REBUILDING_SERVER:'Перестройка сервера',WALLET_MIGRATION:'Перенос средств',MESSAGE_MIGRATION:'Перешифрование сообщений',FINALIZING:'Завершение',COMPLETE:'Готово',NONE:'Новая ротация'})[s]||s;}
|
||||||
|
function input(label,type='password'){const wrap=document.createElement('label');wrap.className='stack';const t=document.createElement('span');t.className='field-label';t.textContent=label;const el=document.createElement('input');el.className='text-input';el.type=type;wrap.append(t,el);return {wrap,el};}
|
||||||
|
|
||||||
|
export function render({navigate,chrome}){
|
||||||
|
const screen=document.createElement('section');screen.className='stack';
|
||||||
|
chrome?.setTopbar(createTopBar({title:'Смена ключей',back:{label:'←',onClick:()=>navigate('my-blockchain-view')}}));
|
||||||
|
const root=document.createElement('div');root.className='stack';screen.append(root);
|
||||||
|
const client=new KeyRotationClient(authService);let disposed=false;let timer=null;let cachedOldBundle=null;let cachedNewBundle=null;
|
||||||
|
const login=String(state.session.login||'').trim(); const storagePwd=String(state.session.storagePwdInMemory||'').trim();
|
||||||
|
const solanaEndpoint=String(state.entrySettings.solanaServer||'').trim();
|
||||||
|
|
||||||
|
async function derive(password){return authService.derivePasswordKeyBundle(login,password,{onProgress:()=>{}});}
|
||||||
|
async function verifyOldPassword(bundle){
|
||||||
|
const pda=await readShineUserPda({login,solanaEndpoint});
|
||||||
|
if(bundle.rootPair.publicKeyB64!==bytesToBase64(pda.rootKey) || bundle.blockchainPair.publicKeyB64!==bytesToBase64(pda.blockchain.blockchainPublicKey) || bundle.clientPair.publicKeyB64!==bytesToBase64(pda.clientKey)) throw new Error('Текущий пароль не соответствует ключам PDA');
|
||||||
|
}
|
||||||
|
function setRoot(...nodes){root.replaceChildren(...nodes);}
|
||||||
|
function statusCard(s){const card=document.createElement('div');card.className='card stack';card.innerHTML=`<strong>${h(stageText(s.rotationStatus))}</strong><span class="meta-muted">${h(s.sourceBlockchainName||'')} → ${h(s.candidateBlockchainName||'')}</span><span class="meta-muted">Прогресс: ${Number(s.progressCurrent||0)} / ${Number(s.progressTotal||0)}</span>${s.lastError?`<span>${h(s.lastError)}</span>`:''}`;return card;}
|
||||||
|
function note(text){const c=document.createElement('div');c.className='card';c.textContent=text;return c;}
|
||||||
|
|
||||||
|
async function renderNone(){
|
||||||
|
const head=note('Выберите последний блок, которому вы доверяете. Всё до него будет точно перепубликовано новым blockchain key, после чего добавится TECH_FORK.');
|
||||||
|
const oldP=input('Текущий пароль'); const newP=input('Новый пароль'); const new2=input('Повторите новый пароль');
|
||||||
|
const reason=document.createElement('select');reason.className='text-input';for(const r of KEY_ROTATION_REASONS){const o=document.createElement('option');o.value=String(r.code);o.textContent=r.label;reason.append(o);}
|
||||||
|
const reasonWrap=document.createElement('label');reasonWrap.className='stack';reasonWrap.innerHTML='<span class="field-label">Причина</span>';reasonWrap.append(reason);
|
||||||
|
const comment=document.createElement('textarea');comment.className='text-input';comment.rows=3;comment.maxLength=1024;comment.placeholder='Необязательный комментарий для истории';
|
||||||
|
const list=document.createElement('div');list.className='stack';const more=document.createElement('button');more.className='secondary-btn';more.type='button';more.textContent='Показать более ранние блоки';
|
||||||
|
let selected=null,before=null,loading=false;
|
||||||
|
async function load(){if(loading)return;loading=true;more.disabled=true;try{const d=await client.getMyBlockchain({beforeBlock:before,limit:100});for(const b of d.blocks||[]){const btn=document.createElement('button');btn.type='button';btn.className='nav-row';btn.innerHTML=`<span class="nav-row__label">#${b.blockNumber} · ${new Date(Number(b.timestampMs)||0).toLocaleString('ru-RU')}</span><span class="nav-row__hint">${short(b.blockHash)}</span>`;btn.addEventListener('click',()=>{selected=b;list.querySelectorAll('button').forEach(x=>x.setAttribute('aria-pressed','false'));btn.setAttribute('aria-pressed','true');});if(selected==null && b.blockNumber===d.tipBlockNumber){selected=b;btn.setAttribute('aria-pressed','true');}list.append(btn);}before=d.nextBeforeBlock;more.hidden=before==null;}finally{loading=false;more.disabled=false;}}
|
||||||
|
more.addEventListener('click',()=>void load());
|
||||||
|
const start=document.createElement('button');start.className='primary-btn';start.type='button';start.textContent='Начать смену ключей';
|
||||||
|
const error=document.createElement('div');error.className='meta-muted';
|
||||||
|
start.addEventListener('click',async()=>{start.disabled=true;error.textContent='';try{if(!selected)throw new Error('Выберите последний доверенный блок');if(!oldP.el.value||!newP.el.value)throw new Error('Введите текущий и новый пароль');if(newP.el.value!==new2.el.value)throw new Error('Новые пароли не совпадают');if(oldP.el.value===newP.el.value)throw new Error('Новый пароль должен отличаться');cachedOldBundle=await derive(oldP.el.value);await verifyOldPassword(cachedOldBundle);cachedNewBundle=await derive(newP.el.value);const s=await client.start({newRootKey:cachedNewBundle.rootPair.publicKeyB64,newBlockchainKey:cachedNewBundle.blockchainPair.publicKeyB64,newClientKey:cachedNewBundle.clientPair.publicKeyB64,forkFromBlock:selected.blockNumber,forkFromHash:selected.blockHash,reasonCode:Number(reason.value),comment:comment.value});await runCopy(s); }catch(e){error.textContent=e?.message||String(e);start.disabled=false;}});
|
||||||
|
setRoot(head,oldP.wrap,newP.wrap,new2.wrap,reasonWrap,comment,list,more,start,error);void load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askNewPasswordAndCopy(s){
|
||||||
|
const card=statusCard(s), p=input('Новый пароль');const go=document.createElement('button');go.className='primary-btn';go.textContent='Продолжить копирование';const err=document.createElement('div');err.className='meta-muted';go.addEventListener('click',async()=>{go.disabled=true;try{cachedNewBundle=await derive(p.el.value);if(cachedNewBundle.blockchainPair.publicKeyB64!==s.newBlockchainKey)throw new Error('Этот пароль выводит другой новый blockchain key');await runCopy(s);}catch(e){err.textContent=e?.message||e;go.disabled=false;}});const abort=document.createElement('button');abort.className='secondary-btn';abort.textContent='Прервать смену ключей';abort.addEventListener('click',async()=>{await client.abort();await refresh();});setRoot(card,note('Для продолжения на этом устройстве введите тот же новый пароль, который использовался при запуске.'),p.wrap,go,abort,err);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCopy(s){
|
||||||
|
const card=statusCard(s), info=note('Создаётся новая копия выбранной части цепочки в Arweave/Turbo. Обычные записи аккаунта в это время заблокированы.');setRoot(card,info);
|
||||||
|
try{await client.copyCandidateChain({rotation:s,newBundle:cachedNewBundle,onProgress:({current,total})=>{card.querySelectorAll('.meta-muted')[1].textContent=`Прогресс: ${current} / ${total}`;}});await client.waitUntilPublished({onProgress:({current,total})=>{card.querySelectorAll('.meta-muted')[1].textContent=`Опубликовано: ${current} / ${total}`;}});await client.finishChain();await refresh();}catch(e){root.append(note(e?.message||String(e)));const retry=document.createElement('button');retry.className='primary-btn';retry.textContent='Повторить / продолжить';retry.addEventListener('click',()=>void refresh());root.append(retry);}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderChainReady(s){
|
||||||
|
const oldP=input('Текущий пароль');const newP=input('Новый пароль');const go=document.createElement('button');go.className='primary-btn';go.textContent='Изменить ключи в Solana';const abort=document.createElement('button');abort.className='secondary-btn';abort.textContent='Прервать смену ключей';const err=document.createElement('div');err.className='meta-muted';
|
||||||
|
go.addEventListener('click',async()=>{go.disabled=true;try{cachedOldBundle=await derive(oldP.el.value);cachedNewBundle=await derive(newP.el.value);await client.rotatePda({login,solanaEndpoint,oldBundle:cachedOldBundle,newBundle:cachedNewBundle,storagePwd});await refresh();}catch(e){err.textContent=e?.message||String(e);go.disabled=false;}});abort.addEventListener('click',async()=>{await client.abort();await refresh();});setRoot(statusCard(s),note('Новая цепочка полностью готова. Следующий шаг — точка невозврата: старый root разрешит атомарную смену root + client + blockchain fork, а новое PDA подпишет новый blockchain key.'),oldP.wrap,newP.wrap,go,abort,err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollView(s){setRoot(statusCard(s),note(s.rotationStatus==='REBUILDING_SERVER'?'Сервер перестраивает текущую рабочую историю по новому fork.':'Ожидаем подтверждение нового состояния PDA в Solana.'));timer=setTimeout(()=>void refresh(),POLL_MS);}
|
||||||
|
function placeholderView(s,kind){const wallet=kind==='wallet';const text=wallet?'Перевод SOL со старого blockchain-wallet на новый пока не реализован. Этот этап сейчас будет отмечен как NOT_IMPLEMENTED и пропущен.':'Перешифрование старых личных сообщений новым client key пока не реализовано. Этот этап сейчас будет отмечен как NOT_IMPLEMENTED и пропущен.';const go=document.createElement('button');go.className='primary-btn';go.textContent='Продолжить';go.addEventListener('click',async()=>{go.disabled=true;try{const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();}catch(e){go.disabled=false;root.append(note(e?.message||String(e)));}});setRoot(statusCard(s),note(text),go);}
|
||||||
|
|
||||||
|
async function refresh(){if(disposed)return;if(timer){clearTimeout(timer);timer=null;}try{const s=await client.status();const st=String(s.rotationStatus||'NONE');if(st==='NONE'){await renderNone();return;}if(st==='COPYING_CHAIN'){if(cachedNewBundle)await runCopy(s);else await askNewPasswordAndCopy(s);return;}if(st==='CHAIN_READY'){await renderChainReady(s);return;}if(['ROTATING_PDA','PDA_ROTATED','REBUILDING_SERVER'].includes(st)){pollView(s);return;}if(st==='WALLET_MIGRATION'){placeholderView(s,'wallet');return;}if(st==='MESSAGE_MIGRATION'){placeholderView(s,'messages');return;}if(st==='FINALIZING'){const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();return;}if(st==='COMPLETE'){setRoot(note('Смена ключей завершена.'));return;}setRoot(note(`Неизвестное состояние ротации: ${st}`));}catch(e){setRoot(note(`Не удалось прочитать состояние смены ключей: ${e?.message||e}`));}}
|
||||||
|
void refresh();screen.cleanup=()=>{disposed=true;if(timer)clearTimeout(timer);};return screen;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user