SHA256
Compare commits
6
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
9be4c4d3a5 | ||
|
|
f5698771e4 | ||
|
|
151a2c1754 | ||
|
|
13693a0a53 | ||
|
|
451280edd2 | ||
|
|
f8900e531a |
+84
-3
@@ -3,7 +3,9 @@ package server.archive;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.KeyRotationCandidateBlocksDAO;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -14,6 +16,7 @@ public final class ArweaveBlockPublisherService {
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||
private final KeyRotationCandidateBlocksDAO candidateBlocksDAO = KeyRotationCandidateBlocksDAO.getInstance();
|
||||
private final ArweaveL1Uploader arweaveUploader;
|
||||
private final TurboDataItemUploader turboUploader;
|
||||
|
||||
@@ -25,13 +28,57 @@ public final class ArweaveBlockPublisherService {
|
||||
|
||||
public int runCycle() throws Exception {
|
||||
if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0;
|
||||
List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems());
|
||||
if (candidates.isEmpty()) return 0;
|
||||
return switch (cfg.publishMode()) {
|
||||
|
||||
int maxItems = cfg.publishMaxItems();
|
||||
int attempted = 0;
|
||||
int published = 0;
|
||||
|
||||
// Ротация приоритетна: пользователь уже заблокирован для обычной записи и ждёт завершения копирования.
|
||||
List<KeyRotationCandidateBlockEntry> rotationCandidates = candidateBlocksDAO.listPendingArweave(maxItems);
|
||||
if (!rotationCandidates.isEmpty()) {
|
||||
attempted += rotationCandidates.size();
|
||||
published += switch (cfg.publishMode()) {
|
||||
case TURBO -> publishTurboRotation(rotationCandidates);
|
||||
case ARWEAVE -> publishDirectArweaveRotation(rotationCandidates);
|
||||
case NONE -> 0;
|
||||
};
|
||||
}
|
||||
|
||||
int remaining = Math.max(0, maxItems - attempted);
|
||||
if (remaining == 0) return published;
|
||||
|
||||
List<BlockEntry> candidates = blocksDAO.listPendingArweave(remaining);
|
||||
if (candidates.isEmpty()) return published;
|
||||
published += switch (cfg.publishMode()) {
|
||||
case TURBO -> publishTurbo(candidates);
|
||||
case ARWEAVE -> publishDirectArweave(candidates);
|
||||
case NONE -> 0;
|
||||
};
|
||||
return published;
|
||||
}
|
||||
|
||||
private int publishTurboRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
|
||||
int published = 0;
|
||||
Exception firstFailure = null;
|
||||
for (KeyRotationCandidateBlockEntry e : candidates) {
|
||||
byte[] raw = e.getBlockBytes();
|
||||
byte[] id = e.getDataItemId();
|
||||
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
|
||||
try {
|
||||
TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id);
|
||||
candidateBlocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis());
|
||||
published++;
|
||||
log.debug("Turbo published key-rotation DataItem {} chain={} block={}",
|
||||
result.dataItemId(), e.getCandidateBlockchainName(), e.getBlockNumber());
|
||||
} catch (Exception ex) {
|
||||
if (firstFailure == null) firstFailure = ex;
|
||||
log.warn("Turbo key-rotation publish failed: chain={} block={} bytes={} error={}",
|
||||
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length, ex.getMessage());
|
||||
}
|
||||
}
|
||||
if (published > 0) log.info("Published {} SHiNE key-rotation DataItems through Turbo", published);
|
||||
if (published == 0 && firstFailure != null) throw firstFailure;
|
||||
return published;
|
||||
}
|
||||
|
||||
private int publishTurbo(List<BlockEntry> candidates) throws Exception {
|
||||
@@ -57,6 +104,40 @@ public final class ArweaveBlockPublisherService {
|
||||
return published;
|
||||
}
|
||||
|
||||
private int publishDirectArweaveRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
|
||||
List<byte[]> items = new ArrayList<>();
|
||||
List<byte[]> ids = new ArrayList<>();
|
||||
long estimated = 32;
|
||||
for (KeyRotationCandidateBlockEntry e : candidates) {
|
||||
byte[] raw = e.getBlockBytes();
|
||||
byte[] id = e.getDataItemId();
|
||||
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
|
||||
long next = estimated + 64L + raw.length;
|
||||
if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break;
|
||||
if (next > cfg.publishMaxBundleBytes()) {
|
||||
log.error("One key-rotation DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}",
|
||||
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length);
|
||||
continue;
|
||||
}
|
||||
items.add(raw);
|
||||
ids.add(id);
|
||||
estimated = next;
|
||||
}
|
||||
if (items.isEmpty()) return 0;
|
||||
|
||||
byte[] bundle = Ans104Bundle.write(items);
|
||||
List<ArweaveL1Uploader.Tag> rootTags = List.of(
|
||||
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
|
||||
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
|
||||
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0")
|
||||
);
|
||||
ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags);
|
||||
candidateBlocksDAO.markArweavePublished(ids, System.currentTimeMillis());
|
||||
log.info("Published {} SHiNE key-rotation DataItems in direct Arweave root tx {} (bundle={} bytes)",
|
||||
items.size(), result.txId(), bundle.length);
|
||||
return items.size();
|
||||
}
|
||||
|
||||
private int publishDirectArweave(List<BlockEntry> candidates) throws Exception {
|
||||
List<byte[]> items = new ArrayList<>();
|
||||
List<byte[]> ids = new ArrayList<>();
|
||||
|
||||
@@ -25,6 +25,10 @@ public final class BodyRecordParser {
|
||||
&& (v == (CreateChannelBody.VER & 0xFFFF))) {
|
||||
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(
|
||||
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). */
|
||||
public static final short HEADER_COMPAT = 0;
|
||||
public static final short TECH_CREATE_CHANNEL = 1;
|
||||
/** Новый fork/ротация ключей: ссылка на родительскую цепочку и точку отката. */
|
||||
public static final short TECH_FORK = 2;
|
||||
|
||||
/* ===================== TEXT (msg_type=1) ===================== */
|
||||
|
||||
@@ -53,7 +55,7 @@ public final class MsgSubType {
|
||||
|
||||
/**
|
||||
* REPLY — ответ на сообщение.
|
||||
* НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32).
|
||||
* НЕ в линии. Имеет target (toLogin + blockNumber + hash32).
|
||||
* Может указывать на чужой блокчейн/чужую линию/чужой канал.
|
||||
*/
|
||||
public static final short TEXT_REPLY = 20;
|
||||
@@ -69,7 +71,7 @@ public final class MsgSubType {
|
||||
|
||||
/**
|
||||
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
|
||||
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
||||
* Имеет hasLine + target (toLogin + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
||||
*/
|
||||
public static final short TEXT_REPOST = 50;
|
||||
|
||||
|
||||
+18
-17
@@ -1,31 +1,32 @@
|
||||
package blockchain.body;
|
||||
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
/**
|
||||
* BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля).
|
||||
* BodyHasTarget — дополнительный интерфейс для body, которые ссылаются на логическую цель.
|
||||
*
|
||||
* Новое правило:
|
||||
* - toLogin НЕ храним в байтах блока.
|
||||
* - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN".
|
||||
* Актуальное правило target:
|
||||
* - в подписываемых байтах хранится login цели;
|
||||
* - номер fork/blockchainName в target не хранится;
|
||||
* - идентичность цели задаётся как login + blockNumber + blockHash.
|
||||
*
|
||||
* Все методы могут возвращать null.
|
||||
* Это позволяет одной и той же логической записи сохранять ссылки после fork,
|
||||
* если её номер и SHA-256 hash остались прежними.
|
||||
*/
|
||||
public interface BodyHasTarget {
|
||||
|
||||
/** login цели (nullable). Вычисляется из toBchName(). */
|
||||
default String toLogin() {
|
||||
String bch = toBchName();
|
||||
if (bch == null) return null;
|
||||
return BlockchainNameUtil.loginFromBlockchainName(bch);
|
||||
}
|
||||
/** login цели. Актуальные runtime-body обязаны переопределять этот метод. */
|
||||
default String toLogin() { return null; }
|
||||
|
||||
/** 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();
|
||||
|
||||
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
|
||||
byte[] toBlockHashBytes();
|
||||
}
|
||||
}
|
||||
|
||||
+61
-158
@@ -1,7 +1,6 @@
|
||||
package blockchain.body;
|
||||
|
||||
import blockchain.MsgSubType;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
@@ -12,96 +11,38 @@ import java.util.Objects;
|
||||
/**
|
||||
* ConnectionBody — type=3, ver=1 (в заголовке блока).
|
||||
*
|
||||
* subType (в заголовке блока) как MsgSubType:
|
||||
* FRIEND=10, UNFRIEND=11
|
||||
* CONTACT=20, UNCONTACT=21
|
||||
* FOLLOW=30, UNFOLLOW=31
|
||||
* SPOUSE=40, UNSPOUSE=41
|
||||
* PARENT=50, UNPARENT=51
|
||||
* CHILD=52, UNCHILD=53
|
||||
* SIBLING=54, UNSIBLING=55
|
||||
* FRIEND=14, UNFRIEND=15
|
||||
* KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI)
|
||||
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
|
||||
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
|
||||
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
|
||||
*
|
||||
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
||||
* bodyBytes (BigEndian):
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
*
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [1] toLoginLen (uint8)
|
||||
* [N] toLogin UTF-8
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [32] toBlockHash32 (raw 32 bytes)
|
||||
*
|
||||
* toLogin вычисляется автоматически из toBlockchainName:
|
||||
* toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName)
|
||||
* Номер fork/blockchainName в target не хранится.
|
||||
*/
|
||||
|
||||
/**
|
||||
* =========================================================================
|
||||
* ПРАВИЛО 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 static final short TYPE = 3;
|
||||
public static final short VER = 1;
|
||||
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public final short subType; // из header
|
||||
public final short version; // из header
|
||||
public final short subType;
|
||||
public final short version;
|
||||
|
||||
// line
|
||||
public final int lineCode;
|
||||
public final int prevLineNumber;
|
||||
public final byte[] prevLineHash32;
|
||||
public final int thisLineNumber;
|
||||
|
||||
// payload
|
||||
public final String toBlockchainName;
|
||||
public final String toLogin;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32;
|
||||
|
||||
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
@@ -111,34 +52,25 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
if (!isValidSubType(this.subType)) {
|
||||
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
|
||||
}
|
||||
|
||||
// минимум:
|
||||
// lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32]
|
||||
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) {
|
||||
if (bodyBytes.length < 4 + 4 + 32 + 4 + 1 + 1 + 4 + 32) {
|
||||
throw new IllegalArgumentException("ConnectionBody too short");
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
this.lineCode = bb.getInt();
|
||||
|
||||
this.prevLineNumber = bb.getInt();
|
||||
|
||||
this.prevLineHash32 = new byte[32];
|
||||
bb.get(this.prevLineHash32);
|
||||
|
||||
this.thisLineNumber = bb.getInt();
|
||||
|
||||
int bchLen = Byte.toUnsignedInt(bb.get());
|
||||
if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
||||
if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
|
||||
|
||||
byte[] bchBytes = new byte[bchLen];
|
||||
bb.get(bchBytes);
|
||||
this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8);
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
|
||||
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
|
||||
@@ -150,39 +82,68 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
||||
byte[] prevLineHash32,
|
||||
int thisLineNumber,
|
||||
short subType,
|
||||
String toBlockchainName,
|
||||
String toLogin,
|
||||
int toBlockGlobalNumber,
|
||||
byte[] toBlockHash32) {
|
||||
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
||||
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
// Железное правило формата: bchName -> login + "-NNN"
|
||||
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) {
|
||||
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
|
||||
}
|
||||
|
||||
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.lineCode = lineCode;
|
||||
|
||||
this.prevLineNumber = prevLineNumber;
|
||||
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
this.prevLineHash32 = prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32);
|
||||
this.thisLineNumber = thisLineNumber;
|
||||
|
||||
this.subType = subType;
|
||||
this.version = VER;
|
||||
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
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) {
|
||||
int v = st & 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);
|
||||
}
|
||||
|
||||
@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) {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ====================== BodyHasLine ====================== */
|
||||
@Override public int lineCode() { return lineCode; }
|
||||
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
|
||||
@Override public int lineSeq() { return thisLineNumber; }
|
||||
|
||||
/* ====================== BodyHasTarget ===================== */
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public String toLogin() { return toLogin; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@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
|
||||
* 2 = UNLIKE
|
||||
*
|
||||
* bodyBytes (BigEndian), новый формат:
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* bodyBytes (BigEndian):
|
||||
* [1] toLoginLen (uint8)
|
||||
* [N] toLogin UTF-8
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [32] toBlockHash32 (raw 32 bytes)
|
||||
*
|
||||
* Номер fork/blockchainName в target не хранится.
|
||||
* ЛИНИИ НЕТ.
|
||||
*/
|
||||
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 final short subType; // из header
|
||||
public final short version; // из header
|
||||
public final short subType;
|
||||
public final short version;
|
||||
|
||||
public final String toBlockchainName;
|
||||
public final String toLogin;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32;
|
||||
|
||||
@@ -49,40 +50,37 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
||||
if (!isSupportedSubType(this.subType)) {
|
||||
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
|
||||
}
|
||||
|
||||
// минимум: nameLen[1]+name[1]+global[4]+hash[32]
|
||||
if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
||||
if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
|
||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||
}
|
||||
|
||||
public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
public ReactionBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
|
||||
this.subType = MsgSubType.REACTION_LIKE;
|
||||
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 (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
}
|
||||
@@ -91,37 +89,30 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
||||
public ReactionBody check() {
|
||||
if (!isSupportedSubType(subType))
|
||||
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
||||
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
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[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameBytes.length == 0 || nameBytes.length > 255)
|
||||
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
||||
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||
if (loginBytes.length == 0 || loginBytes.length > 255)
|
||||
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
|
||||
|
||||
int cap = 1 + nameBytes.length + 4 + 32;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) nameBytes.length);
|
||||
bb.put(nameBytes);
|
||||
ByteBuffer bb = ByteBuffer.allocate(1 + loginBytes.length + 4 + 32).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) loginBytes.length);
|
||||
bb.put(loginBytes);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
/* ====================== BodyHasTarget ====================== */
|
||||
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public String toLogin() { return toLogin; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
|
||||
+24
-35
@@ -13,11 +13,11 @@ import java.util.Objects;
|
||||
/**
|
||||
* StatusActionBody — type=5, ver=1.
|
||||
*
|
||||
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
|
||||
* Все STATUS_ACTION имеют target на конкретный логический блок и опциональный текст-пояснение.
|
||||
*
|
||||
* Формат bodyBytes (BigEndian):
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [1] toLoginLen (uint8)
|
||||
* [N] toLogin UTF-8
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
@@ -31,7 +31,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
public final short subType;
|
||||
public final short version;
|
||||
public final String toBlockchainName;
|
||||
public final String toLogin;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32;
|
||||
public final String message;
|
||||
@@ -51,33 +51,32 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toLoginLen is 0");
|
||||
ensureMin(bb, loginLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
|
||||
|
||||
ensureNoTail(bb, "StatusActionBody");
|
||||
}
|
||||
|
||||
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
public StatusActionBody(short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = VER;
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
this.message = message;
|
||||
@@ -85,16 +84,10 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
@Override
|
||||
public StatusActionBody check() {
|
||||
if (!isSupportedSubType(subType)) {
|
||||
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
||||
}
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
|
||||
}
|
||||
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
||||
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("STATUS_ACTION toLogin is blank");
|
||||
if (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 (message == null) throw new IllegalArgumentException("message is null");
|
||||
return this;
|
||||
}
|
||||
@@ -104,15 +97,14 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255");
|
||||
}
|
||||
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||
if (loginUtf8.length == 0 || loginUtf8.length > 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);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
bb.put((byte) loginUtf8.length);
|
||||
bb.put(loginUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
@@ -120,7 +112,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public String toLogin() { return toLogin; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
@@ -141,14 +133,11 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len == 0) return "";
|
||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||
|
||||
byte[] bytes = new byte[len];
|
||||
bb.get(bytes);
|
||||
|
||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
} catch (CharacterCodingException e) {
|
||||
|
||||
+36
-62
@@ -33,23 +33,13 @@ import java.util.Objects;
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
*
|
||||
* EDIT_POST:
|
||||
* EDIT_POST / REPOST:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
*
|
||||
* REPOST:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [1] toLoginLen (uint8)
|
||||
* [N] toLogin UTF-8
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
@@ -72,7 +62,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
public final int thisLineNumber;
|
||||
|
||||
// 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 byte[] toBlockHash32; // nullable для POST
|
||||
|
||||
@@ -116,29 +106,19 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
|
||||
this.thisLineNumber = bb.getInt();
|
||||
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
// нужен target
|
||||
ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target");
|
||||
int tgtNum = bb.getInt();
|
||||
byte[] tgtHash = new byte[32];
|
||||
bb.get(tgtHash);
|
||||
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = tgtNum;
|
||||
this.toBlockHash32 = tgtHash;
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPOST missing target");
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("REPOST toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPOST payload too short");
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TEXT target missing");
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||
ensureMin(bb, loginLen + 4 + 32 + 2, "TEXT target payload too short");
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
} else {
|
||||
this.toBlockchainName = null;
|
||||
this.toLogin = null;
|
||||
this.toBlockGlobalNumber = null;
|
||||
this.toBlockHash32 = null;
|
||||
}
|
||||
@@ -158,7 +138,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
short subType,
|
||||
Integer toBlockGlobalNumber,
|
||||
byte[] toBlockHash32,
|
||||
String toBlockchainName,
|
||||
String toLogin,
|
||||
String message) {
|
||||
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
@@ -189,27 +169,29 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
this.thisLineNumber = thisLineNumber;
|
||||
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = null;
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
} else {
|
||||
this.toBlockchainName = null;
|
||||
this.toLogin = null;
|
||||
this.toBlockGlobalNumber = null;
|
||||
this.toBlockHash32 = null;
|
||||
}
|
||||
@@ -240,13 +222,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
||||
if (toBlockchainName != null)
|
||||
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName");
|
||||
if (toLogin == null || toLogin.isBlank())
|
||||
throw new IllegalArgumentException("EDIT_POST toLogin is blank");
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("REPOST message is blank");
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
throw new IllegalArgumentException("REPOST toBlockchainName is blank");
|
||||
if (toLogin == null || toLogin.isBlank())
|
||||
throw new IllegalArgumentException("REPOST toLogin is blank");
|
||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
@@ -259,7 +241,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
} else if (message == 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");
|
||||
}
|
||||
|
||||
@@ -284,19 +266,14 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
// EDIT_POST
|
||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
|
||||
cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
|
||||
} else {
|
||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName");
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (toLogin == null) throw new IllegalArgumentException("target missing toLogin");
|
||||
byte[] nameUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||
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 (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32");
|
||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("target missing toBlockGlobalNumber");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -307,13 +284,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||
bb.putInt(thisLineNumber);
|
||||
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||
bb.put((byte) loginUtf8.length);
|
||||
bb.put(loginUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
}
|
||||
@@ -331,7 +305,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
@Override public int lineSeq() { return thisLineNumber; }
|
||||
|
||||
/* ====================== BodyHasTarget ===================== */
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public String toLogin() { return toLogin; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
|
||||
+25
-38
@@ -11,14 +11,11 @@ import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* TextRatingBody — type=1, ver=1.
|
||||
*
|
||||
* subType:
|
||||
* - RATING (30)
|
||||
* TextRatingBody — type=1, ver=1, subType=30.
|
||||
*
|
||||
* Формат bodyBytes (BigEndian):
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [1] toLoginLen (uint8)
|
||||
* [N] toLogin UTF-8
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
@@ -32,7 +29,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
public final short subType;
|
||||
public final short version;
|
||||
public final String toBlockchainName;
|
||||
public final String toLogin;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32;
|
||||
public final String message;
|
||||
@@ -52,33 +49,32 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0) throw new IllegalArgumentException("RATING toLoginLen is 0");
|
||||
ensureMin(bb, loginLen + 4 + 32 + 2, "RATING payload too short");
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
|
||||
|
||||
ensureNoTail(bb, "TextRatingBody");
|
||||
}
|
||||
|
||||
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
public TextRatingBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == 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 (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||
|
||||
this.subType = MsgSubType.TEXT_RATING;
|
||||
this.version = VER;
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
this.message = message;
|
||||
@@ -86,16 +82,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
@Override
|
||||
public TextRatingBody check() {
|
||||
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
|
||||
}
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
||||
throw new IllegalArgumentException("RATING toBlockchainName is blank");
|
||||
}
|
||||
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF))
|
||||
throw new IllegalArgumentException("Bad RATING subType");
|
||||
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("RATING toLogin is blank");
|
||||
if (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 (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||
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 > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
|
||||
}
|
||||
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||
if (loginUtf8.length == 0 || loginUtf8.length > 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);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
bb.put((byte) loginUtf8.length);
|
||||
bb.put(loginUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
@@ -122,7 +112,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public String toLogin() { return toLogin; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
@@ -130,14 +120,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
|
||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||
|
||||
byte[] bytes = new byte[len];
|
||||
bb.get(bytes);
|
||||
|
||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||
|
||||
+36
-108
@@ -17,84 +17,56 @@ import java.util.Objects;
|
||||
* - REPLY (20)
|
||||
* - EDIT_REPLY (21)
|
||||
*
|
||||
* Форматы bodyBytes (BigEndian):
|
||||
*
|
||||
* REPLY:
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* Оба subtype используют одинаковый target-формат:
|
||||
* [1] toLoginLen (uint8)
|
||||
* [N] toLogin UTF-8
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [M] text UTF-8
|
||||
*
|
||||
* EDIT_REPLY:
|
||||
* [4] toBlockGlobalNumber
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
* Для EDIT_REPLY text может быть пустым (логическое удаление).
|
||||
*/
|
||||
public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
public static final short TYPE = 1;
|
||||
public static final short VER = 1;
|
||||
|
||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||
|
||||
public final short subType; // из header
|
||||
public final short version; // (=1)
|
||||
public final short subType;
|
||||
public final short version;
|
||||
|
||||
// target
|
||||
public final String toBlockchainName; // nullable для EDIT_REPLY
|
||||
public final String toLogin;
|
||||
public final int toBlockGlobalNumber;
|
||||
public final byte[] toBlockHash32; // 32
|
||||
|
||||
// text
|
||||
public final byte[] toBlockHash32;
|
||||
public final String message;
|
||||
|
||||
public TextReplyBody(short subType, short version, byte[] bodyBytes) {
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
|
||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||
}
|
||||
|
||||
int st = this.subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TextReplyBody too short");
|
||||
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
// минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2]
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");
|
||||
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||
if (loginLen <= 0) throw new IllegalArgumentException("TextReplyBody toLoginLen is 0");
|
||||
ensureMin(bb, loginLen + 4 + 32 + 2, "TextReplyBody payload too short");
|
||||
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");
|
||||
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
|
||||
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);
|
||||
}
|
||||
byte[] loginBytes = new byte[loginLen];
|
||||
bb.get(loginBytes);
|
||||
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||
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));
|
||||
ensureNoTail(bb, "TextReplyBody");
|
||||
@@ -103,11 +75,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
public TextReplyBody(short subType,
|
||||
int toBlockGlobalNumber,
|
||||
byte[] toBlockHash32,
|
||||
String toBlockchainName,
|
||||
String toLogin,
|
||||
String message) {
|
||||
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||
|
||||
int st = subType & 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()) {
|
||||
throw new IllegalArgumentException("message is blank");
|
||||
}
|
||||
|
||||
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
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.version = VER;
|
||||
|
||||
this.toLogin = toLogin;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
@@ -143,23 +105,14 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
|
||||
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
||||
|
||||
if (toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
|
||||
if (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");
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
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");
|
||||
if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank");
|
||||
} else if (message == null) {
|
||||
throw new IllegalArgumentException("EDIT_REPLY message is null");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -167,47 +120,27 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
public byte[] toBytes() {
|
||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
|
||||
throw new IllegalArgumentException("Text payload is empty");
|
||||
}
|
||||
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
||||
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||
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);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255)
|
||||
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");
|
||||
|
||||
int cap = 1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
bb.put(msgUtf8);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
// EDIT_REPLY
|
||||
int cap = (4 + 32) + 2 + msgUtf8.length;
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||
.order(ByteOrder.BIG_ENDIAN);
|
||||
bb.put((byte) loginUtf8.length);
|
||||
bb.put(loginUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
bb.put(msgUtf8);
|
||||
|
||||
return bb.array();
|
||||
}
|
||||
|
||||
/* ====================== BodyHasTarget ====================== */
|
||||
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public String toLogin() { return toLogin; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@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);
|
||||
}
|
||||
|
||||
/* ====================== helpers ====================== */
|
||||
|
||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len == 0) {
|
||||
@@ -224,14 +155,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
throw new IllegalArgumentException(fieldName + " is empty");
|
||||
}
|
||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||
|
||||
byte[] bytes = new byte[len];
|
||||
bb.get(bytes);
|
||||
|
||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||
|
||||
try {
|
||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
if (!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_24 = 24;
|
||||
public static final int SCHEMA_VERSION_25 = 25;
|
||||
public static final int SCHEMA_VERSION_26 = 26;
|
||||
public static final int SCHEMA_VERSION_27 = 27;
|
||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.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_V24_RESOURCE = "postgres/migration_v24.sql";
|
||||
public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql";
|
||||
public static final String POSTGRES_MIGRATION_V26_RESOURCE = "postgres/migration_v26.sql";
|
||||
public static final String POSTGRES_MIGRATION_V27_RESOURCE = "postgres/migration_v27.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -236,6 +240,14 @@ public final class DatabaseInitializer {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_25;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_26) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V26_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_26;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_27) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V27_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_27;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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 {
|
||||
String sql = """
|
||||
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 {
|
||||
String sql = """
|
||||
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)
|
||||
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
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
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)
|
||||
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)
|
||||
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;
|
||||
|
||||
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_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_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.entyties.Net_GetUser_Request;
|
||||
@@ -191,6 +208,16 @@ public final class JsonHandlerRegistry {
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", new Net_AddBlock_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 ---
|
||||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||||
@@ -283,6 +310,16 @@ public final class JsonHandlerRegistry {
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", Net_AddBlock_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 ---
|
||||
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);
|
||||
lock.lock();
|
||||
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(
|
||||
blockchainName,
|
||||
req.getBlockNumber(), // старое поле, пока оставляем
|
||||
@@ -152,6 +164,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
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) {
|
||||
if (code == null) return "Ошибка добавления блока";
|
||||
return switch (code) {
|
||||
@@ -184,6 +213,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
|
||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||
case "key_rotation_in_progress" -> "Сейчас выполняется смена ключей; обычные новые блоки временно запрещены";
|
||||
case "key_rotation_check_failed" -> "Не удалось проверить состояние смены ключей";
|
||||
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";
|
||||
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
|
||||
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
|
||||
WHERE login = ? AND msg_type = ?
|
||||
ORDER BY block_number
|
||||
@@ -132,9 +133,9 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
||||
item.setLikedByMe(false);
|
||||
item.setRepliesCount(0);
|
||||
item.setRatingsCount(0);
|
||||
item.setTargetBlockchainName(statusBody.toBchName());
|
||||
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber());
|
||||
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes()));
|
||||
item.setTargetBlockchainName(rs.getString("to_bch_name"));
|
||||
item.setTargetBlockNumber((Integer) rs.getObject("to_block_number"));
|
||||
item.setTargetBlockHash(ChannelsReadSupport.toHex(rs.getBytes("to_block_hash")));
|
||||
|
||||
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
|
||||
c,
|
||||
@@ -148,7 +149,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
||||
item.setVersionsTotal(versions.size());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -215,7 +217,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
||||
return;
|
||||
}
|
||||
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
|
||||
WHERE bch_name = ? AND block_number = ?
|
||||
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 formatMinor = raw[6] & 0xFF;
|
||||
if (formatMajor != 1 || formatMinor != 2) {
|
||||
if (formatMajor != 1) {
|
||||
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(
|
||||
|
||||
+95
@@ -1,5 +1,6 @@
|
||||
package sync.storage.postgres;
|
||||
|
||||
import sync.util.Base58Util;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
@@ -272,6 +273,11 @@ public final class PostgresStorageRepository
|
||||
snapshots
|
||||
);
|
||||
|
||||
reconcileConfirmedKeyRotations(
|
||||
connection,
|
||||
snapshots
|
||||
);
|
||||
|
||||
upsertSyncState(
|
||||
connection,
|
||||
newState
|
||||
@@ -305,6 +311,11 @@ public final class PostgresStorageRepository
|
||||
snapshots
|
||||
);
|
||||
|
||||
reconcileConfirmedKeyRotations(
|
||||
connection,
|
||||
snapshots
|
||||
);
|
||||
|
||||
upsertSyncState(
|
||||
connection,
|
||||
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(
|
||||
PreparedStatement statement,
|
||||
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;
|
||||
|
||||
import server.keyrotation.KeyRotationRebuildScheduler;
|
||||
|
||||
import org.eclipse.jetty.server.Server;
|
||||
import org.eclipse.jetty.servlet.ServletContextHandler;
|
||||
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.
|
||||
ArweaveBlockPublisherScheduler.startOrLog();
|
||||
ArweaveBlockSyncScheduler.startOrLog();
|
||||
KeyRotationRebuildScheduler.startOrLog();
|
||||
|
||||
// ============================================================
|
||||
// 2) Запуск Jetty WS
|
||||
|
||||
@@ -4,7 +4,8 @@ import blockchain.MsgSubType;
|
||||
import blockchain.body.ConnectionBody;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import blockchain.body.HeaderBody;
|
||||
import blockchain.body.TextBody;
|
||||
import blockchain.body.TextLineBody;
|
||||
import blockchain.body.TextReplyBody;
|
||||
import shine.db.DbController;
|
||||
import test.it.blockchain.AddBlockSender;
|
||||
import test.it.blockchain.ChainState;
|
||||
@@ -27,7 +28,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
* CONNECTION (type=3):
|
||||
* - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber)
|
||||
* - всегда имеет target:
|
||||
* toBlockchainName + toBlockGlobalNumber + toBlockHash32
|
||||
* toLogin + toBlockGlobalNumber + toBlockHash32
|
||||
*
|
||||
* Правило target для связей/подписок:
|
||||
* - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0)
|
||||
@@ -88,12 +89,12 @@ public class IT_03_AddBlock_NoAuth {
|
||||
// POST в канал "0"
|
||||
{
|
||||
var ln = st1.nextTextLineByRoot(root0);
|
||||
sender1.send(new TextBody(
|
||||
MsgSubType.TEXT_POST,
|
||||
sender1.send(new TextLineBody(
|
||||
root0,
|
||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||
"U1: story/post in channel 0",
|
||||
null, null, null
|
||||
MsgSubType.TEXT_POST,
|
||||
null, null, null,
|
||||
"U1: story/post in channel 0"
|
||||
), t);
|
||||
}
|
||||
|
||||
@@ -148,12 +149,12 @@ public class IT_03_AddBlock_NoAuth {
|
||||
byte[] newsPost0Hash;
|
||||
{
|
||||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||||
sender1.send(new TextBody(
|
||||
MsgSubType.TEXT_POST,
|
||||
sender1.send(new TextLineBody(
|
||||
newsRootBlock,
|
||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||
"U1: News post #0",
|
||||
null, null, null
|
||||
MsgSubType.TEXT_POST,
|
||||
null, null, null,
|
||||
"U1: News post #0"
|
||||
), t);
|
||||
|
||||
newsPost0Block = st1.lastBlockNumber();
|
||||
@@ -164,26 +165,26 @@ public class IT_03_AddBlock_NoAuth {
|
||||
// POST #1 в канал "News"
|
||||
{
|
||||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||||
sender1.send(new TextBody(
|
||||
MsgSubType.TEXT_POST,
|
||||
sender1.send(new TextLineBody(
|
||||
newsRootBlock,
|
||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||
"U1: News post #1",
|
||||
null, null, null
|
||||
MsgSubType.TEXT_POST,
|
||||
null, null, null,
|
||||
"U1: News post #1"
|
||||
), t);
|
||||
}
|
||||
|
||||
// EDIT_POST (в линии канала) -> target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName)
|
||||
// EDIT_POST -> target на ОРИГИНАЛЬНЫЙ POST по login + number + hash
|
||||
{
|
||||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||||
sender1.send(new TextBody(
|
||||
MsgSubType.TEXT_EDIT_POST,
|
||||
sender1.send(new TextLineBody(
|
||||
newsRootBlock,
|
||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||
"U1: News post #0 (EDIT)",
|
||||
null,
|
||||
MsgSubType.TEXT_EDIT_POST,
|
||||
newsPost0Block,
|
||||
newsPost0Hash
|
||||
newsPost0Hash,
|
||||
u1,
|
||||
"U1: News post #0 (EDIT)"
|
||||
), t);
|
||||
}
|
||||
|
||||
@@ -206,17 +207,17 @@ public class IT_03_AddBlock_NoAuth {
|
||||
|
||||
// 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2)
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW,
|
||||
bch2, u2HeaderBlock, u2HeaderHash,
|
||||
u2, u2HeaderBlock, u2HeaderHash,
|
||||
"U1 follows U2 (target=U2 HEADER)", t);
|
||||
|
||||
// 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1)
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
||||
bch1, newsRootBlock, newsRootHash,
|
||||
u1, newsRootBlock, newsRootHash,
|
||||
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
||||
|
||||
// 3) U2 подписался на второй канал U1 "Updates"
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
||||
bch1, updatesRootBlock, updatesRootHash,
|
||||
u1, updatesRootBlock, updatesRootHash,
|
||||
"U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t);
|
||||
|
||||
assertEquals(2, countConnectionsByOwner(u2, u1),
|
||||
@@ -230,30 +231,31 @@ public class IT_03_AddBlock_NoAuth {
|
||||
|
||||
// 4) FRIEND взаимно (на HEADER)
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||
bch2, u2HeaderBlock, u2HeaderHash,
|
||||
u2, u2HeaderBlock, u2HeaderHash,
|
||||
"U1 -> U2: FRIEND", t);
|
||||
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||
bch1, u1HeaderBlock, u1HeaderHash,
|
||||
u1, u1HeaderBlock, u1HeaderHash,
|
||||
"U2 -> U1: FRIEND", t);
|
||||
|
||||
// 5) CONTACT несколько
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
||||
bch2, u2HeaderBlock, u2HeaderHash,
|
||||
u2, u2HeaderBlock, u2HeaderHash,
|
||||
"U1 -> U2: CONTACT", t);
|
||||
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT,
|
||||
bch1, u1HeaderBlock, u1HeaderHash,
|
||||
u1, u1HeaderBlock, u1HeaderHash,
|
||||
"U2 -> U1: CONTACT", t);
|
||||
|
||||
// =========================
|
||||
// USER2 REPLY (ответ в чужой канал)
|
||||
// =========================
|
||||
{
|
||||
sender2.send(TextBody.newReply(
|
||||
bch1,
|
||||
sender2.send(new TextReplyBody(
|
||||
MsgSubType.TEXT_REPLY,
|
||||
newsPost0Block,
|
||||
newsPost0Hash,
|
||||
u1,
|
||||
"U2: reply to U1 News post #0 (cross-chain)"
|
||||
), t);
|
||||
}
|
||||
@@ -273,12 +275,12 @@ public class IT_03_AddBlock_NoAuth {
|
||||
|
||||
// U1 -> U3: CONTACT
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
||||
bch3, u3HeaderBlock, u3HeaderHash,
|
||||
u3, u3HeaderBlock, u3HeaderHash,
|
||||
"U1 -> U3: CONTACT", t);
|
||||
|
||||
// 6) U2 отписывается только от News
|
||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW,
|
||||
bch1, newsRootBlock, newsRootHash,
|
||||
u1, newsRootBlock, newsRootHash,
|
||||
"U2 unfollows U1 channel 'News'", t);
|
||||
|
||||
assertEquals(1, countConnectionsByOwner(u2, u1),
|
||||
@@ -292,7 +294,7 @@ public class IT_03_AddBlock_NoAuth {
|
||||
|
||||
// 7) U1 убирает U2 из контактов (UNCONTACT)
|
||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
|
||||
bch2, u2HeaderBlock, u2HeaderHash,
|
||||
u2, u2HeaderBlock, u2HeaderHash,
|
||||
"U1 -> U2: UNCONTACT", t);
|
||||
|
||||
r.ok("IT_03 сценарий блоков + connections выполнен");
|
||||
@@ -313,7 +315,7 @@ public class IT_03_AddBlock_NoAuth {
|
||||
private static void sendConnection(AddBlockSender sender,
|
||||
ChainState st,
|
||||
short subType,
|
||||
String toBlockchainName,
|
||||
String toLogin,
|
||||
int toBlockNumber,
|
||||
byte[] toBlockHash32,
|
||||
String logNote,
|
||||
@@ -321,7 +323,7 @@ public class IT_03_AddBlock_NoAuth {
|
||||
|
||||
if (TestConfig.DEBUG()) {
|
||||
TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF)
|
||||
+ " to=" + toBlockchainName
|
||||
+ " to=" + toLogin
|
||||
+ " targetBlock=" + toBlockNumber
|
||||
+ " note=" + logNote);
|
||||
}
|
||||
@@ -330,14 +332,14 @@ public class IT_03_AddBlock_NoAuth {
|
||||
|
||||
// КОНСТРУКТОР ИЗ ТВОЕГО КОДА:
|
||||
// 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(
|
||||
0, // lineCode для connection линии
|
||||
ln.prevLineNumber,
|
||||
ln.prevLineHash32,
|
||||
ln.thisLineNumber,
|
||||
subType,
|
||||
toBlockchainName,
|
||||
toLogin,
|
||||
toBlockNumber,
|
||||
toBlockHash32
|
||||
), timeout);
|
||||
|
||||
@@ -151,7 +151,7 @@ public final class SeedDataPopulationHelper {
|
||||
line.prevLineHash32,
|
||||
line.thisLineNumber,
|
||||
relationSubType,
|
||||
bch(to),
|
||||
to,
|
||||
0,
|
||||
targetHeaderHash
|
||||
), timeout);
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Миграция 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_prev_hash` — нарушена SHiNE hash chain;
|
||||
- `bad_block_bytes` — DataItem/Frame не парсится.
|
||||
- `key_rotation_in_progress` (HTTP/status `423`) — для пользователя уже запущена смена ключей; обычный `AddBlock` временно запрещён до завершения/отмены ротации;
|
||||
- `key_rotation_check_failed` — сервер не смог проверить локальный rotation status и отклонил запись fail-closed.
|
||||
|
||||
## Storage/publish
|
||||
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
||||
| `AddBlock` | `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 без криптографической проверки |
|
||||
| `Ping` | `05_Technical_Requests_API.md` | keep-alive |
|
||||
| `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=2` — REACTION: LIKE/UNLIKE.
|
||||
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
|
||||
|
||||
@@ -12,7 +12,43 @@ TECH-тип покрывает системные записи цепочки.
|
||||
- создание нового канала;
|
||||
- хранит 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`
|
||||
- ответ на сообщение;
|
||||
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
|
||||
- target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
|
||||
|
||||
4. `subType=21` — `TEXT_EDIT_REPLY`
|
||||
- редактирование ответа;
|
||||
@@ -23,7 +23,7 @@ TEXT-тип хранит сообщения, материалы и редакт
|
||||
|
||||
5. `subType=30` — `TEXT_RATING`
|
||||
- target-based отзыв на конкретный блок;
|
||||
- содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
|
||||
- содержит target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
|
||||
- не является сообщением линии канала.
|
||||
|
||||
6. `subType=50` — `TEXT_REPOST`
|
||||
@@ -57,6 +57,20 @@ TEXT-тип хранит сообщения, материалы и редакт
|
||||
|
||||
Подробная спецификация: [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_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
||||
|
||||
@@ -4,11 +4,25 @@
|
||||
|
||||
1. `subType=1` — `REACTION_LIKE`
|
||||
- лайк на целевой блок;
|
||||
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||
- хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||
2. `subType=2` — `REACTION_UNLIKE`
|
||||
- снятие лайка с целевого блока;
|
||||
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||
- хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||
|
||||
## Назначение
|
||||
|
||||
- реакция на текстовые сообщения (и потенциально другие 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
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
Все `STATUS_ACTION` используют один и тот же бинарный body-формат:
|
||||
|
||||
```text
|
||||
[1] toBlockchainNameLen (uint8)
|
||||
[N] toBlockchainName UTF-8
|
||||
[1] toLoginLen (uint8)
|
||||
[N] toLogin UTF-8
|
||||
[4] toBlockGlobalNumber
|
||||
[32] toBlockHash32
|
||||
[2] textLenBytes (uint16)
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
Где:
|
||||
|
||||
- `toBlockchainName` — блокчейн, в котором находится целевой материал;
|
||||
- `toLogin` — login владельца целевого блока; номер fork в target не хранится;
|
||||
- `toBlockGlobalNumber` — номер целевого блока;
|
||||
- `toBlockHash32` — хэш целевого блока;
|
||||
- `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
|
||||
|
||||
+19
-19
@@ -2,10 +2,10 @@
|
||||
|
||||
> **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.**
|
||||
> Этот файл описывает, как из пароля получается секрет и как из секрета выводятся
|
||||
> все ключи (root, blockchain, device/Solana, homeserver) — формулами, байт-в-байт.
|
||||
> все ключи (root, blockchain, client, homeserver) — формулами, байт-в-байт.
|
||||
> Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула
|
||||
> ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь
|
||||
> dev-ключ ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
|
||||
> blockchain key ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
|
||||
> Роли и назначение ключей описаны отдельно в `docs/Keys/README.md` (архитектура).
|
||||
> Здесь — только механика. Документ намеренно краткий.
|
||||
|
||||
@@ -55,29 +55,29 @@ seed(32) = SHA-256(material)
|
||||
| Ключ | Суффикс | Назначение (кратко) |
|
||||
|------|---------|---------------------|
|
||||
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
|
||||
| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). |
|
||||
| device / **Solana** | `client.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(clientPub)`. См. §3. |
|
||||
| blockchain | `blockchain.key` | Подписывает пользовательские блоки/ANS-104 DataItem и является текущим Solana-wallet/fee payer. |
|
||||
| client | `client.key` | Общий клиентский ключ для DM/устройств и derivation отдельного Arweave SAWD-кошелька; не является текущим Solana-wallet. |
|
||||
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
|
||||
|
||||
Полные роли каждого ключа — в `docs/Keys/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Solana-ключ
|
||||
## 3. Solana-кошелёк и authority
|
||||
|
||||
Отдельного «солана-ключа» нет. На Solana работают два ключа:
|
||||
Отдельного «solana.key» нет. В актуальной модели Solana-wallet пользователя — **активный `blockchain.key`**:
|
||||
|
||||
- **`client.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(clientPub)`.
|
||||
Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`.
|
||||
Пополнять SOL нужно именно на этот адрес.
|
||||
- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer.
|
||||
- адрес кошелька = `base58(activeBlockchainPub)`;
|
||||
- им оплачивается регистрация (`create_user_pda`) и обычные `update_user_pda`;
|
||||
- обычное обновление PDA авторизуется активным blockchain key;
|
||||
- новый 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
|
||||
(«create/update оплачиваются с `client_key`», «root_key — не fee payer»).
|
||||
`root.key` не является fee payer. Он используется как холодное дополнительное разрешение только для полной ротации root + client + blockchain fork.
|
||||
|
||||
Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью
|
||||
(`create/update`) и через это можно заменить все остальные ключи; `client.key` — это **пополняемый
|
||||
кошелёк и плательщик комиссий**. Полное описание ролей — `docs/Keys/README.md`.
|
||||
`client.key` больше не используется как Solana-wallet/fee payer. Он остаётся клиентским криптографическим ключом (DM/устройства) и входом в отдельный протокол derivation Arweave-кошелька.
|
||||
|
||||
При 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).
|
||||
|
||||
### Solana-ключ / адрес кошелька (UI)
|
||||
- `shine-UI/js/pages/registration-payment-view.js` — `deriveUserWalletAddress`: адрес = `base58(clientPub)` (~113).
|
||||
- `shine-UI/js/pages/topup-view.js` — `clientWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.clientPair`.
|
||||
Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `client.key`, мимо `masterSecret`) удалён.
|
||||
- `shine-UI/js/pages/registration-payment-view.js` — адрес пополнения = `base58(blockchainPub)`.
|
||||
- `shine-UI/js/pages/topup-view.js` — тот же адрес из `preGeneratedKeyBundle.blockchainPair`.
|
||||
- `shine-UI/js/services/solana-wallet-service.js` — текущий пользовательский Solana-wallet загружается из сохранённого `blockchainKey`.
|
||||
|
||||
### Деривация ключей (прошивка ESP32)
|
||||
- `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. Этот документ — источник истины по деривации секрета и ключей.
|
||||
2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа,
|
||||
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь dev-ключ ↔ Solana-адрес —
|
||||
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь blockchain key ↔ Solana-адрес —
|
||||
**обязательно** отражать здесь в том же изменении.
|
||||
3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма.
|
||||
4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости.
|
||||
|
||||
+11
-14
@@ -8,9 +8,9 @@
|
||||
|
||||
В SHiNE у пользователя есть несколько уровней ключей:
|
||||
|
||||
- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `client key`).
|
||||
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
|
||||
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей.
|
||||
- `root key` - холодный recovery-ключ: при полной ротации дополнительно разрешает замену root + client + blockchain fork. Это не кошелёк.
|
||||
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн и текущий Solana-wallet/fee payer пользователя.
|
||||
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков и DM; Solana-wallet им больше не является.
|
||||
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
|
||||
|
||||
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
|
||||
@@ -21,16 +21,13 @@
|
||||
|
||||
Назначение:
|
||||
|
||||
- регистрация пользователя в Solana;
|
||||
- создание и обновление пользовательской PDA-записи;
|
||||
- вызов критически важных Solana-функций;
|
||||
- изменение главных настроек пользователя;
|
||||
- управление остальными ключами;
|
||||
- подтверждение операций, которые должны иметь максимальный уровень доверия.
|
||||
- холодное recovery-разрешение для полной смены ключей;
|
||||
- подтверждение атомарной ротации `root + client + новый blockchain fork`;
|
||||
- восстановительные сценарии повышенного уровня доверия.
|
||||
|
||||
`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`
|
||||
|
||||
@@ -40,7 +37,8 @@
|
||||
|
||||
- подпись записей в персональном блокчейне пользователя;
|
||||
- подтверждение действий, которые должны попасть в SHiNE-блокчейн;
|
||||
- разделение полномочий между главным Solana-ключом и ключом ежедневной записи.
|
||||
- обычные обновления пользовательской PDA;
|
||||
- текущий Solana-wallet/fee payer (`base58(active blockchain public key)`).
|
||||
|
||||
У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи:
|
||||
|
||||
@@ -59,7 +57,6 @@
|
||||
- повседневные входящие и исходящие личные сообщения;
|
||||
- звонки и связанные с ними сообщения;
|
||||
- self-messages, то есть внутренние сообщения пользователя самому себе;
|
||||
- мелкие Solana-расходы на текущие операции;
|
||||
- derivation 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/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
|
||||
|
||||
@@ -584,3 +584,28 @@ SYNC_POLL_INTERVAL_SECONDS=300
|
||||
- server profile считается присутствующим, если опубликован один server address; в старые SQL-поля временно проецируется этот адрес.
|
||||
|
||||
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;
|
||||
- `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 активен;
|
||||
- необязательный `ServerProfileBlock` — в 1.2 ровно один адрес сервера;
|
||||
- необязательный `AccessServersBlock` — в 1.2 максимум один access server.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Замечания к серверу и UI (найдено при редизайне)
|
||||
|
||||
Код по этим пунктам не менялся.
|
||||
|
||||
## Ошибки
|
||||
|
||||
1. **Лента «Подписки» пуста у логинов с заглавными буквами** (например `User_t2_03`).
|
||||
`Net_ListSubscriptionsFeed_Handler.loadFollowedChannels` ищет `connections_state.login = ?` в исходном регистре,
|
||||
а в `connections_state` логин хранится в нижнем регистре (FK на `solana_user_pda_current.normalized_login`).
|
||||
Подписки в базе есть, но `followedChannels` пуст. Отсюда же «подписка не срабатывает» в UI:
|
||||
кнопка не меняется на «Отписаться», канал не появляется в «Подписках».
|
||||
|
||||
2. **Превью в списке чатов — «Сообщение недоступно»**, хотя в самом чате сообщения расшифровываются.
|
||||
`messages-list.js` строит превью по `dialog.lastMessageBlobB64`, `catch` глушит настоящую ошибку.
|
||||
Проверить, не бывает ли так у реальных пользователей (например, если последнее сообщение отправлено с другого устройства).
|
||||
|
||||
3. **Прямой переход по адресу** `/settings`, `/profile`, `/channels/new` при загрузке страницы уводит на «Личные».
|
||||
|
||||
4. **`?localWsPort=` теряется при навигации**: после обновления страницы UI молча подключается к `shineup.me`.
|
||||
|
||||
## Безопасность / конфигурация
|
||||
|
||||
5. **Приватный ключ web-push в репозитории**: `SHiNE-server/src/main/resources/application.properties` → `webpush.vapid.private`.
|
||||
Если он совпадает с продовым — перевыпустить и убрать из git.
|
||||
|
||||
6. По умолчанию сервер представляется логином прода `server.SHiNE.login=shineupme` — при запуске вне прода легко перепутать.
|
||||
|
||||
## На будущее
|
||||
|
||||
7. **Каждое действие перерисовывает страницу целиком** (лайк → шапка на мгновение «Канал / Загрузка…», лента перестраивается),
|
||||
отсюда «дёрганье» при переходах назад. Нужны оптимистичные обновления (лайк/ответ меняют только свой элемент),
|
||||
кэш загруженных лент между переходами, обновление без полной перерисовки.
|
||||
|
||||
8. **Кэш модулей после деплоя.** Страницы в `js/app.js` подключаются с метками `?v=…`, а `js/components/*` и `js/services/*` — без меток,
|
||||
браузер может держать старую версию. Для изменённых страниц метки подняты до `?v=202609260900`;
|
||||
для остальных модулей стоит проверить заголовки кэширования или ввести общую метку версии, как у CSS и `app.js`.
|
||||
|
||||
## Не сделано в редизайне
|
||||
|
||||
- Автопроверка серверов в настройках.
|
||||
- Число подписчиков в шапке канала (нет в API).
|
||||
@@ -90,14 +90,14 @@
|
||||
|
||||
## Кто оплачивает create/update user_pda
|
||||
|
||||
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`.
|
||||
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя или сервера.
|
||||
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
|
||||
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`.
|
||||
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с активного `blockchainKey`.
|
||||
- В UI это означает, что Solana fee payer всегда берётся из текущего активного `blockchain key` пользователя или сервера.
|
||||
- `rootKey` не оплачивает транзакцию; он дополнительно авторизует только полную ротацию ключей.
|
||||
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес текущего `blockchainKey`.
|
||||
|
||||
## Важно
|
||||
|
||||
- `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-классификации логина.
|
||||
- При новом devnet deploy планируется использовать те же program keypair, чтобы `program id` на devnet совпадали с mainnet.
|
||||
|
||||
+13
-1
@@ -6,6 +6,7 @@
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual"
|
||||
/>
|
||||
<meta name="theme-color" content="#12141f" />
|
||||
<base href="/" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<link rel="icon" type="image/jpeg" href="./img/logo.jpg" />
|
||||
@@ -24,6 +25,17 @@
|
||||
document.documentElement.dataset.themeMode = mode;
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
// Своя палитра (см. js/services/theme-service.js) применяется до отрисовки, без вспышки.
|
||||
try {
|
||||
const applied = JSON.parse(localStorage.getItem('shine-ui-palette-applied-v1') || 'null');
|
||||
const colors = applied && applied[resolved];
|
||||
if (colors) {
|
||||
Object.keys(colors).forEach(function (role) {
|
||||
if (/^#[0-9a-f]{6}$/i.test(colors[role])) document.documentElement.style.setProperty('--' + role, colors[role]);
|
||||
});
|
||||
if (colors.accent) document.documentElement.style.setProperty('--focus-ring', colors.accent);
|
||||
}
|
||||
} catch {}
|
||||
}());
|
||||
</script>
|
||||
<script>
|
||||
@@ -33,7 +45,7 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
|
||||
<script>
|
||||
(function attachStylesWithBuildHash() {
|
||||
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/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/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) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
|
||||
+40
-18
@@ -16,6 +16,7 @@ import { initPwaInstallPromptHandling } from './services/pwa-install-service.js'
|
||||
import { initPwaPush } from './services/pwa-push-service.js';
|
||||
import { initCallUiOverlay } from './services/call-ui-service.js';
|
||||
import { showToast } from './services/channels-ux.js';
|
||||
import { KeyRotationClient } from './services/key-rotation-service.js';
|
||||
import {
|
||||
handleCallPushAction,
|
||||
handleIncomingCallInvite,
|
||||
@@ -52,37 +53,39 @@ import {
|
||||
setContacts,
|
||||
} from './state.js';
|
||||
|
||||
import * as startView from './pages/start-view.js?v=202606142105';
|
||||
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
|
||||
import * as registerView from './pages/register-view.js?v=202606201650';
|
||||
import * as registrationFaqView from './pages/registration-faq-view.js?v=202606201650';
|
||||
import * as startView from './pages/start-view.js?v=202609260900';
|
||||
import * as entrySettingsView from './pages/entry-settings-view.js?v=202609260900';
|
||||
import * as registerView from './pages/register-view.js?v=202609260900';
|
||||
import * as registrationFaqView from './pages/registration-faq-view.js?v=202609260900';
|
||||
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
|
||||
import * as registrationKeysView from './pages/registration-keys-view.js';
|
||||
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
|
||||
import * as topupView from './pages/topup-view.js';
|
||||
import * as devnetTopupView from './pages/devnet-topup-view.js';
|
||||
import * as loginView from './pages/login-view.js?v=202606150110';
|
||||
import * as loginView from './pages/login-view.js?v=202609260900';
|
||||
import * as loginCameraView from './pages/login-camera-view.js';
|
||||
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
|
||||
import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
|
||||
import * as keyStorageView from './pages/key-storage-view.js';
|
||||
import * as publicSupportQueueView from './pages/public-support-queue-view.js';
|
||||
|
||||
import * as profileView from './pages/profile-view.js?v=202607150910';
|
||||
import * as profileView from './pages/profile-view.js?v=202609260900';
|
||||
import * as profileEditView from './pages/profile-edit-view.js';
|
||||
import * as profilesView from './pages/profiles-view.js';
|
||||
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||
import * as walletView from './pages/wallet-view.js?v=202609260900';
|
||||
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 developerSettingsView from './pages/developer-settings-view.js';
|
||||
import * as advancedSettingsView from './pages/advanced-settings-view.js';
|
||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202609260900';
|
||||
import * as arweaveUploadsView from './pages/arweave-uploads-view.js';
|
||||
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
|
||||
import * as deviceView from './pages/device-view.js?v=202606131435';
|
||||
import * as deviceView from './pages/device-view.js?v=202609260900';
|
||||
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
|
||||
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
|
||||
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202606180930';
|
||||
import * as clientPairingView from './pages/device-pairing-view.js?v=202609260900';
|
||||
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202609260900';
|
||||
import { applyThemeMode, watchSystemTheme } from './services/theme-service.js';
|
||||
import * as deviceQrView from './pages/device-qr-view.js';
|
||||
import * as deviceCameraView from './pages/device-camera-view.js';
|
||||
@@ -93,21 +96,21 @@ import * as appLogView from './pages/app-log-view.js';
|
||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
||||
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
||||
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
|
||||
import * as messagesList from './pages/messages-list.js?v=202608221218';
|
||||
import * as messagesList from './pages/messages-list.js?v=202609260900';
|
||||
import * as contactSearchView from './pages/contact-search-view.js';
|
||||
import * as chatView from './pages/chat-view.js?v=202608221218';
|
||||
import * as chatView from './pages/chat-view.js?v=202609260900';
|
||||
import * as userProfileView from './pages/user-profile-view.js';
|
||||
import * as userProfileListView from './pages/user-profile-list-view.js';
|
||||
import * as userRelationManageView from './pages/user-relation-manage-view.js';
|
||||
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
||||
import * as channelsList from './pages/channels-list.js?v=202609260900';
|
||||
import * as channelView from './pages/channel-view.js';
|
||||
import * as channelAboutView from './pages/channel-about-view.js';
|
||||
import * as channelDonateView from './pages/channel-donate-view.js';
|
||||
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||
import * as addChannelView from './pages/add-channel-view.js';
|
||||
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
||||
import * as networkView from './pages/network-view.js?v=202608221226';
|
||||
import * as notificationsView from './pages/notifications-view.js?v=202608221354';
|
||||
import * as networkView from './pages/network-view.js?v=202609260900';
|
||||
import * as notificationsView from './pages/notifications-view.js?v=202609260900';
|
||||
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
||||
@@ -142,6 +145,8 @@ const routes = {
|
||||
'profiles-view': profilesView,
|
||||
'wallet-view': walletView,
|
||||
'settings-view': settingsView,
|
||||
'my-blockchain-view': myBlockchainView,
|
||||
'key-rotation-view': keyRotationView,
|
||||
'access-servers-view': accessServersView,
|
||||
'developer-settings-view': developerSettingsView,
|
||||
'advanced-settings-view': advancedSettingsView,
|
||||
@@ -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() {
|
||||
syncTrackedRouteHistory(window.location.pathname || '/');
|
||||
const route = getRoute();
|
||||
@@ -1417,7 +1437,8 @@ function renderApp() {
|
||||
try {
|
||||
const chrome = createChromeController(showAppChrome, page.pageMeta?.shellMode);
|
||||
currentChromeCleanup = () => chrome.dispose();
|
||||
if (showAppChrome) {
|
||||
// В переписке нижняя панель прячется (pageMeta.hideToolbar), чтобы не отнимать место у сообщений.
|
||||
if (showAppChrome && !page.pageMeta?.hideToolbar) {
|
||||
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
|
||||
toolbarHeightObserver?.sync?.();
|
||||
}
|
||||
@@ -1449,7 +1470,7 @@ function refreshToolbarOnly() {
|
||||
&& !(pageId === 'language-view' && !state.session.isAuthorized);
|
||||
|
||||
clearSlot(toolbarEl, '--toolbar-height');
|
||||
if (showAppChrome) {
|
||||
if (showAppChrome && !page.pageMeta?.hideToolbar) {
|
||||
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
@@ -1565,6 +1586,7 @@ async function init() {
|
||||
setSessionAuthorizedHandler(() => {
|
||||
void ensureSessionRuntimeStarted();
|
||||
void processPendingCallPushActionIfPossible();
|
||||
void redirectToActiveKeyRotationIfNeeded();
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
|
||||
@@ -290,7 +290,7 @@ export function openArweaveAttachmentManager({
|
||||
mode = 'attachment',
|
||||
historyPurpose = '',
|
||||
uploadTransport = 'turbo',
|
||||
turboKeySource = 'client',
|
||||
turboKeySource = 'blockchain',
|
||||
dialogTitle = '',
|
||||
uploadButtonLabel = '',
|
||||
initialFile = null,
|
||||
@@ -406,20 +406,9 @@ export function openArweaveAttachmentManager({
|
||||
turboKeyChoices = await getStoredSolanaWalletChoices({
|
||||
login: cleanLogin,
|
||||
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))) {
|
||||
selectedTurboKeySource = String(
|
||||
turboKeyChoices.find((item) => item.keySource === 'client')?.keySource
|
||||
|| turboKeyChoices[0]?.keySource
|
||||
|| 'client'
|
||||
);
|
||||
selectedTurboKeySource = String(turboKeyChoices[0]?.keySource || 'blockchain');
|
||||
}
|
||||
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
|
||||
} catch (error) {
|
||||
@@ -434,20 +423,23 @@ export function openArweaveAttachmentManager({
|
||||
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—';
|
||||
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—';
|
||||
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—';
|
||||
const freeHint = selectedFile
|
||||
? (combinedUploadSize() <= getFreeTurboUploadBytesLimit() ? 'Да, бесплатно через Turbo' : 'Нет, нужен Turbo balance')
|
||||
: `До ${formatBytes(getFreeTurboUploadBytesLimit())} бесплатно`;
|
||||
const freeBadge = priceInfo?.isFree
|
||||
? ' <span style="color:#15803d;">(пока файлы до 100 KB бесплатно)</span>'
|
||||
const isFreeSize = selectedFile && combinedUploadSize() <= getFreeTurboUploadBytesLimit();
|
||||
const summary = selectedFile
|
||||
? `Размер ${escapeHtml(fileSize)} · ${isFreeSize
|
||||
? '<span class="ar-attachment-ok">бесплатно через Turbo</span>'
|
||||
: `нужен баланс Turbo: ${escapeHtml(uploadPrice)}`}`
|
||||
: '';
|
||||
mainMetaEl.innerHTML = `
|
||||
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
|
||||
<div>Turbo balance: ${escapeHtml(turboCredits)}</div>
|
||||
<div>SOL на этом ключе: ${solBalance}</div>
|
||||
<div>Размер файла: ${escapeHtml(fileSize)}</div>
|
||||
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
|
||||
<div>Цена Turbo: ${escapeHtml(uploadPrice)}${freeBadge}</div>
|
||||
<div>До ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))} бесплатно: ${escapeHtml(freeHint)}</div>
|
||||
${summary ? `<div class="ar-attachment-summary">${summary}</div>` : ''}
|
||||
<details class="ar-attachment-details">
|
||||
<summary>Подробнее</summary>
|
||||
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
|
||||
<div>Баланс Turbo: ${escapeHtml(turboCredits)}</div>
|
||||
<div>SOL на этом ключе: ${solBalance}</div>
|
||||
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
|
||||
<div>Цена Turbo: ${escapeHtml(uploadPrice)}</div>
|
||||
<div>Бесплатно до ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))}</div>
|
||||
</details>
|
||||
`;
|
||||
previewMetaEl.innerHTML = '';
|
||||
if (selectedPreviewEnabled && selectedPreviewFile) {
|
||||
@@ -628,28 +620,40 @@ export function openArweaveAttachmentManager({
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-ar-attach-modal="true">
|
||||
<div class="modal-card stack ar-attachment-manager-card">
|
||||
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
|
||||
<p class="meta-muted" style="margin-top:-6px; color:#15803d;">Маленькие файлы и аватары через Turbo пока загружаются бесплатно.</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button>
|
||||
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button>
|
||||
${canShowHistory ? `<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>` : ''}
|
||||
${canShowExisting ? `<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>` : ''}
|
||||
<div class="ar-attachment-manager-head">
|
||||
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
|
||||
<button class="icon-btn ar-attachment-manager-close" type="button" data-action="cancel" aria-label="Закрыть">✕</button>
|
||||
</div>
|
||||
<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-arweave">Свой кошелёк Arweave</button>
|
||||
</div>
|
||||
${turboMode
|
||||
? `
|
||||
<label class="meta-muted" for="turbo-key-source">Подписывать и пополнять через</label>
|
||||
<select class="input ar-attachment-wallet-select" id="turbo-key-source"></select>
|
||||
<p class="meta-muted">Turbo использует Solana-ключ пользователя. Маленькие файлы и аватары пока бесплатно. Если файл больше, пополните Turbo со своего SOL-кошелька.</p>
|
||||
<p class="ar-attachment-note">Маленькие файлы и аватары через Turbo пока загружаются бесплатно. Для больших файлов пополните Turbo со своего SOL-кошелька.</p>
|
||||
<div class="form-field">
|
||||
<label for="turbo-key-source">Подписывать и пополнять через</label>
|
||||
<select class="select ar-attachment-wallet-select" id="turbo-key-source"></select>
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
|
||||
<select class="input ar-attachment-wallet-select" id="ar-attach-wallet"></select>
|
||||
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
|
||||
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
||||
<div class="form-field">
|
||||
<label for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
|
||||
<select class="select ar-attachment-wallet-select" id="ar-attach-wallet"></select>
|
||||
<button class="text-btn ar-attachment-link" type="button" data-action="add-wallet">+ Добавить кошелёк</button>
|
||||
</div>
|
||||
${isAvatarMode ? '<p class="ar-attachment-note">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="ar-attachment-note">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
||||
`}
|
||||
${fixedFile ? '' : '<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>'}
|
||||
${fixedFile ? '' : `<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />`}
|
||||
${canShowHistory || canShowExisting ? `
|
||||
<div class="ar-attachment-alt-sources">
|
||||
${canShowHistory ? `<button class="text-btn ar-attachment-link" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Из журнала загрузок'}</button>` : ''}
|
||||
${canShowExisting ? `<button class="text-btn ar-attachment-link" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Уже есть в Arweave (tx id)'}</button>` : ''}
|
||||
</div>` : ''}
|
||||
${fixedFile ? '' : `<label class="ar-attachment-file-picker">
|
||||
<input class="ar-attachment-file-input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
|
||||
<span class="ar-attachment-file-btn">Выбрать файл</span>
|
||||
<span class="ar-attachment-file-name" data-file-name="true">Файл не выбран</span>
|
||||
</label>`}
|
||||
<div class="ar-attachment-meta" data-meta-main="true"></div>
|
||||
<label class="meta-muted" data-preview-option="true" hidden>
|
||||
<input type="checkbox" data-preview-toggle="true" />
|
||||
@@ -658,10 +662,9 @@ export function openArweaveAttachmentManager({
|
||||
<div class="ar-attachment-meta" data-meta-preview="true"></div>
|
||||
<p class="meta-muted inline-error" data-error="true"></p>
|
||||
<div class="form-actions-grid">
|
||||
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : ''}
|
||||
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : '<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>'}
|
||||
<button class="primary-btn" type="button" data-action="upload" disabled>${escapeHtml(uploadText)}</button>
|
||||
</div>
|
||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -734,7 +737,7 @@ export function openArweaveAttachmentManager({
|
||||
}
|
||||
}
|
||||
|
||||
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null));
|
||||
root.querySelectorAll('[data-action="cancel"]').forEach((btn) => btn.addEventListener('click', () => close(resolve, null)));
|
||||
root.querySelector('[data-action="topup"]')?.addEventListener('click', async () => {
|
||||
await promptTurboTopUp(mainMetaEl, previewMetaEl, errorEl);
|
||||
if (turboMode && selectedFile && selectedSha256) {
|
||||
@@ -745,6 +748,10 @@ export function openArweaveAttachmentManager({
|
||||
root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet);
|
||||
}
|
||||
|
||||
fileEl?.addEventListener('change', () => {
|
||||
const nameEl = root.querySelector('[data-file-name="true"]');
|
||||
if (nameEl) nameEl.textContent = fileEl.files?.[0]?.name || 'Файл не выбран';
|
||||
});
|
||||
fileEl?.addEventListener('change', async () => {
|
||||
selectedFile = fileEl.files?.[0] || null;
|
||||
selectedSha256 = '';
|
||||
@@ -1041,7 +1048,7 @@ export function openArweaveAttachmentManager({
|
||||
<span>${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}</span>
|
||||
${hasAttachmentPreview(item) ? '<span class="ar-attachment-placement-flag">С превью</span>' : ''}
|
||||
<span class="ar-attachment-status ar-attachment-status--pending" data-status="${index}">Проверяем...</span>
|
||||
${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Не добавлен в SHiNE</span>' : ''}
|
||||
${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Ещё не прикреплён</span>' : ''}
|
||||
</div>
|
||||
<div class="mono-cell ar-attachment-history-txid">${escapeHtml(item.ar)}</div>
|
||||
<button class="${isSelected ? 'secondary-btn' : 'ghost-btn'}" type="button" data-pick="${index}" ${isSelected ? 'disabled' : ''}>${isSelected ? 'Уже добавлен ✓' : 'Выбрать'}</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from './ui-icon.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from './arweave-attachment-manager.js';
|
||||
import { composeMessageWithAttachments, MAX_MESSAGE_ATTACHMENTS } from '../services/attachment-format.js';
|
||||
import { state } from '../state.js';
|
||||
@@ -133,7 +134,6 @@ export function openChannelEditor({
|
||||
authorName.textContent = login || 'Гость';
|
||||
author.append(authorName);
|
||||
body.append(author);
|
||||
if (extraControl instanceof Node) body.append(extraControl);
|
||||
body.append(textarea, attachmentsBox, error, clearButton);
|
||||
|
||||
const footer = document.createElement('footer');
|
||||
@@ -141,7 +141,8 @@ export function openChannelEditor({
|
||||
const attachButton = document.createElement('button');
|
||||
attachButton.type = 'button';
|
||||
attachButton.className = 'secondary-btn channel-editor__attach';
|
||||
attachButton.textContent = 'Прикрепить';
|
||||
attachButton.innerHTML = `${iconHtml('clip')}<span class="sr-only">Прикрепить</span>`;
|
||||
attachButton.title = 'Прикрепить файл';
|
||||
attachButton.hidden = !allowAttachments;
|
||||
const counter = document.createElement('span');
|
||||
counter.className = 'channel-editor__counter';
|
||||
@@ -151,6 +152,10 @@ export function openChannelEditor({
|
||||
submitButton.textContent = submitLabel;
|
||||
submitButton.title = 'Отправить · Ctrl+Enter / ⌘+Enter';
|
||||
footer.append(attachButton);
|
||||
if (extraControl instanceof Node) {
|
||||
footer.classList.add('has-extra');
|
||||
footer.append(extraControl);
|
||||
}
|
||||
footer.append(counter, submitButton);
|
||||
dialog.append(header, body, footer);
|
||||
overlay.append(dialog);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Возвращает Promise<boolean>: true — подтвердили, false — отмена, Escape или тап мимо.
|
||||
export function confirmDialog({
|
||||
title = 'Подтвердите действие',
|
||||
text = '',
|
||||
confirmLabel = 'Да',
|
||||
cancelLabel = 'Отмена',
|
||||
danger = false,
|
||||
} = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const opener = document.activeElement;
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal confirm-dialog';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-card confirm-dialog__card" role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
|
||||
<h2 class="modal-title" id="confirm-dialog-title"></h2>
|
||||
<p class="confirm-dialog__text"></p>
|
||||
<div class="form-actions-grid">
|
||||
<button type="button" class="secondary-btn" data-answer="no"></button>
|
||||
<button type="button" class="${danger ? 'destructive-btn confirm-dialog__danger' : 'primary-btn'}" data-answer="yes"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
modal.querySelector('.modal-title').textContent = title;
|
||||
const textEl = modal.querySelector('.confirm-dialog__text');
|
||||
textEl.textContent = text;
|
||||
textEl.hidden = !text;
|
||||
modal.querySelector('[data-answer="no"]').textContent = cancelLabel;
|
||||
modal.querySelector('[data-answer="yes"]').textContent = confirmLabel;
|
||||
|
||||
const finish = (answer) => {
|
||||
document.removeEventListener('keydown', onKeydown, true);
|
||||
modal.remove();
|
||||
opener?.focus?.();
|
||||
resolve(answer);
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key === 'Escape') { event.stopPropagation(); finish(false); }
|
||||
};
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) { finish(false); return; }
|
||||
const answer = event.target.closest('[data-answer]')?.dataset.answer;
|
||||
if (answer) finish(answer === 'yes');
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown, true);
|
||||
document.body.append(modal);
|
||||
modal.querySelector('[data-answer="no"]').focus();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Редактор точной настройки палитры.
|
||||
import {
|
||||
PALETTE_PRESETS,
|
||||
PALETTE_ROLES,
|
||||
exportPalette,
|
||||
getPaletteSettings,
|
||||
importPalette,
|
||||
resolvePalette,
|
||||
resolveThemeMode,
|
||||
setPaletteSettings,
|
||||
} from '../services/theme-service.js';
|
||||
|
||||
export function openPaletteEditor({ theme = resolveThemeMode(), onClose } = {}) {
|
||||
let editTheme = theme === 'light' ? 'light' : 'dark';
|
||||
const opener = document.activeElement;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal palette-editor';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-card palette-editor__card" role="dialog" aria-modal="true" aria-labelledby="palette-editor-title">
|
||||
<div class="palette-editor__head">
|
||||
<h2 class="modal-title" id="palette-editor-title">Цвета оформления</h2>
|
||||
<button class="icon-btn palette-editor__close" type="button" aria-label="Закрыть">✕</button>
|
||||
</div>
|
||||
<div class="palette-editor__section">
|
||||
<span class="palette-editor__label">Основа</span>
|
||||
<select class="input palette-editor__presets" aria-label="Готовая цветовая тема"></select>
|
||||
<div class="palette-editor__preview" aria-hidden="true"><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="palette-editor__section">
|
||||
<span class="palette-editor__label">Настраиваемая тема</span>
|
||||
<div class="tabs tabs--auto" role="radiogroup" aria-label="Тема для настройки">
|
||||
<button type="button" class="tab-btn" role="radio" data-edit-theme="light">День</button>
|
||||
<button type="button" class="tab-btn" role="radio" data-edit-theme="dark">Ночь</button>
|
||||
</div>
|
||||
<p class="palette-editor__hint">Правки видны сразу. Меняется только выбранная тема.</p>
|
||||
</div>
|
||||
<div class="palette-editor__roles"></div>
|
||||
<details class="palette-editor__share">
|
||||
<summary>Поделиться палитрой</summary>
|
||||
<p class="palette-editor__hint">Скопируйте текст и отправьте команде или вставьте чужую палитру и нажмите «Применить».</p>
|
||||
<textarea class="input palette-editor__json" rows="8" spellcheck="false"></textarea>
|
||||
<p class="palette-editor__error" role="alert" hidden></p>
|
||||
<div class="palette-editor__actions">
|
||||
<button type="button" class="secondary-btn" data-action="copy">Скопировать</button>
|
||||
<button type="button" class="secondary-btn" data-action="import">Применить</button>
|
||||
</div>
|
||||
</details>
|
||||
<div class="palette-editor__actions">
|
||||
<button type="button" class="secondary-btn" data-action="reset-theme">Сбросить эту тему</button>
|
||||
<button type="button" class="primary-btn" data-action="done">Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const card = modal.querySelector('.palette-editor__card');
|
||||
const presetsEl = modal.querySelector('.palette-editor__presets');
|
||||
const rolesEl = modal.querySelector('.palette-editor__roles');
|
||||
const jsonEl = modal.querySelector('.palette-editor__json');
|
||||
const errorEl = modal.querySelector('.palette-editor__error');
|
||||
|
||||
for (const [id, preset] of Object.entries(PALETTE_PRESETS)) {
|
||||
const option = document.createElement('option');
|
||||
option.value = id;
|
||||
option.textContent = preset.label;
|
||||
presetsEl.append(option);
|
||||
}
|
||||
|
||||
const update = (mutate) => {
|
||||
const settings = getPaletteSettings();
|
||||
const next = { preset: settings.preset, custom: { dark: { ...settings.custom.dark }, light: { ...settings.custom.light } } };
|
||||
mutate(next);
|
||||
setPaletteSettings(next);
|
||||
render();
|
||||
};
|
||||
|
||||
function render() {
|
||||
const settings = getPaletteSettings();
|
||||
const colors = resolvePalette(editTheme, settings);
|
||||
const custom = settings.custom[editTheme];
|
||||
presetsEl.value = settings.preset;
|
||||
modal.querySelectorAll('.palette-editor__preview span').forEach((swatch, index) => {
|
||||
swatch.style.backgroundColor = [colors.background, colors.surface, colors.accent, colors['surface-selected']][index];
|
||||
});
|
||||
modal.querySelectorAll('[data-edit-theme]').forEach((btn) => {
|
||||
btn.setAttribute('aria-checked', String(btn.dataset.editTheme === editTheme));
|
||||
});
|
||||
rolesEl.replaceChildren(...PALETTE_ROLES.map((role) => {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'palette-editor__role';
|
||||
const changed = Boolean(custom[role.id]);
|
||||
row.innerHTML = `
|
||||
<input type="color" value="${colors[role.id]}" aria-label="${role.label}">
|
||||
<span class="palette-editor__role-name">${role.label}${changed ? ' <span class="palette-editor__changed">изменён</span>' : ''}</span>
|
||||
<code class="palette-editor__role-value">${colors[role.id]}</code>
|
||||
`;
|
||||
const input = row.querySelector('input');
|
||||
input.addEventListener('input', () => {
|
||||
document.documentElement.style.setProperty(`--${role.id}`, input.value);
|
||||
row.querySelector('code').textContent = input.value;
|
||||
});
|
||||
input.addEventListener('change', () => update((next) => { next.custom[editTheme][role.id] = input.value; }));
|
||||
return row;
|
||||
}));
|
||||
jsonEl.value = exportPalette();
|
||||
errorEl.hidden = true;
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
modal.remove();
|
||||
opener?.focus?.();
|
||||
onClose?.();
|
||||
};
|
||||
const onKeydown = (event) => { if (event.key === 'Escape') close(); };
|
||||
|
||||
modal.addEventListener('click', async (event) => {
|
||||
if (event.target === modal) { close(); return; }
|
||||
// data-edit-theme, а не data-theme: data-theme стоит на <html>, и closest() находил бы его при любом клике.
|
||||
const themeBtn = event.target.closest('[data-edit-theme]');
|
||||
if (themeBtn) { editTheme = themeBtn.dataset.editTheme; render(); return; }
|
||||
if (event.target.closest('.palette-editor__close')) { close(); return; }
|
||||
const action = event.target.closest('[data-action]')?.dataset.action;
|
||||
if (action === 'done') close();
|
||||
if (action === 'reset-theme') update((next) => { next.custom[editTheme] = {}; });
|
||||
if (action === 'copy') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(jsonEl.value);
|
||||
} catch {
|
||||
jsonEl.select();
|
||||
}
|
||||
}
|
||||
if (action === 'import') {
|
||||
try {
|
||||
importPalette(jsonEl.value);
|
||||
render();
|
||||
} catch {
|
||||
errorEl.textContent = 'Не удалось прочитать палитру: проверьте, что текст скопирован целиком.';
|
||||
errorEl.hidden = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
presetsEl.addEventListener('change', () => update((next) => {
|
||||
next.preset = presetsEl.value;
|
||||
next.custom = { dark: {}, light: {} };
|
||||
}));
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
|
||||
render();
|
||||
document.body.append(modal);
|
||||
card.querySelector('.palette-editor__close').focus();
|
||||
return close;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// data-profile-list / data-self-profile-action / data-profile-action обрабатывают profile-view.js и user-profile-view.js.
|
||||
import { iconHtml } from './ui-icon.js';
|
||||
|
||||
export function escapeProfileHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
const esc = escapeProfileHtml;
|
||||
|
||||
function statusChip({ kind, label, value, active }) {
|
||||
const n = Number(value || 0);
|
||||
return `
|
||||
<button type="button" class="pf-status${active ? ' is-active' : ''}" data-profile-list="${esc(kind)}"
|
||||
aria-label="${esc(label)}: подтверждений ${n}" title="Статус «${esc(label)}». Число — сколько людей его подтвердили. Нажмите, чтобы увидеть кто.">
|
||||
<span class="pf-status-dot" aria-hidden="true"></span>${esc(label)}${n > 0 ? `<b>${n}</b>` : ''}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function statHtml({ kind, label, valueHtml, ariaLabel }) {
|
||||
return `
|
||||
<button type="button" class="pf-stat" data-profile-list="${esc(kind)}" aria-label="${esc(ariaLabel)}">
|
||||
<span class="pf-stat-value">${valueHtml}</span>
|
||||
<span class="pf-stat-label">${esc(label)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function contactRows(card) {
|
||||
return [
|
||||
['Ссылки', card?.web],
|
||||
['Телефон', card?.phone],
|
||||
['Адрес', card?.address],
|
||||
]
|
||||
.filter(([, value]) => String(value || '').trim())
|
||||
.map(([label, value]) => `
|
||||
<div class="pf-row">
|
||||
<span class="pf-row-label">${esc(label)}</span>
|
||||
<span class="pf-row-value">${esc(value)}</span>
|
||||
</div>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {object} opts.card карточка профиля (loadUserProfileCard)
|
||||
* @param {string} opts.login логин на случай, если в карточке его нет
|
||||
* @param {string} opts.tilesHtml плитки действий (свои/чужие)
|
||||
* @param {string} [opts.tilesWrapClass] доп. класс обёртки плиток (для меню «Добавить»)
|
||||
* @param {string} [opts.beforeTilesHtml] разметка внутри обёртки перед плитками
|
||||
*/
|
||||
export function profileCardHtml({ card, login = '', tilesHtml = '', tilesWrapClass = '', beforeTilesHtml = '', isSelf = true }) {
|
||||
const stats = card?.stats || {};
|
||||
const official = card?.accountRole === 'primary';
|
||||
const shining = card?.shineStatus === 'shining';
|
||||
const cardLogin = card?.login || login;
|
||||
const fullName = [card?.firstName, card?.lastName].map((v) => String(v || '').trim()).filter(Boolean).join(' ');
|
||||
const displayName = fullName || cardLogin || 'Профиль';
|
||||
const about = String(card?.about || '').trim();
|
||||
const spiritualPath = String(card?.spiritualPath || '').trim();
|
||||
const contacts = contactRows(card);
|
||||
const aboutRow = (about || isSelf) ? `
|
||||
<div class="pf-row pf-row--block">
|
||||
<span class="pf-row-label">О себе</span>
|
||||
<p class="pf-row-text${about ? '' : ' is-empty'}">${esc(about || 'Не заполнено')}</p>
|
||||
</div>` : '';
|
||||
const pathRow = spiritualPath ? `
|
||||
<div class="pf-row pf-row--block">
|
||||
<span class="pf-row-label">Духовный путь</span>
|
||||
<p class="pf-row-text">${esc(spiritualPath)}</p>
|
||||
</div>` : '';
|
||||
const listInner = aboutRow + contacts + pathRow;
|
||||
const listHtml = listInner.trim() ? `<div class="pf-list">${listInner}</div>` : '';
|
||||
const friends = Number(stats.friendsCount || 0);
|
||||
const statusChips = [
|
||||
(official || Number(stats.primaryReceivedCount || 0) > 0) && statusChip({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, active: official }),
|
||||
(shining || Number(stats.shineReceivedCount || 0) > 0) && statusChip({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, active: shining }),
|
||||
].filter(Boolean);
|
||||
const statusesHtml = statusChips.length ? `<div class="pf-statuses">${statusChips.join('')}</div>` : '';
|
||||
const closeFriends = Number(stats.closeFriendsCount || 0);
|
||||
|
||||
return `
|
||||
<div class="pf-hero${shining ? ' is-shining' : ''}" aria-label="Профиль ${esc(cardLogin)}">
|
||||
<div class="pf-avatar user-profile-avatar-slot"></div>
|
||||
<h2 class="pf-name">${esc(displayName)}</h2>
|
||||
<div class="pf-login">@${esc(cardLogin)}</div>
|
||||
${statusesHtml}
|
||||
</div>
|
||||
|
||||
<div class="pf-stats">
|
||||
${statHtml({ kind: 'friends', label: 'Друзья', valueHtml: closeFriends ? `${friends}<small> · ${closeFriends} близк.</small>` : String(friends), ariaLabel: `Друзья: ${friends}, близкие: ${closeFriends}` })}
|
||||
${statHtml({ kind: 'channels_owned', label: 'Каналы', valueHtml: String(Number(stats.ownedPublicChannelsCount || 0)), ariaLabel: `Каналы: ${Number(stats.ownedPublicChannelsCount || 0)}` })}
|
||||
${statHtml({ kind: 'channels_following', label: 'Подписки', valueHtml: String(Number(stats.followingChannelsCount || 0)), ariaLabel: `Подписки: ${Number(stats.followingChannelsCount || 0)}` })}
|
||||
</div>
|
||||
|
||||
${tilesHtml ? `
|
||||
<div class="pf-tiles-wrap user-profile-actions-wrap ${esc(tilesWrapClass)}">
|
||||
${beforeTilesHtml}
|
||||
<div class="pf-tiles user-profile-actions">${tilesHtml}</div>
|
||||
</div>` : ''}
|
||||
|
||||
${listHtml}`;
|
||||
}
|
||||
|
||||
export function profileTileHtml({ icon, label, attrs = '', iconMarkup = '' }) {
|
||||
return `<button type="button" class="pf-tile user-profile-action-btn" ${attrs}>${iconMarkup || iconHtml(icon)}<span>${esc(label)}</span></button>`;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from './ui-icon.js';
|
||||
function resolveElement(value) {
|
||||
return typeof value === 'function' ? value() : value;
|
||||
}
|
||||
@@ -6,7 +7,7 @@ function buildArrowIcon() {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'scroll-to-bottom-btn__icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = '↓';
|
||||
icon.innerHTML = iconHtml('down');
|
||||
return icon;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { resolveToolbarActive } from '../router.js';
|
||||
import { state, authService } from '../state.js';
|
||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||
import { iconHtml as lineIcon } from './ui-icon.js';
|
||||
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
||||
|
||||
const TOOLBAR_ICONS = {
|
||||
'messages-list': '<g transform="translate(12 12) scale(.88) translate(-11.5 -13)"><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"/><circle cx="8" cy="12" r="1.05" class="toolbar-svg-dot"/><circle cx="11.7" cy="12" r="1.05" class="toolbar-svg-dot"/><circle cx="15.4" cy="12" r="1.05" class="toolbar-svg-dot"/></g>',
|
||||
'channels-list': '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
|
||||
'notifications-view': '<path d="M6.2 16.8V11a5.8 5.8 0 0 1 11.6 0v5.8l1.7 1.7h-15Z"/><path d="M10.2 21a2 2 0 0 0 3.6 0"/>',
|
||||
'profile-view': '<circle cx="12" cy="6.8" r="3.6"/><path d="M4.4 21v-2.1a5.6 5.6 0 0 1 5.6-5.6h4a5.6 5.6 0 0 1 5.6 5.6V21Z"/>',
|
||||
};
|
||||
|
||||
const ITEMS = [
|
||||
{ pageId: 'messages-list', label: 'Личные' },
|
||||
{ pageId: 'channels-list', label: 'Каналы' },
|
||||
{ pageId: 'network-view', label: 'Связи' },
|
||||
{ pageId: 'network-view', label: 'Связи', mandala: true },
|
||||
{ pageId: 'notifications-view', label: 'Уведомления' },
|
||||
{ pageId: 'profile-view', label: 'Профиль' },
|
||||
];
|
||||
|
||||
function iconHtml(item) {
|
||||
const names = { 'messages-list': 'message', 'channels-list': 'channels', 'network-view': 'network', 'notifications-view': 'bell', 'profile-view': 'profile' };
|
||||
return lineIcon(names[item.pageId]);
|
||||
function iconHtml(item, extra = '') {
|
||||
const glyph = item.mandala
|
||||
? `<img class="toolbar-mandala" src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true" />`
|
||||
: `<svg class="toolbar-svg" viewBox="0 0 24 24" aria-hidden="true">${TOOLBAR_ICONS[item.pageId]}</svg>`;
|
||||
return `<span class="toolbar-icon${item.mandala ? ' toolbar-icon--mandala' : ''}">${glyph}${extra}</span>`;
|
||||
}
|
||||
|
||||
function normalizeCounters(payload = {}) {
|
||||
@@ -44,7 +54,7 @@ function renderBadge(btn, count, ariaLabel, extraClass = '') {
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = `toolbar-unread-badge${extraClass ? ` ${extraClass}` : ''}`;
|
||||
btn.append(badge);
|
||||
(btn.querySelector('.toolbar-icon') || btn).append(badge);
|
||||
}
|
||||
badge.textContent = count > 99 ? '99+' : String(count);
|
||||
badge.setAttribute('aria-label', `${ariaLabel}: ${count}`);
|
||||
@@ -127,21 +137,17 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
|
||||
if (isProfile) {
|
||||
btn.innerHTML = `
|
||||
${iconHtml(item)}
|
||||
<span class="toolbar-label-wrap">
|
||||
<span>${item.label}</span>
|
||||
<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
|
||||
${iconHtml(item, `<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
|
||||
<span class="toolbar-connection-dot" aria-hidden="true"></span>
|
||||
</span>
|
||||
</span>
|
||||
</span>`)}
|
||||
<span class="sr-only">${item.label}</span>
|
||||
`;
|
||||
} else if (isNetwork) {
|
||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||
btn.setAttribute('aria-label', item.label);
|
||||
btn.title = item.label;
|
||||
btn.innerHTML = `${iconHtml(item)}<span class="sr-only">${item.label}</span>`;
|
||||
} else {
|
||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||
btn.innerHTML = `${iconHtml(item)}<span class="sr-only">${item.label}</span>`;
|
||||
}
|
||||
btn.title = item.label;
|
||||
if (isMessages) renderBadge(btn, counters.dmUnreadCount, 'Непрочитанных личных сообщений');
|
||||
if (item.pageId === 'channels-list') renderBadge(btn, counters.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
|
||||
if (isNotifications) renderBadge(btn, counters.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from './ui-icon.js';
|
||||
import { createDropdownMenu } from './dropdown-menu.js';
|
||||
|
||||
function appendNode(target, node) {
|
||||
@@ -15,6 +16,8 @@ function createActionButton(action = {}, cleanupFns) {
|
||||
|
||||
if (action.iconNode instanceof Node) {
|
||||
button.append(action.iconNode);
|
||||
} else if (action.icon) {
|
||||
button.innerHTML = iconHtml(action.icon);
|
||||
} else {
|
||||
button.textContent = String(action.label ?? '');
|
||||
}
|
||||
@@ -56,7 +59,7 @@ export function createTopBar({
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `icon-btn topbar__back${backAction.className ? ` ${backAction.className}` : ''}`;
|
||||
button.textContent = '←';
|
||||
button.innerHTML = iconHtml('back');
|
||||
button.setAttribute('aria-label', backAction.ariaLabel || 'Назад');
|
||||
button.title = backAction.title || 'Назад';
|
||||
button.addEventListener('click', backAction.onClick);
|
||||
|
||||
@@ -7,6 +7,20 @@ const paths = {
|
||||
profile: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
|
||||
open: '<path d="M5 12h14M13 6l6 6-6 6"/>',
|
||||
chevron: '<path d="m9 6 6 6-6 6"/>',
|
||||
back: '<path d="M19 12H5M11 5l-7 7 7 7"/>',
|
||||
down: '<path d="M12 5v14M5 12l7 7 7-7"/>',
|
||||
edit: '<path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/>',
|
||||
wallet: '<path d="M4 7.5A2.5 2.5 0 0 1 6.5 5H18v3"/><rect x="4" y="8" width="16" height="11" rx="2.5"/><circle cx="16" cy="13.5" r="1.2" fill="currentColor" stroke="none"/>',
|
||||
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"/>',
|
||||
refresh: '<path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v4.4h-4.4"/>',
|
||||
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) {
|
||||
|
||||
@@ -177,14 +177,14 @@ function createPasswordModal() {
|
||||
<div class="stack" style="gap:0.45rem;">
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="once" checked />
|
||||
<span>Использовать root key только сейчас</span>
|
||||
<span>Использовать blockchain key только сейчас</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="save" />
|
||||
<span>Сохранить root key на этом устройстве</span>
|
||||
<span>Сохранить blockchain key на этом устройстве</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
|
||||
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, blockchain key попадёт в зашифрованный контейнер устройства.</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</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');
|
||||
close({
|
||||
password,
|
||||
saveRoot: mode instanceof HTMLInputElement && mode.value === 'save',
|
||||
saveBlockchain: mode instanceof HTMLInputElement && mode.value === 'save',
|
||||
});
|
||||
});
|
||||
window.setTimeout(() => inputEl.focus(), 0);
|
||||
@@ -277,15 +277,9 @@ export function render({navigate, chrome}) {
|
||||
const passwordModal = createPasswordModal();
|
||||
|
||||
const introCard = document.createElement('div');
|
||||
introCard.className = 'card stack';
|
||||
introCard.className = 'settings-intro';
|
||||
introCard.innerHTML = `
|
||||
<p class="field-label">Где хранятся личные данные</p>
|
||||
<p class="meta-muted">
|
||||
Единственный сервер доступа хранит зашифрованную личную переписку пользователя и участвует в звонках.
|
||||
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
|
||||
а настройки и приватная переписка хранятся только на выбранном сервере.
|
||||
При смене сервера прежняя переписка и настройки автоматически не переносятся.
|
||||
</p>
|
||||
<p class="meta-muted">Сервер доступа хранит вашу зашифрованную переписку и настройки и помогает со звонками. Публичные записи доступны через любой сервер. При смене сервера переписка сама не переносится.</p>
|
||||
`;
|
||||
|
||||
const listCard = document.createElement('div');
|
||||
@@ -302,7 +296,7 @@ export function render({navigate, chrome}) {
|
||||
listBody.className = 'stack';
|
||||
const listStatus = document.createElement('p');
|
||||
listStatus.className = 'meta-muted';
|
||||
listStatus.textContent = 'Загрузка данных из PDA...';
|
||||
listStatus.textContent = 'Загрузка данных аккаунта...';
|
||||
listCard.append(listTitle, listHint, listBody, listStatus);
|
||||
|
||||
const addCard = document.createElement('div');
|
||||
@@ -323,7 +317,7 @@ export function render({navigate, chrome}) {
|
||||
suggestEl.hidden = true;
|
||||
const addStatus = document.createElement('p');
|
||||
addStatus.className = 'meta-muted';
|
||||
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
|
||||
addStatus.textContent = 'Для изменения списка понадобится подтверждение главным ключом.';
|
||||
const addButton = document.createElement('button');
|
||||
addButton.className = 'primary-btn';
|
||||
addButton.type = 'button';
|
||||
@@ -352,7 +346,7 @@ export function render({navigate, chrome}) {
|
||||
addInput.value = candidate.login;
|
||||
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
|
||||
} else {
|
||||
addStatus.textContent = 'Для смены сервера понадобится подпись root key.';
|
||||
addStatus.textContent = 'Для смены сервера понадобится подтверждение главным ключом.';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -392,7 +386,7 @@ export function render({navigate, chrome}) {
|
||||
return;
|
||||
}
|
||||
|
||||
listStatus.textContent = 'Читаем серверы доступа из Solana PDA...';
|
||||
listStatus.textContent = 'Читаем серверы доступа из записи аккаунта в Solana...';
|
||||
try {
|
||||
const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
|
||||
const logins = uniqueLogins(parsed?.accessServers);
|
||||
@@ -409,8 +403,8 @@ export function render({navigate, chrome}) {
|
||||
renderServerList();
|
||||
refreshAddButton();
|
||||
listStatus.textContent = rows.length
|
||||
? 'Сервер доступа загружен из PDA.'
|
||||
: 'В PDA пользователя пока нет серверов доступа.';
|
||||
? 'Сервер доступа загружен из записи аккаунта.'
|
||||
: 'В записи аккаунта пока нет серверов доступа.';
|
||||
} catch (error) {
|
||||
currentAccessServers = [];
|
||||
renderServerList();
|
||||
@@ -439,7 +433,7 @@ export function render({navigate, chrome}) {
|
||||
const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup';
|
||||
target.innerHTML = `
|
||||
<span style="display:block; margin-bottom:0.55rem;">
|
||||
Не хватает SOL на client key для оплаты Solana rent/fee при обновлении user PDA.
|
||||
Не хватает SOL на ключе устройства для оплаты комиссии Solana при обновлении записи аккаунта в Solana.
|
||||
</span>
|
||||
${address ? `<span style="display:block; overflow-wrap:anywhere; margin-bottom:0.55rem;">Кошелёк: ${escapeHtml(address)}</span>` : ''}
|
||||
<a class="primary-btn" href="${escapeHtml(topupUrl)}" target="_blank" rel="noopener" style="display:inline-flex; text-decoration:none;">Пополнить DEVNET кошелёк</a>
|
||||
@@ -488,48 +482,49 @@ export function render({navigate, chrome}) {
|
||||
saved = null;
|
||||
}
|
||||
|
||||
const savedRoot = String(saved?.rootKey || '').trim();
|
||||
const savedBlockchain = String(saved?.blockchainKey || '').trim();
|
||||
const savedClient = String(saved?.clientKey || '').trim();
|
||||
if (savedRoot && savedClient) {
|
||||
if (savedBlockchain && savedClient) {
|
||||
return {
|
||||
rootPrivatePkcs8B64: savedRoot,
|
||||
blockchainPrivatePkcs8B64: savedBlockchain,
|
||||
clientPrivatePkcs8B64: savedClient,
|
||||
clientAddress: await clientAddressFromPrivatePkcs8(savedClient),
|
||||
payerAddress: await clientAddressFromPrivatePkcs8(savedBlockchain),
|
||||
};
|
||||
}
|
||||
|
||||
const passwordResult = await passwordModal?.open({
|
||||
title: 'Нужен пароль для обновления серверов доступа',
|
||||
text: 'Чтобы изменить сервер доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
text: 'Чтобы изменить сервер доступа, нужно подписать обновление PDA текущим blockchain key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
note: savedClient
|
||||
? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.'
|
||||
: 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.',
|
||||
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен blockchain key.'
|
||||
: 'На устройстве не хватает blockchain key и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
|
||||
});
|
||||
if (!passwordResult) {
|
||||
throw new Error('Операция отменена пользователем.');
|
||||
}
|
||||
|
||||
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);
|
||||
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
|
||||
throw new Error('Пароль не подходит: root key не совпал с user PDA.');
|
||||
const activeBlockchain = currentPda.forks?.at(-1)?.blockchainKey;
|
||||
if (!activeBlockchain || !equalBytes(derivedBlockchainPublic, activeBlockchain)) {
|
||||
throw new Error('Пароль не подходит: blockchain key не совпал с активным fork аккаунта.');
|
||||
}
|
||||
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
|
||||
throw new Error('Пароль не подходит: client key не совпал с user PDA.');
|
||||
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
|
||||
}
|
||||
|
||||
if (passwordResult.saveRoot) {
|
||||
if (passwordResult.saveBlockchain) {
|
||||
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
|
||||
saveRoot: true,
|
||||
saveBlockchain: false,
|
||||
saveRoot: false,
|
||||
saveBlockchain: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
|
||||
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
|
||||
clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64),
|
||||
payerAddress: clientAddressFromPublicB64(keyBundle.blockchainPair.publicKeyB64),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -556,7 +551,7 @@ export function render({navigate, chrome}) {
|
||||
const tx = await updateShineUserPdaOnSolana({
|
||||
login: sessionLogin,
|
||||
solanaEndpoint,
|
||||
rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64: signingMaterial.blockchainPrivatePkcs8B64,
|
||||
clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64,
|
||||
accessServers: normalizedList,
|
||||
});
|
||||
@@ -568,7 +563,7 @@ export function render({navigate, chrome}) {
|
||||
refreshAddButton();
|
||||
} catch (error) {
|
||||
if (isInsufficientFundsForRentError(error)) {
|
||||
showTopupRequiredStatus(target, signingMaterial?.clientAddress);
|
||||
showTopupRequiredStatus(target, signingMaterial?.payerAddress);
|
||||
} else {
|
||||
target.textContent = error?.message || 'Не удалось обновить сервер доступа.';
|
||||
}
|
||||
@@ -616,15 +611,15 @@ export function render({navigate, chrome}) {
|
||||
title: 'Сменить сервер доступа?',
|
||||
text: `Заменить текущий сервер доступа на @${resolved.serverLogin}?`,
|
||||
note: resolved.httpBase
|
||||
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.`
|
||||
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.',
|
||||
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в записи аккаунта в Solana.`
|
||||
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в записи аккаунта в Solana.',
|
||||
onConfirm: async () => {
|
||||
const nextList = [resolved.serverLogin];
|
||||
try {
|
||||
await updateAccessServers(nextList, {
|
||||
statusTarget: addStatus,
|
||||
successText: `Сервер доступа заменён на @${resolved.serverLogin}.`,
|
||||
inFlightText: `Обновляем PDA и меняем сервер на @${resolved.serverLogin}...`,
|
||||
inFlightText: `Обновляем запись аккаунта и меняем сервер на @${resolved.serverLogin}...`,
|
||||
});
|
||||
} catch {
|
||||
// Сообщение уже показано в статусе.
|
||||
|
||||
@@ -17,7 +17,7 @@ export const pageMeta = { id: 'add-channel-view', title: 'Создание ка
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const CHANNEL_TYPE_PUBLIC = 1;
|
||||
const CHANNEL_LOGIN_HINT = 'Разрешены латинские буквы, цифры, _ и -. Длина: от 3 до 32 символов. Название не должно состоять только из цифр.';
|
||||
const CHANNEL_LOGIN_HINT = 'Латиница, цифры, _ и -, от 3 до 32 символов, не только цифры.';
|
||||
const CHANNEL_LOGIN_DIGITS_ONLY_HINT = 'Имя канала не должно состоять только из цифр.';
|
||||
|
||||
function persistCreateSuccessFlash(message) {
|
||||
@@ -78,6 +78,17 @@ function shortAvatarBlockchainAddress(value) {
|
||||
return raw.slice(-24);
|
||||
}
|
||||
|
||||
const TRANSLIT = {
|
||||
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm',
|
||||
н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch',
|
||||
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
|
||||
};
|
||||
|
||||
function suggestChannelLogin(title) {
|
||||
const latin = Array.from(String(title || '').toLowerCase()).map((ch) => TRANSLIT[ch] ?? ch).join('');
|
||||
return latin.replace(/[^a-z0-9_-]+/g, '-').replace(/-{2,}/g, '-').replace(/^[-_]+|[-_]+$/g, '').slice(0, 32);
|
||||
}
|
||||
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
@@ -101,21 +112,31 @@ export function render({navigate, chrome}) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="channel-name">Технический логин канала</label>
|
||||
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required />
|
||||
<div class="meta-muted channel-create-login-hint">${CHANNEL_LOGIN_HINT}</div>
|
||||
<div class="meta-muted channel-link-preview" id="channel-link-preview"> </div>
|
||||
<div id="channel-name-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-field">
|
||||
<label for="channel-title">Название канала</label>
|
||||
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой канал" autocomplete="off" />
|
||||
<div class="form-field__meta">
|
||||
<div id="channel-title-error" class="inline-error"></div>
|
||||
<div class="form-field__counter" id="channel-title-counter">0 / 50</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="channel-title">Как канал будет виден пользователям</label>
|
||||
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" />
|
||||
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div>
|
||||
<div id="channel-title-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-field">
|
||||
<label for="channel-name">Адрес канала</label>
|
||||
<input id="channel-name" class="input" maxlength="32" placeholder="my-channel" required autocomplete="off" autocapitalize="off" spellcheck="false" />
|
||||
<div class="form-field__hint channel-create-login-hint">${CHANNEL_LOGIN_HINT} Заполняется из названия, можно поправить.</div>
|
||||
<div class="form-field__hint channel-link-preview" id="channel-link-preview" hidden></div>
|
||||
<div id="channel-name-error" class="inline-error"></div>
|
||||
</div>
|
||||
|
||||
<label for="channel-description">Описание канала (необязательно)</label>
|
||||
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
|
||||
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div>
|
||||
<div id="channel-description-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-field">
|
||||
<label for="channel-description">Описание <span class="form-field__optional">необязательно</span></label>
|
||||
<textarea id="channel-description" class="input" rows="3" maxlength="250" placeholder="Коротко о канале"></textarea>
|
||||
<div class="form-field__meta">
|
||||
<div id="channel-description-error" class="inline-error"></div>
|
||||
<div class="form-field__counter" id="channel-description-counter">0 / 250</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="channel-create-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
@@ -166,9 +187,11 @@ export function render({navigate, chrome}) {
|
||||
const renderChannelLinkPreview = (nameValue) => {
|
||||
const normalizedChannelName = normalizeChannelDisplayName(nameValue);
|
||||
if (!ownerBlockchainName || !normalizedChannelName) {
|
||||
linkPreviewEl.innerHTML = ' ';
|
||||
linkPreviewEl.hidden = true;
|
||||
linkPreviewEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
linkPreviewEl.hidden = false;
|
||||
const directUrl = buildAbsoluteChannelUrl({
|
||||
ownerBlockchainName,
|
||||
channelName: normalizedChannelName,
|
||||
@@ -216,8 +239,8 @@ export function render({navigate, chrome}) {
|
||||
titleErrorEl.textContent = titleCheck.error;
|
||||
descriptionErrorEl.textContent = descriptionCheck.error;
|
||||
|
||||
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`;
|
||||
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250 символов`;
|
||||
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50`;
|
||||
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250`;
|
||||
|
||||
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
|
||||
submitEl.disabled = submitInFlight || !ok;
|
||||
@@ -232,16 +255,19 @@ export function render({navigate, chrome}) {
|
||||
};
|
||||
};
|
||||
|
||||
let nameEditedByHand = false;
|
||||
nameEl.addEventListener('input', () => {
|
||||
nameEditedByHand = nameEl.value.trim() !== '';
|
||||
const raw = String(nameEl.value || '');
|
||||
const sanitized = sanitizeChannelLoginInput(raw);
|
||||
if (raw !== sanitized) {
|
||||
nameEl.value = sanitized;
|
||||
window.alert(CHANNEL_LOGIN_HINT);
|
||||
}
|
||||
if (raw !== sanitized) nameEl.value = sanitized;
|
||||
updateValidation();
|
||||
if (raw !== sanitized) nameErrorEl.textContent = 'Недопустимые символы убраны: можно латиницу, цифры, _ и -.';
|
||||
});
|
||||
titleEl.addEventListener('input', () => {
|
||||
if (!nameEditedByHand) nameEl.value = suggestChannelLogin(titleEl.value);
|
||||
updateValidation();
|
||||
});
|
||||
titleEl.addEventListener('input', updateValidation);
|
||||
descriptionEl.addEventListener('input', updateValidation);
|
||||
avatarBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
@@ -285,7 +311,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const check = updateValidation();
|
||||
if (check.name && /^[0-9]+$/.test(check.name)) {
|
||||
window.alert(CHANNEL_LOGIN_DIGITS_ONLY_HINT);
|
||||
nameErrorEl.textContent = CHANNEL_LOGIN_DIGITS_ONLY_HINT;
|
||||
nameEl.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { authService, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { normalizeChannelDescription } from '../services/channel-name-rules.js';
|
||||
|
||||
export const pageMeta = { id: 'add-personal-public-chat-view', title: 'Новый персональный публичный чат' };
|
||||
export const pageMeta = { id: 'add-personal-public-chat-view', title: 'Новый публичный чат' };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
@@ -52,7 +52,7 @@ export function render({navigate, chrome}) {
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Новый персональный публичный чат',
|
||||
title: 'Новый публичный чат',
|
||||
back: { label: '<', onClick: () => navigate('channels-list/dialogs') },
|
||||
}));
|
||||
|
||||
|
||||
@@ -32,10 +32,9 @@ export function render({ navigate, chrome }) {
|
||||
}));
|
||||
|
||||
const intro = document.createElement('div');
|
||||
intro.className = 'card stack advanced-settings-intro';
|
||||
intro.className = 'settings-intro';
|
||||
intro.innerHTML = `
|
||||
<p class="field-label">Скрытые возможности SHiNE</p>
|
||||
<p class="meta-muted">Этот экран открывается пятью быстрыми нажатиями по логотипу в обычных настройках и имеет постоянный адрес <code>/settings/advanced</code>.</p>
|
||||
<p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по логотипу в настройках.</p>
|
||||
`;
|
||||
|
||||
const developerRow = createToggleRow({
|
||||
|
||||
@@ -18,17 +18,14 @@ export function render({navigate, chrome}) {
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Лог приложения',
|
||||
back: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
actions: [{ icon: 'refresh', title: 'Обновить лог', ariaLabel: 'Обновить лог', onClick: () => renderEntries() }],
|
||||
}));
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'card row';
|
||||
controls.style.justifyContent = 'flex-start';
|
||||
controls.style.gap = '8px';
|
||||
controls.style.flexWrap = 'wrap';
|
||||
controls.className = 'action-pair';
|
||||
controls.innerHTML = `
|
||||
<button class="ghost-btn" type="button" data-action="refresh">Обновить</button>
|
||||
<button class="ghost-btn" type="button" data-action="copy-all">Скопировать всё</button>
|
||||
<button class="ghost-btn" type="button" data-action="clear">Очистить</button>
|
||||
<button class="secondary-btn" type="button" data-action="copy-all">Скопировать всё</button>
|
||||
<button class="destructive-btn" type="button" data-action="clear">Очистить</button>
|
||||
`;
|
||||
|
||||
const status = document.createElement('div');
|
||||
@@ -77,7 +74,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
if (!entries.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Лог пока пуст.';
|
||||
list.append(empty);
|
||||
status.className = 'status-line is-available';
|
||||
@@ -117,7 +114,6 @@ export function render({navigate, chrome}) {
|
||||
status.textContent = `Записей: ${entries.length}`;
|
||||
}
|
||||
|
||||
controls.querySelector('[data-action="refresh"]').addEventListener('click', renderEntries);
|
||||
controls.querySelector('[data-action="copy-all"]').addEventListener('click', async () => {
|
||||
const entries = getAppLogEntries();
|
||||
if (!entries.length) {
|
||||
|
||||
@@ -48,7 +48,7 @@ function renderTile(item, index) {
|
||||
if (item.pendingPlacement) {
|
||||
const flag = document.createElement('span');
|
||||
flag.className = 'ar-attachment-placement-flag';
|
||||
flag.textContent = 'Не добавлен в SHiNE';
|
||||
flag.textContent = 'Ещё не прикреплён к записи';
|
||||
meta.append(flag);
|
||||
}
|
||||
|
||||
@@ -92,8 +92,8 @@ export function render({navigate, chrome}) {
|
||||
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Загруженные в этой сессии файлы появятся здесь.';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Файлов пока нет. Нажмите «+», чтобы загрузить файл заранее и прикрепить его к записи позже.';
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export function render({navigate, chrome}) {
|
||||
}
|
||||
|
||||
const topbar = createTopBar({
|
||||
title: 'Загрузка файлов в блокчейн',
|
||||
title: 'Файлы в блокчейне',
|
||||
back: { onClick: () => navigate('settings-view') },
|
||||
actions: [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { authService, setChannelsFeed, state } from '../state.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { navigateBack, getPreviousTrackedPath } from '../router.js';
|
||||
import {
|
||||
extractLoginFromBlockchainName,
|
||||
makeProfileRoute,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
formatSol,
|
||||
getBalanceSol,
|
||||
getSolanaWalletFromStoredSecret,
|
||||
getWalletFromStoredClientKey,
|
||||
getWalletFromStoredBlockchainKey,
|
||||
getWalletFromStoredRootKey,
|
||||
solanaAddressFromPublicKeyBase64,
|
||||
transferSol,
|
||||
@@ -137,9 +138,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
||||
if (!root) return;
|
||||
|
||||
const recipientChoices = [
|
||||
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
|
||||
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
|
||||
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
|
||||
].filter(Boolean);
|
||||
|
||||
root.innerHTML = `
|
||||
@@ -165,10 +164,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
||||
<div class="channel-support-address" id="channel-support-recipient-address">—</div>
|
||||
|
||||
<label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label>
|
||||
<select class="select" id="channel-support-sender-key">
|
||||
<option value="client-key">Client key</option>
|
||||
<option value="root-key">Root key</option>
|
||||
</select>
|
||||
<div class="channel-support-address" id="channel-support-sender-key">Blockchain key</div>
|
||||
<div class="channel-support-address" id="channel-support-sender-address">—</div>
|
||||
|
||||
<div class="channel-support-balance-row">
|
||||
@@ -246,31 +242,13 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
||||
};
|
||||
|
||||
const resolveSenderWallet = async () => {
|
||||
const keyId = String(senderSelect?.value || 'client-key');
|
||||
const keyId = 'blockchain-key';
|
||||
if (walletCache.has(keyId)) return walletCache.get(keyId);
|
||||
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.');
|
||||
|
||||
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 });
|
||||
}
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
||||
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
|
||||
|
||||
walletCache.set(keyId, wallet);
|
||||
return wallet;
|
||||
@@ -410,12 +388,21 @@ function confirmUnsubscribeModal({ channelTitle }) {
|
||||
});
|
||||
}
|
||||
|
||||
function subscribersWord(count) {
|
||||
const n = Math.abs(Number(count) || 0) % 100;
|
||||
const last = n % 10;
|
||||
if (n > 10 && n < 20) return 'подписчиков';
|
||||
if (last === 1) return 'подписчик';
|
||||
if (last >= 2 && last <= 4) return 'подписчика';
|
||||
return 'подписчиков';
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--channel-about';
|
||||
|
||||
const topbar = createTopBar({
|
||||
title: '',
|
||||
title: 'О канале',
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
@@ -471,61 +458,66 @@ export function render({ navigate, route, chrome }) {
|
||||
channelRootBlockHash: selector?.channelRootBlockHash,
|
||||
});
|
||||
const serverLink = buildChannelLink(shortRoute);
|
||||
const shortLink = String(serverLink || '').replace(/^https?:\/\//, '');
|
||||
const cameFromChannel = !!shortRoute && (getPreviousTrackedPath() || '').replace(/^\//, '') === String(shortRoute).replace(/^\//, '');
|
||||
const ownerName = ownerDisplayName(ownerProfile, ownerLogin);
|
||||
|
||||
const subscriptionTile = isOwnChannel ? '' : `
|
||||
<button class="channel-about-tile${isSubscribed ? ' is-active' : ''}" id="channel-about-subscription" type="button">
|
||||
${iconHtml(isSubscribed ? 'check' : 'plus')}<span>${isSubscribed ? 'Вы подписаны' : 'Подписаться'}</span>
|
||||
</button>`;
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="channel-about-hero">
|
||||
<div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div>
|
||||
<h2 class="channel-about-title">${escapeHtml(cleanName)}</h2>
|
||||
<div class="channel-about-technical">${escapeHtml(ownerLogin)} / ${escapeHtml(channelName)}</div>
|
||||
<div class="channel-about-subscribers">${escapeHtml(statsText(subscribersCount))} подписчиков</div>
|
||||
<div class="channel-about-subline">@${escapeHtml(ownerLogin)} · ${escapeHtml(statsText(subscribersCount))} ${escapeHtml(subscribersWord(subscribersCount))}</div>
|
||||
</div>
|
||||
|
||||
<section class="channel-about-section">
|
||||
<h3>О канале</h3>
|
||||
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
|
||||
</section>
|
||||
<div class="channel-about-tiles">
|
||||
${cameFromChannel ? '' : `<button class="channel-about-tile" id="channel-about-open" type="button">${iconHtml('open')}<span>Открыть</span></button>`}
|
||||
${subscriptionTile}
|
||||
<button class="channel-about-tile" id="channel-about-support" type="button">${iconHtml('gift')}<span>Донат</span></button>
|
||||
</div>
|
||||
|
||||
<section class="channel-about-section channel-about-owner-section">
|
||||
<h3>Владелец канала</h3>
|
||||
<button class="channel-about-owner-link" id="channel-about-owner" type="button">
|
||||
<strong>${escapeHtml(ownerName)}</strong>
|
||||
<span>@${escapeHtml(ownerLogin)}</span>
|
||||
</button>
|
||||
<button class="secondary-btn channel-about-support-btn" id="channel-about-support" type="button">Донат автору</button>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="channel-about-section">
|
||||
<h3>Ссылка на этом сервере</h3>
|
||||
<div class="channel-about-link-box">
|
||||
<a href="${escapeHtml(serverLink)}">${escapeHtml(serverLink)}</a>
|
||||
<button class="icon-btn channel-about-copy-btn" id="channel-about-copy" type="button" aria-label="Скопировать ссылку" title="Скопировать ссылку">⧉</button>
|
||||
<div class="channel-about-list">
|
||||
<div class="channel-about-row channel-about-row--static">
|
||||
<span class="channel-about-row-label">Описание</span>
|
||||
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="primary-btn channel-about-open-btn" id="channel-about-open" type="button">Открыть канал</button>
|
||||
${isOwnChannel ? '' : `
|
||||
<button class="${isSubscribed ? 'destructive-btn is-unsubscribe' : 'secondary-btn'} channel-about-subscription-btn" id="channel-about-subscription" type="button">
|
||||
${isSubscribed ? 'Отписаться от канала' : 'Подписаться на канал'}
|
||||
<button class="channel-about-row" id="channel-about-owner" type="button">
|
||||
<span class="channel-about-owner-avatar" id="channel-about-owner-avatar"></span>
|
||||
<span class="channel-about-row-main">
|
||||
<strong>${escapeHtml(ownerName)}</strong>
|
||||
<span class="channel-about-row-label">Владелец · @${escapeHtml(ownerLogin)}</span>
|
||||
</span>
|
||||
<span class="channel-about-row-chevron">${iconHtml('chevron')}</span>
|
||||
</button>
|
||||
`}
|
||||
<button class="channel-about-row" id="channel-about-copy-row" type="button">
|
||||
<span class="channel-about-row-main">
|
||||
<span class="channel-about-row-label">Ссылка</span>
|
||||
<span class="channel-about-link">${escapeHtml(shortLink)}</span>
|
||||
</span>
|
||||
<span class="channel-about-row-chevron" aria-hidden="true">${iconHtml('link')}</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (String(channel?.avaAr || '').trim()) {
|
||||
const avatar = renderAvatar({
|
||||
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: { ar: String(channel.avaAr || '').trim() },
|
||||
size: 'xl',
|
||||
className: 'channel-about-avatar channel-profile-avatar',
|
||||
title: cleanName,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
avatar.style.setProperty('--channel-avatar-size', '112px');
|
||||
content.querySelector('#channel-about-avatar-slot')?.append(avatar);
|
||||
} else {
|
||||
content.querySelector('#channel-about-avatar-slot')?.remove();
|
||||
}
|
||||
const channelAvatar = renderAvatar({
|
||||
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: String(channel?.avaAr || '').trim() ? { ar: String(channel.avaAr || '').trim() } : null,
|
||||
size: 'xl',
|
||||
className: 'channel-about-avatar channel-profile-avatar',
|
||||
title: cleanName,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
content.querySelector('#channel-about-avatar-slot')?.append(channelAvatar);
|
||||
content.querySelector('#channel-about-owner-avatar')?.append(renderAvatar({
|
||||
initials: ownerName.slice(0, 1).toUpperCase() || '?',
|
||||
size: 'md',
|
||||
className: 'channel-about-owner-avatar-img',
|
||||
alt: '',
|
||||
}));
|
||||
|
||||
content.querySelector('#channel-about-owner')?.addEventListener('click', () => {
|
||||
const profileRoute = makeProfileRoute(ownerLogin);
|
||||
@@ -537,7 +529,7 @@ export function render({ navigate, route, chrome }) {
|
||||
content.querySelector('#channel-about-open')?.addEventListener('click', () => {
|
||||
if (shortRoute) navigate(shortRoute);
|
||||
});
|
||||
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
|
||||
const copyLink = async () => {
|
||||
if (!serverLink) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(serverLink);
|
||||
@@ -545,7 +537,8 @@ export function render({ navigate, route, chrome }) {
|
||||
} catch (error) {
|
||||
showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' });
|
||||
}
|
||||
});
|
||||
};
|
||||
content.querySelector('#channel-about-copy-row')?.addEventListener('click', copyLink);
|
||||
|
||||
const subscriptionButton = content.querySelector('#channel-about-subscription');
|
||||
subscriptionButton?.addEventListener('click', async () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
formatSol,
|
||||
getBalanceSol,
|
||||
getSolanaWalletFromStoredSecret,
|
||||
getWalletFromStoredClientKey,
|
||||
getWalletFromStoredBlockchainKey,
|
||||
getWalletFromStoredRootKey,
|
||||
solanaAddressFromPublicKeyBase64,
|
||||
transferSol,
|
||||
@@ -140,9 +140,7 @@ export function render({ navigate, route, chrome }) {
|
||||
const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
|
||||
const channelName = String(channel?.channelName || selector?.channelName || '').trim();
|
||||
const recipientChoices = [
|
||||
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
|
||||
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
|
||||
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
|
||||
].filter(Boolean);
|
||||
|
||||
content.innerHTML = `
|
||||
@@ -169,10 +167,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
<section class="channel-donate-section">
|
||||
<label class="field-label" for="channel-donate-sender-key">Перевести с моего счёта</label>
|
||||
<select class="select" id="channel-donate-sender-key">
|
||||
<option value="client-key">Client key</option>
|
||||
<option value="root-key">Root key</option>
|
||||
</select>
|
||||
<div class="channel-donate-address" id="channel-donate-sender-key">Blockchain key</div>
|
||||
<div class="channel-donate-address" id="channel-donate-sender-address">—</div>
|
||||
|
||||
<div class="channel-donate-balance-row">
|
||||
@@ -233,31 +228,13 @@ export function render({ navigate, route, chrome }) {
|
||||
};
|
||||
|
||||
const resolveSenderWallet = async () => {
|
||||
const keyId = String(senderSelect?.value || 'client-key');
|
||||
const keyId = 'blockchain-key';
|
||||
if (walletCache.has(keyId)) return walletCache.get(keyId);
|
||||
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.');
|
||||
|
||||
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 });
|
||||
}
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
||||
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
|
||||
|
||||
walletCache.set(keyId, wallet);
|
||||
return wallet;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { confirmDialog } from '../components/confirm-dialog.js';
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
|
||||
import { attachMessageMenu } from '../components/message-menu.js';
|
||||
@@ -8,6 +9,7 @@ import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
formatRelativeTime,
|
||||
longPressFeel,
|
||||
shareOrCopyLink,
|
||||
showToast,
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Обсуждение', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
const MSG_SUBTYPE_TEXT_POST = 10;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
|
||||
@@ -200,6 +202,13 @@ function allFeedSummaries() {
|
||||
});
|
||||
}
|
||||
|
||||
const channelTitleByLabel = new Map();
|
||||
|
||||
function rememberChannelTitle(label, channel) {
|
||||
const title = String(channel?.displayName || channel?.displayTitle || '').trim();
|
||||
if (label && title) channelTitleByLabel.set(label, title);
|
||||
}
|
||||
|
||||
function resolveChannelDisplayName(channelSelector) {
|
||||
const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber;
|
||||
const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash;
|
||||
@@ -214,7 +223,9 @@ function resolveChannelDisplayName(channelSelector) {
|
||||
&& normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash
|
||||
));
|
||||
if (!found) return '';
|
||||
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
|
||||
const label = `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
|
||||
rememberChannelTitle(label, found.channel);
|
||||
return label;
|
||||
}
|
||||
|
||||
function resolveChannelHeadingFromNode(node) {
|
||||
@@ -299,7 +310,9 @@ async function resolveChannelDisplayNameFromServer(channelSelector) {
|
||||
if (!row?.channel?.channelName) return '';
|
||||
|
||||
channelSelector.channelRootBlockHash = normalizeRouteHash(row?.channel?.channelRoot?.blockHash);
|
||||
return `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
|
||||
const label = `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
|
||||
rememberChannelTitle(label, row.channel);
|
||||
return label;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
@@ -446,7 +459,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
||||
<div class="modal" id="thread-blockchain-details-modal">
|
||||
<div class="modal-card stack blockchain-details-card">
|
||||
<h3 class="modal-title">Данные блокчейна сообщения</h3>
|
||||
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<p class="meta-muted">Это технические данные записи Сияния. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<div class="blockchain-details-grid">
|
||||
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
|
||||
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
|
||||
@@ -702,7 +715,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
}
|
||||
const timestamp = document.createElement('div');
|
||||
timestamp.className = 'channel-message-time';
|
||||
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
|
||||
timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '—';
|
||||
if (node?.createdAtMs) timestamp.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
|
||||
authorBlock.append(title, timestamp);
|
||||
authorTile.append(avatar, authorBlock);
|
||||
headRow.append(authorTile);
|
||||
@@ -786,7 +800,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter">${likes}</span>
|
||||
<span class="channel-action-counter">${Number(likes) > 0 ? likes : ''}</span>
|
||||
`;
|
||||
likeButton.setAttribute('aria-pressed', String(isLiked));
|
||||
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`);
|
||||
@@ -817,7 +831,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
<span class="channel-action-counter">${replies}</span>
|
||||
<span class="channel-action-counter">${Number(replies) > 0 ? replies : ''}</span>
|
||||
`;
|
||||
setActionTitle(replyButton, 'Ответить');
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
@@ -856,7 +870,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const discussionButton = document.createElement('button');
|
||||
discussionButton.type = 'button';
|
||||
discussionButton.className = 'ui-button channel-action-item';
|
||||
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span><span>${replies}</span>`;
|
||||
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span>${Number(replies) > 0 ? `<span>${replies}</span>` : ''}`;
|
||||
discussionButton.setAttribute('aria-label', `Открыть обсуждение, ответов: ${replies}`);
|
||||
discussionButton.addEventListener('click', () => handlers.onOpenThread(target));
|
||||
actions.append(likeButton, discussionButton, shareButton, replyButton);
|
||||
@@ -871,8 +885,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
setActionTitle(originalButton, 'Оригинал');
|
||||
originalButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const ok = window.confirm('Перейти к оригинальному сообщению?');
|
||||
if (!ok) return;
|
||||
const ownerLogin = extractLoginFromBlockchainName(repostTarget.blockchainName);
|
||||
if (!ownerLogin) return;
|
||||
handlers.navigate(makeShineMessageRoute({
|
||||
@@ -927,7 +939,12 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
});
|
||||
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
|
||||
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
|
||||
if (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) return;
|
||||
if (!await confirmDialog({
|
||||
title: 'Удалить сообщение?',
|
||||
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true,
|
||||
})) return;
|
||||
try { await handlers.onEdit(target, '', { isChannelPost, isDelete: true }); }
|
||||
catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
|
||||
} });
|
||||
@@ -1026,21 +1043,18 @@ export function render({ navigate, route, chrome }) {
|
||||
const threadHeaderButton = document.createElement('button');
|
||||
threadHeaderButton.type = 'button';
|
||||
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
const threadHeaderTitle = document.createElement('span');
|
||||
threadHeaderTitle.className = 'channel-header-title';
|
||||
threadHeaderTitle.textContent = 'Обсуждение';
|
||||
const threadHeaderChannel = document.createElement('span');
|
||||
threadHeaderChannel.className = 'channel-header-owner';
|
||||
threadHeaderChannel.textContent = '…';
|
||||
threadHeaderButton.append(threadHeaderTitle, threadHeaderChannel);
|
||||
threadHeaderButton.disabled = true;
|
||||
|
||||
const header = createTopBar({
|
||||
center: threadHeaderButton,
|
||||
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
actions: [
|
||||
{
|
||||
label: '↑',
|
||||
title: 'К списку каналов',
|
||||
ariaLabel: 'К списку каналов',
|
||||
className: 'channel-thread-list-btn',
|
||||
onClick: () => navigate('channels-list'),
|
||||
},
|
||||
],
|
||||
});
|
||||
header.classList.add('channel-thread-topbar');
|
||||
chrome?.setTopbar(header);
|
||||
@@ -1050,7 +1064,7 @@ export function render({ navigate, route, chrome }) {
|
||||
statusBox.style.display = 'none';
|
||||
|
||||
const ensureActive = () => {
|
||||
if (disposed) throw new Error('Экран треда уже закрыт.');
|
||||
if (disposed) throw new Error('Экран обсуждения уже закрыт.');
|
||||
};
|
||||
|
||||
const showStatus = (message) => {
|
||||
@@ -1175,10 +1189,10 @@ export function render({ navigate, route, chrome }) {
|
||||
onShare: async (target) => {
|
||||
try {
|
||||
const routePath = buildThreadRouteFromTarget(target, selector);
|
||||
if (!routePath) throw new Error('Не удалось подготовить ссылку на тред.');
|
||||
if (!routePath) throw new Error('Не удалось подготовить ссылку на обсуждение.');
|
||||
const result = await shareOrCopyLink({
|
||||
title: 'SHiNE · Тред',
|
||||
text: 'Сообщение из треда SHiNE',
|
||||
title: 'Сияние · Обсуждение',
|
||||
text: 'Сообщение из обсуждения в Сиянии',
|
||||
url: buildAbsoluteRouteUrl(routePath),
|
||||
});
|
||||
if (disposed) return;
|
||||
@@ -1192,7 +1206,7 @@ export function render({ navigate, route, chrome }) {
|
||||
onOpenThread: (target) => {
|
||||
const routePath = buildThreadRouteFromTarget(target, selector);
|
||||
if (!routePath) {
|
||||
showStatus('Не удалось определить путь до треда.');
|
||||
showStatus('Не удалось определить ссылку на обсуждение.');
|
||||
return;
|
||||
}
|
||||
navigate(routePath);
|
||||
@@ -1264,14 +1278,14 @@ export function render({ navigate, route, chrome }) {
|
||||
showStatus('');
|
||||
selector = parseThreadSelector(route);
|
||||
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderChannel.textContent = '…';
|
||||
threadHeaderButton.disabled = true;
|
||||
threadHeaderButton.onclick = null;
|
||||
|
||||
if (!selector) {
|
||||
const invalid = document.createElement('div');
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
invalid.textContent = 'Обсуждение не найдено: неверная ссылка.';
|
||||
screen.append(invalid);
|
||||
return;
|
||||
}
|
||||
@@ -1382,7 +1396,8 @@ export function render({ navigate, route, chrome }) {
|
||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
||||
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
||||
if (threadHeaderButton) {
|
||||
threadHeaderButton.textContent = `Обсуждение · ${resolvedChannelTitle}`;
|
||||
threadHeaderChannel.textContent = `в канале «${channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle}»`;
|
||||
threadHeaderButton.title = 'Открыть канал';
|
||||
threadHeaderButton.disabled = false;
|
||||
threadHeaderButton.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
@@ -1422,8 +1437,8 @@ export function render({ navigate, route, chrome }) {
|
||||
composer.className = 'channel-composer';
|
||||
const reply = document.createElement('button');
|
||||
reply.type = 'button';
|
||||
reply.className = 'primary-btn';
|
||||
reply.textContent = state.session.isAuthorized ? 'Написать ответ' : 'Войти и ответить';
|
||||
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({
|
||||
@@ -1448,7 +1463,7 @@ export function render({ navigate, route, chrome }) {
|
||||
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Пока нет ответов. Начните обсуждение.';
|
||||
descendantsWrap.append(empty);
|
||||
}
|
||||
@@ -1478,7 +1493,7 @@ export function render({ navigate, route, chrome }) {
|
||||
if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить обсуждение.')); return; }
|
||||
const failed = document.createElement('div');
|
||||
failed.className = 'card meta-muted';
|
||||
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
||||
failed.textContent = `Не удалось загрузить обсуждение: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.className = 'primary-btn';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { confirmDialog } from '../components/confirm-dialog.js';
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
|
||||
import { getPreviousTrackedPath } from '../router.js';
|
||||
import { attachMessageMenu } from '../components/message-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
@@ -13,6 +15,9 @@ import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
dayKey,
|
||||
formatClockTime,
|
||||
formatDayLabel,
|
||||
formatRelativeTime,
|
||||
longPressFeel,
|
||||
shareOrCopyLink,
|
||||
@@ -39,7 +44,7 @@ import {
|
||||
} from '../services/shine-routes.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
@@ -828,7 +833,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
||||
<div class="modal" id="blockchain-details-modal">
|
||||
<div class="modal-card stack blockchain-details-card">
|
||||
<h3 class="modal-title">Данные блокчейна сообщения</h3>
|
||||
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<p class="meta-muted">Это технические данные записи Сияния. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<div class="blockchain-details-grid">
|
||||
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
|
||||
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
|
||||
@@ -874,8 +879,8 @@ function renderDraftAttachments(container, attachments) {
|
||||
button.className = 'ui-button draft-attachment-chip';
|
||||
button.textContent = `${item.name} · ${item.ar}`;
|
||||
button.title = 'Нажмите, чтобы убрать вложение';
|
||||
button.addEventListener('click', () => {
|
||||
const ok = window.confirm('Отменить вложение?');
|
||||
button.addEventListener('click', async () => {
|
||||
const ok = await confirmDialog({ title: 'Убрать вложение?', text: item.name, confirmLabel: 'Убрать', danger: true });
|
||||
if (!ok) return;
|
||||
attachments.splice(index, 1);
|
||||
renderDraftAttachments(container, attachments);
|
||||
@@ -1063,7 +1068,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
|
||||
if (list) {
|
||||
if (!items.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Оглавление в этом канале пока не добавлялось.';
|
||||
list.append(empty);
|
||||
} else {
|
||||
@@ -1169,18 +1174,19 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
|
||||
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
|
||||
const typeWrap = document.createElement('label');
|
||||
typeWrap.className = 'channel-editor__type';
|
||||
typeWrap.textContent = 'Тип сообщения';
|
||||
typeWrap.innerHTML = '<span class="sr-only">Тип сообщения</span>';
|
||||
const typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'select';
|
||||
typeSelect.className = 'select channel-editor__type-select';
|
||||
typeSelect.title = 'Тип сообщения';
|
||||
typeSelect.innerHTML = `
|
||||
<option value="10">Публикация</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление</option>
|
||||
`;
|
||||
typeWrap.append(typeSelect);
|
||||
|
||||
return openChannelEditor({
|
||||
id: 'channel-message-modal',
|
||||
title: 'Новое сообщение',
|
||||
title: 'Новая запись',
|
||||
submitLabel: 'Опубликовать',
|
||||
placeholder: 'Напишите сообщение',
|
||||
key: `channel-post:${channelName}`,
|
||||
@@ -1611,14 +1617,15 @@ function mapChannelMetaEvent(event, fallbackChannel) {
|
||||
};
|
||||
}
|
||||
|
||||
function renderChannelMetaEventCard(event) {
|
||||
function renderChannelMetaEventCard(event, dayLabel = '') {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card channel-system-event-card';
|
||||
const label = event.kind === 'created'
|
||||
? 'Создан канал'
|
||||
? 'Канал создан'
|
||||
: 'Изменено описание канала';
|
||||
const shownLabel = dayLabel ? `${label} · ${dayLabel.toLocaleLowerCase('ru-RU')}` : label;
|
||||
card.innerHTML = `
|
||||
<span class="channel-system-event-card__label">${escapeHtml(label)}</span>
|
||||
<span class="channel-system-event-card__label">${escapeHtml(shownLabel)}</span>
|
||||
`;
|
||||
card.addEventListener('click', () => {
|
||||
openChannelMetaDetailsModal({
|
||||
@@ -1856,6 +1863,7 @@ function renderPostCard(post, {
|
||||
const timestamp = document.createElement('div');
|
||||
timestamp.className = 'channel-message-time';
|
||||
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
||||
if (post.timestampMs) timestamp.title = new Date(post.timestampMs).toLocaleString('ru-RU');
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('span');
|
||||
editedMarker.type = 'button';
|
||||
@@ -1906,6 +1914,15 @@ function renderPostCard(post, {
|
||||
navigate(makeProfileRoute(cleanLogin));
|
||||
});
|
||||
|
||||
const isOwnerPost = !!selector
|
||||
&& !isDiarySelector(selector)
|
||||
&& !!post.messageRef?.blockchainName
|
||||
&& String(post.messageRef.blockchainName).trim().toLowerCase() === String(selector.ownerBlockchainName || '').trim().toLowerCase();
|
||||
if (isOwnerPost) {
|
||||
card.classList.add('channel-message-card--owner');
|
||||
authorTile.hidden = true;
|
||||
}
|
||||
|
||||
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
|
||||
const parsedBody = parseMessageAttachments(post.body);
|
||||
|
||||
@@ -1990,7 +2007,7 @@ function renderPostCard(post, {
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
||||
<span class="channel-action-counter">${post.likesCount || ''}</span>
|
||||
`;
|
||||
likeButton.setAttribute('aria-pressed', String(isLiked));
|
||||
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${post.likesCount || 0}`);
|
||||
@@ -2015,7 +2032,7 @@ function renderPostCard(post, {
|
||||
discussionButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Обсуждение</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || ''}</span>
|
||||
`;
|
||||
setActionTitle(discussionButton, `Открыть обсуждение, ответов: ${post.repliesCount || 0}`);
|
||||
discussionButton.addEventListener('click', (event) => {
|
||||
@@ -2030,7 +2047,7 @@ function renderPostCard(post, {
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || ''}</span>
|
||||
`;
|
||||
setActionTitle(replyButton, 'Ответить');
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
@@ -2067,7 +2084,8 @@ function renderPostCard(post, {
|
||||
await onShare(route);
|
||||
});
|
||||
|
||||
actions.append(shareButton, replyButton);
|
||||
actions.append(shareButton);
|
||||
menuItems.unshift({ label: 'Ответить', action: () => replyButton.click() });
|
||||
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
|
||||
const originalBtn = document.createElement('button');
|
||||
originalBtn.type = 'button';
|
||||
@@ -2081,8 +2099,6 @@ function renderPostCard(post, {
|
||||
event.stopPropagation();
|
||||
const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName);
|
||||
if (!ownerLogin) return;
|
||||
const ok = window.confirm('Перейти к оригинальному сообщению?');
|
||||
if (!ok) return;
|
||||
navigate(makeShineMessageRoute({
|
||||
ownerLogin,
|
||||
messageBlockchainName: post.targetRef.blockchainName,
|
||||
@@ -2135,13 +2151,28 @@ function renderPostCard(post, {
|
||||
});
|
||||
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
|
||||
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
|
||||
if (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) return;
|
||||
if (!await confirmDialog({
|
||||
title: 'Удалить сообщение?',
|
||||
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true,
|
||||
})) return;
|
||||
try { await onEdit(post.messageRef, '', { isDelete: true }); }
|
||||
catch (error) { if (isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
|
||||
} });
|
||||
}
|
||||
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
|
||||
attachMessageMenu(card, headRow, menuItems);
|
||||
if (isOwnerPost) {
|
||||
if (post.timestampMs) timestamp.textContent = formatClockTime(post.timestampMs);
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'channel-message-meta';
|
||||
const moreButton = headRow.querySelector('.channel-message-more');
|
||||
meta.append(timestamp);
|
||||
if (moreButton) meta.append(moreButton);
|
||||
actions.append(meta);
|
||||
if (!headRow.querySelector('.channel-message-type-button')) headRow.hidden = true;
|
||||
}
|
||||
card.append(actions);
|
||||
return card;
|
||||
}
|
||||
@@ -2166,8 +2197,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
|
||||
const addMessageButton = document.createElement('button');
|
||||
addMessageButton.type = 'button';
|
||||
addMessageButton.className = 'primary-btn channel-main-action channel-main-action--compose';
|
||||
addMessageButton.textContent = 'Добавить сообщение';
|
||||
addMessageButton.className = 'channel-compose-bar';
|
||||
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) => {
|
||||
animatePress(event.currentTarget);
|
||||
handlers.onAddMessage();
|
||||
@@ -2199,7 +2231,17 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
});
|
||||
|
||||
if (feedItems.length) {
|
||||
let lastDayKey = '';
|
||||
feedItems.forEach((item) => {
|
||||
const itemDayKey = dayKey(item.timestampMs);
|
||||
const startsNewDay = !!itemDayKey && itemDayKey !== lastDayKey;
|
||||
if (startsNewDay) lastDayKey = itemDayKey;
|
||||
if (startsNewDay && item.type !== 'meta') {
|
||||
const separator = document.createElement('div');
|
||||
separator.className = 'channel-day-separator';
|
||||
separator.innerHTML = `<span>${escapeHtml(formatDayLabel(item.timestampMs))}</span>`;
|
||||
feed.append(separator);
|
||||
}
|
||||
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
|
||||
const unreadLine = document.createElement('div');
|
||||
unreadLine.className = 'card channel-unread-line';
|
||||
@@ -2208,7 +2250,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
unreadLineInserted = true;
|
||||
}
|
||||
if (item.type === 'meta') {
|
||||
feed.append(renderChannelMetaEventCard(item.event));
|
||||
feed.append(renderChannelMetaEventCard(item.event, startsNewDay ? formatDayLabel(item.timestampMs) : ''));
|
||||
return;
|
||||
}
|
||||
const row = renderPostCard(item.post, {
|
||||
@@ -2232,7 +2274,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
});
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = channelData.isDiary
|
||||
? 'К сожалению, у вас пока еще ничего нет в Дневнике.'
|
||||
: 'Ждем ваших начинаний';
|
||||
@@ -2400,26 +2442,6 @@ export function render({ navigate, route, chrome }) {
|
||||
const aboutRoute = makeShineChannelAboutRoute(routeArgs);
|
||||
const donateRoute = makeShineChannelDonateRoute(routeArgs);
|
||||
const items = [];
|
||||
if (apiData?.isOwnChannel && !apiData?.isDiary) {
|
||||
items.push({
|
||||
label: 'Добавить сообщение',
|
||||
action: () => {
|
||||
openAddMessageModal({
|
||||
channelName: apiData?.channel?.name || '',
|
||||
navigate,
|
||||
isActive: () => !disposed,
|
||||
onSubmit: async ({ text: bodyText, msgSubType }) => {
|
||||
try {
|
||||
await onAddPost(bodyText, msgSubType);
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
items.push({ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } });
|
||||
items.push({
|
||||
label: 'Оглавление',
|
||||
@@ -2479,9 +2501,6 @@ export function render({ navigate, route, chrome }) {
|
||||
try {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (!apiData?.selector) throw new Error('Не удалось определить канал для подписки.');
|
||||
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
|
||||
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
|
||||
if (!ok) return;
|
||||
|
||||
await authService.addBlockFollowChannel({
|
||||
login,
|
||||
@@ -2694,7 +2713,7 @@ export function render({ navigate, route, chrome }) {
|
||||
if (!routeToShare) throw new Error('Не удалось подготовить ссылку на сообщение.');
|
||||
const result = await shareOrCopyLink({
|
||||
title: 'SHiNE · Каналы',
|
||||
text: 'Тред из канала SHiNE',
|
||||
text: 'Обсуждение в Сиянии',
|
||||
url: buildAbsoluteRouteUrl(routeToShare),
|
||||
});
|
||||
if (disposed) return;
|
||||
@@ -2806,7 +2825,13 @@ export function render({ navigate, route, chrome }) {
|
||||
if (disposed) return;
|
||||
const seq = ++refreshSeq;
|
||||
const hadContent = !!screen.querySelector('.channel-feed');
|
||||
const restorePosition = hadContent ? getChannelScrollRoot()?.scrollTop : readChannelPosition(positionKey);
|
||||
// Прежнее место в ленте восстанавливаем только при возврате из обсуждения / «О канале».
|
||||
// Открытие канала из списка или другого раздела ведёт к свежим постам (или к непрочитанным).
|
||||
const previousPath = getPreviousTrackedPath() || '';
|
||||
const returnedFromChannelPage = /^\/(SHiNE|thread)\//.test(previousPath) || /\/(about|donate)$/.test(previousPath);
|
||||
const restorePosition = hadContent
|
||||
? getChannelScrollRoot()?.scrollTop
|
||||
: (returnedFromChannelPage ? readChannelPosition(positionKey) : undefined);
|
||||
if (!hadContent) clearContent();
|
||||
activeChannelData = null;
|
||||
activeOpenEntrypointHistory = null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { channels as mockChannels } from '../mock-data.js';
|
||||
import { authService, setChannelsFeed, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
@@ -5,7 +6,7 @@ import { parseMessageAttachments } from '../services/attachment-format.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
formatRelativeTime,
|
||||
formatListTime,
|
||||
readChannelNotificationsState,
|
||||
showToast,
|
||||
softHaptic,
|
||||
@@ -755,7 +756,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
messagePreview: cleanChannelMessagePreview(lastMessage.text),
|
||||
messagesCount: Number(summary?.messagesCount || 0),
|
||||
unreadCount: Number(summary?.unreadCount || 0),
|
||||
lastMessageAt: Number(lastMessage.createdAtMs || 0),
|
||||
lastMessageAt: Number(lastMessage.createdAtMs || summary?.channel?.metaUpdatedAtMs || 0),
|
||||
isOwnChannel: isOwn,
|
||||
isSubscribed: !isOwn,
|
||||
notificationsEnabled: notificationsState[rowId] === true,
|
||||
@@ -964,12 +965,13 @@ function renderChannelMain(channel) {
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'channel-row-time';
|
||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
||||
time.textContent = channel.lastMessageAt ? formatListTime(channel.lastMessageAt) : '';
|
||||
titleLine.append(title, time);
|
||||
|
||||
const technical = document.createElement('p');
|
||||
technical.className = 'channel-row-technical';
|
||||
technical.textContent = `@${channel.ownerName || 'автор'}`;
|
||||
technical.hidden = !!channel.isOwnChannel;
|
||||
|
||||
if (channel.channelDescription) {
|
||||
const desc = document.createElement('p');
|
||||
@@ -984,6 +986,7 @@ function renderChannelMain(channel) {
|
||||
const preview = document.createElement('p');
|
||||
preview.className = 'channel-row-message';
|
||||
preview.textContent = channel.messagePreview || 'Пока нет сообщений';
|
||||
if (!channel.messagePreview) preview.classList.add('is-empty');
|
||||
|
||||
previewLine.append(preview);
|
||||
|
||||
@@ -1173,7 +1176,7 @@ export function render({ navigate, route, chrome }) {
|
||||
controls.className = 'channels-list-controls';
|
||||
const searchWrap = document.createElement('div');
|
||||
searchWrap.className = 'channels-inline-search';
|
||||
searchWrap.innerHTML = '<span aria-hidden="true">⌕</span><span class="sr-only">Поиск каналов</span>';
|
||||
searchWrap.innerHTML = `<span class="channels-inline-search__icon" aria-hidden="true">${iconHtml('search')}</span><span class="sr-only">Поиск каналов</span>`;
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.type = 'search';
|
||||
searchInput.value = listState.query;
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
stopAllTwemojiAnimations,
|
||||
} from '../components/emoji-picker.js?v=202607152130';
|
||||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { dayKey, formatClockTime, formatDayLabel, showToast } from '../services/channels-ux.js';
|
||||
import { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
||||
import { isDmFileTransferEnabled } from '../services/feature-settings.js';
|
||||
import {
|
||||
@@ -44,6 +44,7 @@ import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-route
|
||||
export const pageMeta = {
|
||||
id: 'chat-view',
|
||||
title: 'Чат',
|
||||
hideToolbar: true,
|
||||
shellMode: {
|
||||
topFade: true,
|
||||
bottomFade: true,
|
||||
@@ -81,7 +82,7 @@ function chatRelationLabel(value) {
|
||||
case 'close_friend': return 'Близкий друг';
|
||||
case 'friend': return 'Друг';
|
||||
case 'contact': return 'Контакт';
|
||||
default: return 'Не в контактах';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +118,9 @@ function createChatHeaderParts(login, navigate) {
|
||||
const lastName = String(currentPeer?.lastName || '').trim();
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||
nameEl.textContent = fullName || cleanLogin;
|
||||
metaEl.textContent = `${cleanLogin} · ${chatRelationLabel(currentPeer?.relationType)}`;
|
||||
const relation = chatRelationLabel(currentPeer?.relationType);
|
||||
metaEl.textContent = [fullName ? `@${cleanLogin}` : '', relation].filter(Boolean).join(' · ');
|
||||
metaEl.hidden = !metaEl.textContent;
|
||||
const avatar = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
firstName,
|
||||
@@ -1058,7 +1061,19 @@ function renderLog(
|
||||
const messages = getChatMessages(chatId);
|
||||
let unreadSeparatorInserted = false;
|
||||
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
|
||||
let lastDayKey = '';
|
||||
messages.forEach((msg) => {
|
||||
const msgTimeMs = resolveMessageTimeMs(msg);
|
||||
const msgDayKey = dayKey(msgTimeMs);
|
||||
if (msgDayKey && msgDayKey !== lastDayKey) {
|
||||
lastDayKey = msgDayKey;
|
||||
const daySep = document.createElement('div');
|
||||
daySep.className = 'dm-day-separator';
|
||||
const dayLabel = document.createElement('span');
|
||||
dayLabel.textContent = formatDayLabel(msgTimeMs);
|
||||
daySep.append(dayLabel);
|
||||
list.append(daySep);
|
||||
}
|
||||
const isUnreadBoundary = showUnreadSeparator
|
||||
&& !unreadSeparatorInserted
|
||||
&& separatorMessageKey
|
||||
@@ -1176,7 +1191,8 @@ function renderLog(
|
||||
|
||||
const timeNode = document.createElement('span');
|
||||
timeNode.className = 'bubble-time';
|
||||
timeNode.textContent = formatMessageTime(resolveMessageTimeMs(msg));
|
||||
timeNode.textContent = formatClockTime(resolveMessageTimeMs(msg));
|
||||
timeNode.title = formatMessageTime(resolveMessageTimeMs(msg));
|
||||
metaNode.append(timeNode);
|
||||
|
||||
const status = resolveDeliveryStatus(messages, msg);
|
||||
@@ -1812,7 +1828,7 @@ export function render({ navigate, route, chrome }) {
|
||||
updateSendButtonMode();
|
||||
const denied = error?.name === 'NotAllowedError' || error?.name === 'SecurityError';
|
||||
showToast(
|
||||
denied ? 'Нужно разрешить SHiNE доступ к микрофону.' : (error?.message || 'Не удалось начать запись'),
|
||||
denied ? 'Нужно разрешить Сиянию доступ к микрофону.' : (error?.message || 'Не удалось начать запись'),
|
||||
{ kind: 'error', timeoutMs: 2600 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
@@ -65,15 +66,17 @@ export function render({navigate, chrome}) {
|
||||
let searchSeq = 0;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'input dm-input contact-search-input';
|
||||
input.className = 'search-field__input contact-search-input';
|
||||
input.type = 'text';
|
||||
input.name = 'contact';
|
||||
input.placeholder = 'Введите начало логина';
|
||||
input.placeholder = 'Логин или его начало';
|
||||
input.setAttribute('aria-label', 'Логин для поиска');
|
||||
input.enterKeyHint = 'search';
|
||||
input.autocomplete = 'off';
|
||||
input.maxLength = 80;
|
||||
|
||||
const resultsCard = document.createElement('section');
|
||||
resultsCard.className = 'card stack contact-search-results-card';
|
||||
resultsCard.className = 'stack contact-search-results';
|
||||
resultsCard.hidden = true;
|
||||
|
||||
const status = document.createElement('p');
|
||||
@@ -143,13 +146,13 @@ export function render({navigate, chrome}) {
|
||||
searchTimer = window.setTimeout(() => {
|
||||
searchTimer = 0;
|
||||
void runSearch();
|
||||
}, 2000);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const searchButton = document.createElement('button');
|
||||
searchButton.className = 'primary-btn dm-send-btn';
|
||||
searchButton.className = 'search-field__action';
|
||||
searchButton.type = 'button';
|
||||
searchButton.textContent = 'Поиск';
|
||||
searchButton.textContent = 'Найти';
|
||||
searchButton.addEventListener('click', async () => {
|
||||
if (searchTimer) {
|
||||
window.clearTimeout(searchTimer);
|
||||
@@ -161,19 +164,21 @@ export function render({navigate, chrome}) {
|
||||
input.addEventListener('input', () => {
|
||||
scheduleSearch();
|
||||
});
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
searchButton.click();
|
||||
});
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'contact-search-actions';
|
||||
controls.append(searchButton);
|
||||
|
||||
const formCard = document.createElement('section');
|
||||
formCard.className = 'card stack contact-search-form-card';
|
||||
formCard.append(input, controls);
|
||||
const formCard = document.createElement('label');
|
||||
formCard.className = 'search-field contact-search-field';
|
||||
formCard.innerHTML = `<span class="search-field__icon" aria-hidden="true">${iconHtml('search')}</span>`;
|
||||
formCard.append(input, searchButton);
|
||||
|
||||
resultsCard.append(status, resultsList);
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Поиск контактов',
|
||||
title: 'Найти человека',
|
||||
back: { label: '←', onClick: () => navigate('messages-list') },
|
||||
}));
|
||||
screen.append(
|
||||
|
||||
@@ -207,7 +207,7 @@ async function forceUiUpdateNow() {
|
||||
function showClientUpdateHelp() {
|
||||
window.alert(
|
||||
'Если UI не обновился:\n\n'
|
||||
+ '1) Закройте вкладки с SHiNE.\n'
|
||||
+ '1) Закройте вкладки с Сиянием.\n'
|
||||
+ `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n`
|
||||
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n'
|
||||
+ `4) Откройте ${defaultServerHttp} заново и выполните вход.\n`
|
||||
@@ -256,17 +256,17 @@ export function render({navigate, chrome}) {
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack settings-developer-card';
|
||||
card.className = 'nav-list settings-developer-card';
|
||||
card.innerHTML = `
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-app-log">Лог приложения</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-install">Как установить PWA</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-upload-avatar">Загрузить аватар</button>
|
||||
<button class="nav-row" type="button" id="settings-force-ui-update"><span class="nav-row__label">Принудительно обновить UI</span></button>
|
||||
<button class="nav-row" type="button" id="settings-force-update-help"><span class="nav-row__label">Клиент не обновляется?</span></button>
|
||||
<button class="nav-row" type="button" id="settings-ui-error-reporting"><span class="nav-row__label">Отправлять ошибки на сервер</span></button>
|
||||
<button class="nav-row" type="button" id="settings-solana-users-init"><span class="nav-row__label">Solana: первичная настройка регистрации</span></button>
|
||||
<button class="nav-row" type="button" id="settings-solana-rpc-check"><span class="nav-row__label">Solana: проверить публичный узел</span></button>
|
||||
<button class="nav-row" type="button" id="settings-app-log"><span class="nav-row__label">Лог приложения</span></button>
|
||||
<button class="nav-row" type="button" id="settings-pwa-diagnostics"><span class="nav-row__label">Диагностика PWA / Push</span></button>
|
||||
<button class="nav-row" type="button" id="settings-pwa-install"><span class="nav-row__label">Установка на главный экран</span></button>
|
||||
<button class="nav-row" type="button" id="settings-upload-avatar"><span class="nav-row__label">Загрузить аватар</span></button>
|
||||
`;
|
||||
|
||||
const appLogBtn = card.querySelector('#settings-app-log');
|
||||
@@ -307,16 +307,22 @@ export function render({navigate, chrome}) {
|
||||
}
|
||||
});
|
||||
|
||||
// Меняем только подпись строки: textContent на самой кнопке стирал .nav-row__label, и строка выбивалась по кеглю.
|
||||
const setPwaLabel = (text) => {
|
||||
const labelEl = pwaInstallBtn.querySelector('.nav-row__label');
|
||||
if (labelEl) labelEl.textContent = text;
|
||||
else pwaInstallBtn.textContent = text;
|
||||
};
|
||||
const syncPwaButtonLabel = () => {
|
||||
if (isStandalonePwaMode()) {
|
||||
pwaInstallBtn.textContent = 'PWA установлено (проверить WebPush)';
|
||||
setPwaLabel('Приложение установлено — проверить уведомления');
|
||||
return;
|
||||
}
|
||||
if (canInstallPwa()) {
|
||||
pwaInstallBtn.textContent = 'Зарегистрировать PWA';
|
||||
setPwaLabel('Установить как приложение');
|
||||
return;
|
||||
}
|
||||
pwaInstallBtn.textContent = 'Как установить PWA';
|
||||
setPwaLabel('Установка на главный экран');
|
||||
};
|
||||
|
||||
const unsubscribeInstallAvailability = onPwaInstallAvailabilityChange(() => {
|
||||
|
||||
@@ -250,7 +250,7 @@ export function render({navigate, chrome}) {
|
||||
passwordDialog.style.inset = '0';
|
||||
passwordDialog.style.zIndex = '30';
|
||||
passwordDialog.innerHTML = `
|
||||
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.72); backdrop-filter:blur(4px);" data-action="close-dialog"></div>
|
||||
<div style="position:absolute; inset:0; background:var(--scrim); backdrop-filter:blur(4px);" data-action="close-dialog"></div>
|
||||
<div class="card stack" style="position:absolute; left:50%; top:24px; width:min(calc(100vw - 32px), 360px); transform:translateX(-50%); gap:12px; box-shadow:var(--shadow);">
|
||||
<div class="stack" style="gap:6px;">
|
||||
<p class="field-label" id="pairing-dialog-title">Задать дополнительный пароль</p>
|
||||
@@ -292,7 +292,7 @@ export function render({navigate, chrome}) {
|
||||
dialogOverlay.style.inset = '0';
|
||||
dialogOverlay.style.zIndex = '2';
|
||||
dialogOverlay.innerHTML = `
|
||||
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.78);"></div>
|
||||
<div style="position:absolute; inset:0; background:var(--scrim);"></div>
|
||||
<div class="card stack" style="position:absolute; left:50%; top:50%; width:min(calc(100% - 32px), 320px); transform:translate(-50%, -50%); gap:12px; box-shadow:var(--shadow);">
|
||||
<p class="field-label" id="pairing-dialog-overlay-title">Ошибка</p>
|
||||
<p class="meta-muted" id="pairing-dialog-overlay-text"></p>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
isSessionInvalidError,
|
||||
@@ -11,24 +12,49 @@ import {
|
||||
export const pageMeta = { id: 'device-view', title: 'Устройства' };
|
||||
|
||||
function formatSessionType(sessionType) {
|
||||
if (Number(sessionType) === 100) return 'Homeserver';
|
||||
if (Number(sessionType) === 50) return 'Wallet';
|
||||
if (Number(sessionType) === 1) return 'Client';
|
||||
return `Type ${Number(sessionType) || 0}`;
|
||||
if (Number(sessionType) === 100) return 'Домашний сервер';
|
||||
if (Number(sessionType) === 50) return 'Кошелёк';
|
||||
if (Number(sessionType) === 1) return 'Приложение';
|
||||
return `Тип ${Number(sessionType) || 0}`;
|
||||
}
|
||||
|
||||
// «Mozilla/5.0 (X11; Linux x86_64) … Chrome/139 …» → «Chrome · Linux».
|
||||
function describeClient(userAgent) {
|
||||
const ua = String(userAgent || '');
|
||||
if (!ua) return 'Неизвестное устройство';
|
||||
const browser = /Edg\//.test(ua) ? 'Edge'
|
||||
: /OPR\/|Opera/.test(ua) ? 'Opera'
|
||||
: /Firefox\//.test(ua) ? 'Firefox'
|
||||
: /YaBrowser\//.test(ua) ? 'Яндекс Браузер'
|
||||
: /Chrome\//.test(ua) ? 'Chrome'
|
||||
: /Safari\//.test(ua) ? 'Safari'
|
||||
: '';
|
||||
const os = /iPhone/.test(ua) ? 'iPhone'
|
||||
: /iPad/.test(ua) ? 'iPad'
|
||||
: /Android/.test(ua) ? 'Android'
|
||||
: /Windows/.test(ua) ? 'Windows'
|
||||
: /Mac OS X|Macintosh/.test(ua) ? 'macOS'
|
||||
: /Linux|X11/.test(ua) ? 'Linux'
|
||||
: '';
|
||||
const parts = [browser, os].filter(Boolean);
|
||||
return parts.length ? parts.join(' · ') : ua.slice(0, 40);
|
||||
}
|
||||
|
||||
// «сегодня, 02:20» / «вчера, 23:10» / «24 сентября, 18:05».
|
||||
function formatSessionTime(ms) {
|
||||
return new Date(ms).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const date = new Date(ms);
|
||||
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
const startOf = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const days = Math.round((startOf(new Date()) - startOf(date)) / 86400000);
|
||||
if (days === 0) return `сегодня, ${time}`;
|
||||
if (days === 1) return `вчера, ${time}`;
|
||||
const sameYear = date.getFullYear() === new Date().getFullYear();
|
||||
const day = date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }) });
|
||||
return `${day}, ${time}`;
|
||||
}
|
||||
|
||||
function formatOnlineStatus(onlineOnThisServer) {
|
||||
return onlineOnThisServer ? 'Online now' : 'Offline';
|
||||
return onlineOnThisServer ? 'в сети' : 'не в сети';
|
||||
}
|
||||
|
||||
function sortSessionsByOnline(sessions = []) {
|
||||
@@ -47,15 +73,15 @@ export function render({navigate, chrome}) {
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Устройства',
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
actions: [{ icon: 'refresh', title: 'Обновить список сеансов', ariaLabel: 'Обновить список сеансов', id: 'reload-sessions', onClick: () => reloadSessions() }],
|
||||
}));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card stack';
|
||||
actions.className = 'nav-list';
|
||||
actions.innerHTML = `
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="pair-by-code-btn">Подключить по коду</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="connect-device-btn">Другие способы подключения</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
||||
<button class="nav-row" type="button" id="pair-by-code-btn"><span class="nav-row__label">Подключить по коду</span><span class="nav-row__hint">Войти на другом устройстве по короткому коду</span></button>
|
||||
<button class="nav-row" type="button" id="connect-device-btn"><span class="nav-row__label">Другие способы подключения</span></button>
|
||||
<button class="nav-row" type="button" id="show-keys-btn"><span class="nav-row__label">Ключи этого устройства</span></button>
|
||||
`;
|
||||
|
||||
actions.querySelector('#pair-by-code-btn').addEventListener('click', () => navigate('device-pairing-view'));
|
||||
@@ -63,7 +89,7 @@ export function render({navigate, chrome}) {
|
||||
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
||||
|
||||
const sessionsBlock = document.createElement('div');
|
||||
sessionsBlock.className = 'card stack';
|
||||
sessionsBlock.className = 'stack device-sessions';
|
||||
|
||||
const buildList = () => {
|
||||
sessionsBlock.innerHTML = '';
|
||||
@@ -75,22 +101,25 @@ export function render({navigate, chrome}) {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'ui-button session-item';
|
||||
item.type = 'button';
|
||||
const sessionTypeText = formatSessionType(session.sessionType);
|
||||
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : '';
|
||||
const isWebClient = Number(session.sessionType) === 1 && /web/i.test(String(session.clientPlatform || ''));
|
||||
const sessionTypeText = isWebClient ? 'Браузер' : formatSessionType(session.sessionType);
|
||||
const sessionPlatformText = !isWebClient && session.clientPlatform ? ` · ${session.clientPlatform}` : '';
|
||||
const onlineStatusText = formatOnlineStatus(!!session.onlineOnThisServer);
|
||||
const onlineStatusClass = session.onlineOnThisServer ? 'session-status session-status--online' : 'session-status';
|
||||
item.innerHTML = `
|
||||
<div class="row" style="align-items:flex-start;">
|
||||
<div class="stack" style="gap:4px; text-align:left;">
|
||||
<strong>${session.clientInfoFromClient || 'unknown client'}</strong>
|
||||
<span class="meta-muted"><strong>Type:</strong> ${sessionTypeText}${sessionPlatformText}</span>
|
||||
<span class="${onlineStatusClass}"><strong>Status:</strong> ${onlineStatusText}</span>
|
||||
<span class="meta-muted">${session.geo || 'unknown'}</span>
|
||||
</div>
|
||||
<span class="session-item__main">
|
||||
<strong class="session-item__title"></strong>
|
||||
<span class="meta-muted">${sessionTypeText}${sessionPlatformText} · <span class="${onlineStatusClass}">${onlineStatusText}</span></span>
|
||||
${session.geo && session.geo !== 'unknown' ? '<span class="meta-muted session-item__geo"></span>' : ''}
|
||||
</span>
|
||||
<span class="session-item__side">
|
||||
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
|
||||
</div>
|
||||
${isCurrent ? '<div><span class="session-current-badge">Текущий сеанс</span></div>' : ''}
|
||||
</span>
|
||||
`;
|
||||
item.querySelector('.session-item__title').textContent = describeClient(session.clientInfoFromClient);
|
||||
item.title = String(session.clientInfoFromClient || '');
|
||||
const geoEl = item.querySelector('.session-item__geo');
|
||||
if (geoEl) geoEl.textContent = session.geo;
|
||||
item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`));
|
||||
return item;
|
||||
};
|
||||
@@ -103,60 +132,36 @@ export function render({navigate, chrome}) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMenu = document.createElement('div');
|
||||
currentMenu.className = 'stack';
|
||||
currentMenu.innerHTML = '<p class="meta-muted">Текущий сеанс</p>';
|
||||
currentMenu.append(createSessionItem(current, true));
|
||||
|
||||
const endCurrentSessionBtn = document.createElement('button');
|
||||
endCurrentSessionBtn.className = 'shine-btn shine-btn--settings settings-bordered-btn';
|
||||
endCurrentSessionBtn.type = 'button';
|
||||
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
||||
endCurrentSessionBtn.addEventListener('click', async () => {
|
||||
const confirmed = window.confirm('Хотите завершить текущую сессию?');
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||
closeServerSession: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isSessionInvalidError(error)) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
return;
|
||||
}
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||
});
|
||||
}
|
||||
});
|
||||
currentMenu.append(endCurrentSessionBtn);
|
||||
|
||||
const othersMenu = document.createElement('div');
|
||||
othersMenu.className = 'stack';
|
||||
othersMenu.innerHTML = '<p class="meta-muted">Остальные сеансы</p>';
|
||||
const groupTitle = (text) => {
|
||||
const title = document.createElement('p');
|
||||
title.className = 'nav-list__title';
|
||||
title.textContent = text;
|
||||
return title;
|
||||
};
|
||||
const currentList = document.createElement('div');
|
||||
currentList.className = 'nav-list';
|
||||
currentList.append(createSessionItem(current, true));
|
||||
sessionsBlock.append(groupTitle('Это устройство'), currentList, groupTitle('Другие сеансы'));
|
||||
|
||||
if (others.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Других сеансов нет.';
|
||||
othersMenu.append(empty);
|
||||
sessionsBlock.append(empty);
|
||||
} else {
|
||||
others.forEach((session) => {
|
||||
othersMenu.append(createSessionItem(session, false));
|
||||
});
|
||||
const othersList = document.createElement('div');
|
||||
othersList.className = 'nav-list';
|
||||
others.forEach((session) => othersList.append(createSessionItem(session, false)));
|
||||
sessionsBlock.append(othersList);
|
||||
}
|
||||
|
||||
sessionsBlock.append(currentMenu, othersMenu);
|
||||
};
|
||||
|
||||
actions.querySelector('#reload-sessions-btn').addEventListener('click', async () => {
|
||||
async function reloadSessions() {
|
||||
try {
|
||||
await refreshSessions();
|
||||
buildList();
|
||||
setAuthInfo('Список сессий обновлён.');
|
||||
setAuthInfo('Список сеансов обновлён.');
|
||||
showToast('Список сеансов обновлён');
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
@@ -165,9 +170,9 @@ export function render({navigate, chrome}) {
|
||||
return;
|
||||
}
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
showToast(error.message, { kind: 'error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buildList();
|
||||
screen.append(actions, sessionsBlock);
|
||||
|
||||
@@ -58,7 +58,7 @@ export function render() {
|
||||
amountHint.style.fontSize = '22px';
|
||||
amountHint.style.fontWeight = '800';
|
||||
amountHint.style.lineHeight = '1.25';
|
||||
amountHint.style.color = '#1fc97a';
|
||||
amountHint.style.color = 'var(--success)';
|
||||
amountHint.style.textAlign = 'center';
|
||||
amountHint.textContent = '';
|
||||
|
||||
@@ -69,7 +69,7 @@ export function render() {
|
||||
balanceHint.style.margin = '0';
|
||||
balanceHint.style.textAlign = 'center';
|
||||
balanceHint.style.fontSize = '14px';
|
||||
balanceHint.style.color = 'rgba(255,255,255,0.9)';
|
||||
balanceHint.style.color = 'var(--text-primary)';
|
||||
balanceHint.textContent = '';
|
||||
|
||||
const closeHint = document.createElement('p');
|
||||
@@ -79,7 +79,7 @@ export function render() {
|
||||
closeHint.style.fontSize = '26px';
|
||||
closeHint.style.fontWeight = '800';
|
||||
closeHint.style.lineHeight = '1.25';
|
||||
closeHint.style.color = '#1fc97a';
|
||||
closeHint.style.color = 'var(--success)';
|
||||
closeHint.style.textAlign = 'center';
|
||||
closeHint.textContent = 'Можете закрыть эту страницу и продолжить регистрацию.';
|
||||
|
||||
@@ -140,7 +140,7 @@ export function render() {
|
||||
status.textContent = `Транзакция прошла.\nSignature: ${tx.signature}`;
|
||||
status.style.fontSize = '13px';
|
||||
status.style.fontWeight = '500';
|
||||
status.style.color = 'rgba(255,255,255,0.88)';
|
||||
status.style.color = 'var(--text-secondary)';
|
||||
fillBtn.style.display = 'none';
|
||||
actions.style.display = 'none';
|
||||
amountHint.style.display = '';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user