Compare commits

..
41 changed files with 6728 additions and 1268 deletions
@@ -21,10 +21,18 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_2 = 2;
public static final int SCHEMA_VERSION_3 = 3;
public static final int SCHEMA_VERSION_4 = 4;
public static final int SCHEMA_VERSION_5 = 5;
public static final int SCHEMA_VERSION_6 = 6;
public static final int SCHEMA_VERSION_7 = 7;
public static final int SCHEMA_VERSION_8 = 8;
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
public static final String POSTGRES_MIGRATION_V5_RESOURCE = "postgres/migration_v5.sql";
public static final String POSTGRES_MIGRATION_V6_RESOURCE = "postgres/migration_v6.sql";
public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql";
public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql";
private DatabaseInitializer() {}
@@ -100,6 +108,22 @@ public final class DatabaseInitializer {
}
if (currentVersion < SCHEMA_VERSION_4) {
runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE);
currentVersion = SCHEMA_VERSION_4;
}
if (currentVersion < SCHEMA_VERSION_5) {
runSqlScript(conn, POSTGRES_MIGRATION_V5_RESOURCE);
currentVersion = SCHEMA_VERSION_5;
}
if (currentVersion < SCHEMA_VERSION_6) {
runSqlScript(conn, POSTGRES_MIGRATION_V6_RESOURCE);
currentVersion = SCHEMA_VERSION_6;
}
if (currentVersion < SCHEMA_VERSION_7) {
runSqlScript(conn, POSTGRES_MIGRATION_V7_RESOURCE);
currentVersion = SCHEMA_VERSION_7;
}
if (currentVersion < SCHEMA_VERSION_8) {
runSqlScript(conn, POSTGRES_MIGRATION_V8_RESOURCE);
}
}
}
@@ -49,7 +49,7 @@ public final class CurrentUsersDAO {
String sql = """
SELECT 1
FROM %s
WHERE LOWER(login) = LOWER(?)
WHERE normalized_login = LOWER(BTRIM(?))
LIMIT 1
""".formatted(CurrentUsersSql.usersSubquery("su"));
@@ -104,7 +104,7 @@ public final class CurrentUsersDAO {
blockchain_key,
client_key
FROM %s
WHERE LOWER(login) = LOWER(?)
WHERE normalized_login = LOWER(BTRIM(?))
""".formatted(CurrentUsersSql.usersSubquery("su"));
try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -167,7 +167,7 @@ public final class CurrentUsersDAO {
blockchain_key,
client_key
FROM %s
WHERE LOWER(login) LIKE ?
WHERE normalized_login LIKE LOWER(BTRIM(?))
AND (? IS NULL OR is_server = ?)
ORDER BY login
LIMIT 5
@@ -176,7 +176,7 @@ public final class CurrentUsersDAO {
List<CurrentUserEntry> result = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, prefix.toLowerCase() + "%");
ps.setString(1, prefix.trim() + "%");
if (isServer == null) {
ps.setNull(2, Types.BOOLEAN);
ps.setNull(3, Types.BOOLEAN);
@@ -1,53 +0,0 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.DirectMessageEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class DirectMessagesDAO {
private static volatile DirectMessagesDAO instance;
private final DbController db = DbController.getInstance();
private DirectMessagesDAO() {}
public static DirectMessagesDAO getInstance() {
if (instance == null) {
synchronized (DirectMessagesDAO.class) {
if (instance == null) instance = new DirectMessagesDAO();
}
}
return instance;
}
public void insert(DirectMessageEntry entry) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO direct_messages (
message_id, from_login, to_login, text, created_at_ms
) VALUES (?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getMessageId());
ps.setString(2, entry.getFromLogin());
ps.setString(3, entry.getToLogin());
ps.setString(4, entry.getText());
ps.setLong(5, entry.getCreatedAtMs());
ps.executeUpdate();
}
}
}
public boolean existsFromTo(String fromLogin, String toLogin) throws Exception {
try (Connection c = db.getConnection()) {
String sql = "SELECT 1 FROM direct_messages WHERE from_login = ? AND to_login = ? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, fromLogin);
ps.setString(2, toLogin);
return ps.executeQuery().next();
}
}
}
}
@@ -1,47 +0,0 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.SignedDirectMessageHistoryEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class SignedDirectMessagesHistoryDAO {
private static volatile SignedDirectMessagesHistoryDAO instance;
private final DbController db = DbController.getInstance();
private SignedDirectMessagesHistoryDAO() {}
public static SignedDirectMessagesHistoryDAO getInstance() {
if (instance == null) {
synchronized (SignedDirectMessagesHistoryDAO.class) {
if (instance == null) instance = new SignedDirectMessagesHistoryDAO();
}
}
return instance;
}
public void insert(SignedDirectMessageHistoryEntry e) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO signed_direct_messages_history (
message_id, from_login, to_login, target_mode, target_session_id,
message_type, time_ms, nonce, raw_packet, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, e.getMessageId());
ps.setString(2, e.getFromLogin());
ps.setString(3, e.getToLogin());
ps.setInt(4, e.getTargetMode());
ps.setString(5, e.getTargetSessionId());
ps.setInt(6, e.getMessageType());
ps.setLong(7, e.getTimeMs());
ps.setLong(8, e.getNonce());
ps.setBytes(9, e.getRawPacket());
ps.setLong(10, e.getCreatedAtMs());
ps.executeUpdate();
}
}
}
}
@@ -1,51 +0,0 @@
package shine.db.dao;
import shine.db.DbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class SignedDmReplayDAO {
private static volatile SignedDmReplayDAO instance;
private final DbController db = DbController.getInstance();
private SignedDmReplayDAO() {}
public static SignedDmReplayDAO getInstance() {
if (instance == null) {
synchronized (SignedDmReplayDAO.class) {
if (instance == null) instance = new SignedDmReplayDAO();
}
}
return instance;
}
public boolean registerUnique(String fromLogin, long timeMs, long nonce, long nowMs) throws Exception {
cleanupExpired(nowMs - 15L * 60L * 1000L);
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO signed_direct_message_replay (
from_login, time_ms, nonce, created_at_ms
) VALUES (?, ?, ?, ?)
ON CONFLICT DO NOTHING
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, fromLogin);
ps.setLong(2, timeMs);
ps.setLong(3, nonce);
ps.setLong(4, nowMs);
return ps.executeUpdate() > 0;
}
}
}
public void cleanupExpired(long minCreatedAtMs) throws Exception {
try (Connection c = db.getConnection()) {
String sql = "DELETE FROM signed_direct_message_replay WHERE created_at_ms < ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, minCreatedAtMs);
ps.executeUpdate();
}
}
}
}
@@ -1,24 +0,0 @@
package shine.db.entities;
public class DirectMessageEntry {
private String messageId;
private String fromLogin;
private String toLogin;
private String text;
private long createdAtMs;
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
}
@@ -1,35 +0,0 @@
package shine.db.entities;
public class SignedDirectMessageHistoryEntry {
private String messageId;
private String fromLogin;
private String toLogin;
private int targetMode;
private String targetSessionId;
private int messageType;
private long timeMs;
private long nonce;
private byte[] rawPacket;
private long createdAtMs;
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public int getTargetMode() { return targetMode; }
public void setTargetMode(int targetMode) { this.targetMode = targetMode; }
public String getTargetSessionId() { return targetSessionId; }
public void setTargetSessionId(String targetSessionId) { this.targetSessionId = targetSessionId; }
public int getMessageType() { return messageType; }
public void setMessageType(int messageType) { this.messageType = messageType; }
public long getTimeMs() { return timeMs; }
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
public long getNonce() { return nonce; }
public void setNonce(long nonce) { this.nonce = nonce; }
public byte[] getRawPacket() { return rawPacket; }
public void setRawPacket(byte[] rawPacket) { this.rawPacket = rawPacket; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
}
@@ -12,6 +12,7 @@ public final class CurrentUsersSql {
(
SELECT
current_users.login AS login,
current_users.normalized_login AS normalized_login,
current_users.blockchain_name AS blockchain_name,
current_users.client_key AS solana_key,
current_users.blockchain_key AS blockchain_key,
@@ -0,0 +1,83 @@
BEGIN;
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS normalized_login TEXT;
UPDATE solana_user_pda_current
SET normalized_login = LOWER(BTRIM(login))
WHERE normalized_login IS NULL
OR normalized_login <> LOWER(BTRIM(login));
ALTER TABLE solana_user_pda_current
ALTER COLUMN normalized_login SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login
ON solana_user_pda_current(normalized_login);
CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login
ON solana_user_pda_current(normalized_login);
WITH bad_signed_messages AS (
SELECT DISTINCT sm.message_key
FROM signed_messages sm
LEFT JOIN solana_user_pda_current u_from
ON u_from.normalized_login = LOWER(BTRIM(sm.from_login))
LEFT JOIN solana_user_pda_current u_to
ON u_to.normalized_login = LOWER(BTRIM(sm.to_login))
WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login)
OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login)
)
DELETE FROM signed_message_session_delivery d
USING bad_signed_messages bad
WHERE d.message_key = bad.message_key;
WITH bad_signed_messages AS (
SELECT DISTINCT sm.message_key
FROM signed_messages sm
LEFT JOIN solana_user_pda_current u_from
ON u_from.normalized_login = LOWER(BTRIM(sm.from_login))
LEFT JOIN solana_user_pda_current u_to
ON u_to.normalized_login = LOWER(BTRIM(sm.to_login))
WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login)
OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login)
)
DELETE FROM signed_messages sm
USING bad_signed_messages bad
WHERE sm.message_key = bad.message_key;
DELETE FROM signed_direct_messages_history h
USING solana_user_pda_current u_from,
solana_user_pda_current u_to
WHERE u_from.normalized_login = LOWER(BTRIM(h.from_login))
AND u_to.normalized_login = LOWER(BTRIM(h.to_login))
AND (h.from_login <> u_from.login OR h.to_login <> u_to.login);
DELETE FROM signed_direct_message_replay r
USING solana_user_pda_current u_from
WHERE u_from.normalized_login = LOWER(BTRIM(r.from_login))
AND r.from_login <> u_from.login;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = 'direct_messages'
) THEN
DELETE FROM direct_messages d
USING solana_user_pda_current u_from,
solana_user_pda_current u_to
WHERE u_from.normalized_login = LOWER(BTRIM(d.from_login))
AND u_to.normalized_login = LOWER(BTRIM(d.to_login))
AND (d.from_login <> u_from.login OR d.to_login <> u_to.login);
END IF;
END $$;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 5, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,21 @@
BEGIN;
ALTER TABLE signed_messages
DROP CONSTRAINT IF EXISTS signed_messages_from_login_fkey;
ALTER TABLE signed_messages
DROP CONSTRAINT IF EXISTS signed_messages_to_login_fkey;
ALTER TABLE signed_direct_messages_history
DROP CONSTRAINT IF EXISTS signed_direct_messages_history_from_login_fkey;
ALTER TABLE signed_direct_messages_history
DROP CONSTRAINT IF EXISTS signed_direct_messages_history_to_login_fkey;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 6, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,16 @@
BEGIN;
ALTER TABLE blocks
DROP CONSTRAINT IF EXISTS blocks_login_fkey;
ALTER TABLE blocks
ADD CONSTRAINT blocks_login_fkey
FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 7, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,16 @@
BEGIN;
ALTER TABLE connections_state
DROP CONSTRAINT IF EXISTS connections_state_login_fkey;
ALTER TABLE connections_state
ADD CONSTRAINT connections_state_login_fkey
FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 8, 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;
@@ -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, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
VALUES (1, 8, 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;
@@ -74,6 +74,7 @@ CREATE INDEX IF NOT EXISTS idx_sync_tx_history_login
CREATE TABLE IF NOT EXISTS solana_user_pda_current (
pda_address TEXT PRIMARY KEY,
login TEXT NOT NULL UNIQUE,
normalized_login TEXT NOT NULL,
record_number INTEGER NOT NULL,
slot BIGINT NOT NULL,
last_tx_signature TEXT NOT NULL,
@@ -109,6 +110,12 @@ CREATE TABLE IF NOT EXISTS solana_user_pda_current (
CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot
ON solana_user_pda_current(slot);
CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login
ON solana_user_pda_current(normalized_login);
CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login
ON solana_user_pda_current(normalized_login);
CREATE TABLE IF NOT EXISTS user_access_servers_current (
user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
@@ -440,7 +447,7 @@ CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at
ON blockchain_state(updated_at_ms);
CREATE TABLE IF NOT EXISTS blocks (
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
bch_name TEXT NOT NULL REFERENCES blockchain_state(blockchain_name),
block_number INTEGER NOT NULL CHECK (block_number >= 0),
msg_type INTEGER NOT NULL,
@@ -470,7 +477,7 @@ CREATE INDEX IF NOT EXISTS idx_blocks_by_line
ON blocks (bch_name, line_code, this_line_number);
CREATE TABLE IF NOT EXISTS connections_state (
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
rel_type INTEGER NOT NULL,
to_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL,
@@ -624,8 +631,8 @@ CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created
CREATE TABLE IF NOT EXISTS signed_direct_messages_history (
message_id TEXT PRIMARY KEY,
from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
from_login TEXT NOT NULL,
to_login TEXT NOT NULL,
target_mode INTEGER NOT NULL,
target_session_id TEXT,
message_type INTEGER NOT NULL,
@@ -642,8 +649,8 @@ CREATE TABLE IF NOT EXISTS signed_messages (
message_key TEXT PRIMARY KEY,
base_key TEXT NOT NULL,
target_login TEXT NOT NULL,
from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
from_login TEXT NOT NULL,
to_login TEXT NOT NULL,
time_ms BIGINT NOT NULL,
nonce BIGINT NOT NULL,
message_type INTEGER NOT NULL,
@@ -94,7 +94,6 @@ import server.logic.ws_protocol.JSON.messages.Net_DmSyncBatch_Handler;
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler;
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
@@ -107,7 +106,6 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request;
@@ -1,208 +0,0 @@
package server.logic.ws_protocol.JSON.messages;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.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.messages.entyties.Net_SendDirectMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Response;
import server.logic.ws_protocol.JSON.push.WebPushSender;
import server.logic.ws_protocol.JSON.push.WsEventSender;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.DirectMessagesDAO;
import shine.db.dao.SignedDirectMessagesHistoryDAO;
import shine.db.dao.SignedDmReplayDAO;
import shine.db.dao.CurrentUsersDAO;
import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.DirectMessageEntry;
import shine.db.entities.SignedDirectMessageHistoryEntry;
import shine.db.entities.CurrentUserEntry;
import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public class Net_SendDirectMessage_Handler implements JsonMessageHandler {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final long REPLAY_TTL_MS = 15L * 60L * 1000L;
private static final int MAX_MESSAGE_BYTES = 3000;
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_SendDirectMessage_Request req = (Net_SendDirectMessage_Request) baseRequest;
if (req.getBlobB64() == null || req.getBlobB64().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "blobB64 обязателен");
}
final byte[] raw;
final SignedDirectMessagePacket packet;
try {
raw = Base64.getDecoder().decode(req.getBlobB64().trim());
packet = SignedDirectMessagePacket.parse(raw, MAX_MESSAGE_BYTES);
} catch (IllegalArgumentException ex) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат пакета");
}
CurrentUserEntry fromUser = CurrentUsersDAO.getInstance().getByLogin(packet.fromLogin);
CurrentUserEntry toUser = CurrentUsersDAO.getInstance().getByLogin(packet.toLogin);
if (fromUser == null || toUser == null) {
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "from/to пользователь не найден");
}
byte[] publicKey32;
try {
publicKey32 = Ed25519Util.keyFromBase64(fromUser.getClientKey());
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_DEVICE_KEY", "Некорректный clientKey отправителя");
}
if (!Ed25519Util.verify(packet.signedBody, packet.signature64, publicKey32)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_SIGNATURE", "Подпись не прошла проверку");
}
long now = System.currentTimeMillis();
if (Math.abs(now - packet.timeMs) > REPLAY_TTL_MS) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_TIME_WINDOW", "Время сообщения вышло за окно 15 минут");
}
boolean replayOk = SignedDmReplayDAO.getInstance().registerUnique(packet.fromLogin, packet.timeMs, packet.nonce, now);
if (!replayOk) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "REPLAY", "Повторное сообщение заблокировано");
}
String messageId = NetIdGenerator.eventId("msg");
String textForUi = new String(packet.messageBytes, StandardCharsets.UTF_8);
DirectMessageEntry entry = new DirectMessageEntry();
entry.setMessageId(messageId);
entry.setFromLogin(packet.fromLogin);
entry.setToLogin(packet.toLogin);
entry.setText(textForUi);
entry.setCreatedAtMs(now);
DirectMessagesDAO.getInstance().insert(entry);
SignedDirectMessageHistoryEntry history = new SignedDirectMessageHistoryEntry();
history.setMessageId(messageId);
history.setFromLogin(packet.fromLogin);
history.setToLogin(packet.toLogin);
history.setTargetMode(packet.targetMode);
history.setTargetSessionId(packet.targetSessionId);
history.setMessageType(packet.messageType);
history.setTimeMs(packet.timeMs);
history.setNonce(packet.nonce);
history.setRawPacket(packet.rawPacket);
history.setCreatedAtMs(now);
SignedDirectMessagesHistoryDAO.getInstance().insert(history);
DeliveryResult delivery = deliver(packet, req.getBlobB64().trim(), messageId, now);
Net_SendDirectMessage_Response resp = new Net_SendDirectMessage_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setMessageId(messageId);
resp.setDeliveredWsSessions(delivery.wsDelivered);
resp.setDeliveredWebPushSessions(delivery.webPushDelivered);
resp.setSessionNotFound(delivery.sessionNotFound);
return resp;
}
private DeliveryResult deliver(SignedDirectMessagePacket packet, String blobB64, String messageId, long createdAtMs) throws Exception {
DeliveryResult result = new DeliveryResult();
Set<String> selectedSessionIds = new HashSet<>();
if (packet.targetMode == SignedDirectMessagePacket.TARGET_ONE_SESSION) {
ActiveSessionEntry byId = ActiveSessionsDAO.getInstance().getBySessionId(packet.targetSessionId);
if (byId == null || !packet.toLogin.equalsIgnoreCase(byId.getLogin())) {
result.sessionNotFound = true;
return result;
}
selectedSessionIds.add(byId.getSessionId());
deliverToSession(packet, blobB64, messageId, createdAtMs, byId.getSessionId(), result);
return result;
}
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(packet.toLogin);
for (ActiveSessionEntry s : sessions) {
selectedSessionIds.add(s.getSessionId());
deliverToSession(packet, blobB64, messageId, createdAtMs, s.getSessionId(), result);
}
return result;
}
private void deliverToSession(
SignedDirectMessagePacket packet,
String blobB64,
String messageId,
long createdAtMs,
String sessionId,
DeliveryResult result
) {
ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId);
boolean wsDelivered = false;
if (targetCtx != null) {
String eventId = NetIdGenerator.eventId("evt");
CompletableFuture<Boolean> waiter = DeliveryTracker.getInstance().register(eventId);
ObjectNode payload = MAPPER.createObjectNode();
payload.put("eventId", eventId);
payload.put("messageId", messageId);
payload.put("fromLogin", packet.fromLogin);
payload.put("toLogin", packet.toLogin);
payload.put("blobB64", blobB64);
payload.put("text", new String(packet.messageBytes, StandardCharsets.UTF_8));
payload.put("timeMs", createdAtMs);
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingDirectMessage", eventId, payload);
if (sent) {
try {
wsDelivered = waiter.get(1200, TimeUnit.MILLISECONDS);
} catch (Exception ignored) {
wsDelivered = false;
}
}
DeliveryTracker.getInstance().remove(eventId);
}
if (wsDelivered) {
result.wsDelivered++;
return;
}
try {
ActiveSessionEntry targetSession = ActiveSessionsDAO.getInstance().getBySessionId(sessionId);
if (targetSession == null) return;
if (isBlank(targetSession.getPushEndpoint()) || isBlank(targetSession.getPushP256dhKey()) || isBlank(targetSession.getPushAuthKey())) {
return;
}
boolean pushed = WebPushSender.sendBase64Payload(
targetSession.getPushEndpoint(),
targetSession.getPushP256dhKey(),
targetSession.getPushAuthKey(),
blobB64
);
if (pushed) result.webPushDelivered++;
} catch (Exception ignored) {
// ignore per-session push errors
}
}
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
private static final class DeliveryResult {
int wsDelivered;
int webPushDelivered;
boolean sessionNotFound;
}
}
@@ -1,10 +0,0 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_SendDirectMessage_Request extends Net_Request {
private String blobB64;
public String getBlobB64() { return blobB64; }
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
}
@@ -1,19 +0,0 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_SendDirectMessage_Response extends Net_Response {
private String messageId;
private int deliveredWsSessions;
private int deliveredWebPushSessions;
private boolean sessionNotFound;
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public int getDeliveredWsSessions() { return deliveredWsSessions; }
public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
public boolean isSessionNotFound() { return sessionNotFound; }
public void setSessionNotFound(boolean sessionNotFound) { this.sessionNotFound = sessionNotFound; }
}
@@ -8,6 +8,7 @@ import sync.codec.ShineUsersCodec;
import java.sql.*;
import java.util.*;
import java.util.Locale;
public final class PostgresStorageRepository
implements AutoCloseable {
@@ -465,7 +466,7 @@ public final class PostgresStorageRepository
String sql =
"INSERT INTO solana_user_pda_current (" +
"pda_address, login, record_number, slot, last_tx_signature, " +
"pda_address, login, normalized_login, record_number, slot, last_tx_signature, " +
"recovery_key, root_key, client_key, blockchain_name, " +
"blockchain_key, paid_limit_bytes, used_bytes, " +
"last_block_number, last_block_hash, last_block_signature, " +
@@ -475,9 +476,10 @@ public final class PostgresStorageRepository
"trusted_count, created_at_ms, updated_at_ms, " +
"prev_record_hash, record_signature, raw_data_base64, " +
"first_seen_at_ms, last_synced_at_ms" +
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT (pda_address) DO UPDATE SET " +
"login = EXCLUDED.login, " +
"normalized_login = EXCLUDED.normalized_login, " +
"record_number = EXCLUDED.record_number, " +
"slot = EXCLUDED.slot, " +
"last_tx_signature = EXCLUDED.last_tx_signature, " +
@@ -679,36 +681,41 @@ public final class PostgresStorageRepository
statement.setString(1, snapshot.pdaAddress());
statement.setString(2, snapshot.login());
statement.setInt(3, snapshot.recordNumber());
statement.setLong(4, snapshot.slot());
statement.setString(5, snapshot.lastTxSignature());
statement.setString(6, snapshot.recoveryKey());
statement.setString(7, snapshot.rootKey());
statement.setString(8, snapshot.clientKey());
statement.setString(9, snapshot.blockchainName());
statement.setString(10, snapshot.blockchainKey());
statement.setLong(11, snapshot.paidLimitBytes());
statement.setLong(12, snapshot.usedBytes());
statement.setInt(13, snapshot.lastBlockNumber());
statement.setString(14, snapshot.lastBlockHash());
statement.setString(15, snapshot.lastBlockSignature());
statement.setString(16, snapshot.arweaveTxId());
statement.setBoolean(17, snapshot.isServer());
statement.setInt(18, snapshot.addressFormatType());
statement.setInt(19, snapshot.addressFormatVersion());
statement.setString(20, snapshot.serverAddress());
statement.setString(21, writeJson(snapshot.syncServers()));
statement.setString(22, writeJson(snapshot.accessServers()));
statement.setInt(23, snapshot.sessionsMode());
statement.setString(24, writeJson(snapshot.sessions()));
statement.setInt(25, snapshot.trustedCount());
statement.setLong(26, snapshot.createdAtMs());
statement.setLong(27, snapshot.updatedAtMs());
statement.setString(28, snapshot.prevRecordHash());
statement.setString(29, snapshot.recordSignature());
statement.setString(30, snapshot.rawDataBase64());
statement.setLong(31, nowMs);
statement.setString(3, normalizeLogin(snapshot.login()));
statement.setInt(4, snapshot.recordNumber());
statement.setLong(5, snapshot.slot());
statement.setString(6, snapshot.lastTxSignature());
statement.setString(7, snapshot.recoveryKey());
statement.setString(8, snapshot.rootKey());
statement.setString(9, snapshot.clientKey());
statement.setString(10, snapshot.blockchainName());
statement.setString(11, snapshot.blockchainKey());
statement.setLong(12, snapshot.paidLimitBytes());
statement.setLong(13, snapshot.usedBytes());
statement.setInt(14, snapshot.lastBlockNumber());
statement.setString(15, snapshot.lastBlockHash());
statement.setString(16, snapshot.lastBlockSignature());
statement.setString(17, snapshot.arweaveTxId());
statement.setBoolean(18, snapshot.isServer());
statement.setInt(19, snapshot.addressFormatType());
statement.setInt(20, snapshot.addressFormatVersion());
statement.setString(21, snapshot.serverAddress());
statement.setString(22, writeJson(snapshot.syncServers()));
statement.setString(23, writeJson(snapshot.accessServers()));
statement.setInt(24, snapshot.sessionsMode());
statement.setString(25, writeJson(snapshot.sessions()));
statement.setInt(26, snapshot.trustedCount());
statement.setLong(27, snapshot.createdAtMs());
statement.setLong(28, snapshot.updatedAtMs());
statement.setString(29, snapshot.prevRecordHash());
statement.setString(30, snapshot.recordSignature());
statement.setString(31, snapshot.rawDataBase64());
statement.setLong(32, nowMs);
statement.setLong(33, nowMs);
}
private String normalizeLogin(String login) {
return login == null ? "" : login.trim().toLowerCase(Locale.ROOT);
}
private ShineUsersCodec.UserPdaSnapshot mapSnapshot(
@@ -911,6 +918,7 @@ public final class PostgresStorageRepository
"CREATE TABLE IF NOT EXISTS solana_user_pda_current (" +
"pda_address TEXT PRIMARY KEY, " +
"login TEXT NOT NULL UNIQUE, " +
"normalized_login TEXT NOT NULL, " +
"record_number INTEGER NOT NULL, " +
"slot BIGINT NOT NULL, " +
"last_tx_signature TEXT NOT NULL, " +
@@ -943,11 +951,29 @@ public final class PostgresStorageRepository
"last_synced_at_ms BIGINT NOT NULL" +
")"
);
statement.executeUpdate(
"ALTER TABLE solana_user_pda_current " +
"ADD COLUMN IF NOT EXISTS normalized_login TEXT"
);
statement.executeUpdate(
"UPDATE solana_user_pda_current " +
"SET normalized_login = LOWER(BTRIM(login)) " +
"WHERE normalized_login IS NULL " +
" OR normalized_login <> LOWER(BTRIM(login))"
);
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot " +
"ON solana_user_pda_current(slot)"
);
statement.executeUpdate(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login " +
"ON solana_user_pda_current(normalized_login)"
);
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login " +
"ON solana_user_pda_current(normalized_login)"
);
statement.executeUpdate(
"CREATE TABLE IF NOT EXISTS solana_user_pda_history (" +
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.7.0
server.version=1.5.0
server.version=1.6.1
+4 -1
View File
@@ -13,6 +13,9 @@
- `user_access_servers_current`
- `solana_user_pda_history`
- источник истины по пользовательским PDA: `solana_user_pda_current`.
- в `solana_user_pda_current` хранятся оба варианта логина:
- `login` — display-логин из PDA;
- `normalized_login` — канонический lower-case для runtime lookup и части FK;
- `user_access_servers_current` — это вторичная локальная проекция для быстрого роутинга DM по access servers;
она автоматически пересобирается из `solana_user_pda_current`, включая backfill для уже существующих пользователей.
@@ -56,7 +59,7 @@ psql \
Скрипт:
- создаёт таблицу версии схемы `db_schema_version`;
- ставит `schema_version = 2`;
- ставит актуальный `schema_version`;
- создаёт таблицы sync-модуля Solana users;
- создаёт server runtime tables;
- создаёт триггеры и функции автоматической актуализации `user_access_servers_current`;
-1
View File
@@ -71,7 +71,6 @@
## Важные замечания
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
- Legacy-операция `SendDirectMessage` больше не зарегистрирована и не должна использоваться для DM v1.
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
@@ -9,7 +9,6 @@
Важно:
- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API;
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
@@ -334,8 +334,6 @@
### 8.2. Новые методы, которые нужны
Отдельный legacy-метод `SendDirectMessage` в DM v1 не используется и должен оставаться отключённым, чтобы не было параллельного старого стека доставки.
## 9. Правила валидации и применения
### 9.1. Общее правило по ревизиям
@@ -653,7 +651,6 @@ UI-следствие для клиента:
- межсерверная маршрутизация DM должна идти через `access_servers`;
- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM;
- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает;
- legacy `SendDirectMessage` должен быть отключён;
- логика должна быть безопасна для нескольких серверов у каждой стороны.
## 14. Что в v1 пока не входит
@@ -323,6 +323,7 @@ Append-only журнал всех просмотренных транзакци
- `pda_address TEXT PRIMARY KEY`
- `login TEXT NOT NULL`
- `normalized_login TEXT NOT NULL`
- `record_number INTEGER NOT NULL`
- `slot INTEGER NOT NULL`
- `last_tx_signature TEXT NOT NULL`
@@ -349,9 +350,16 @@ Append-only журнал всех просмотренных транзакци
Индексы:
- уникальный индекс на `login`
- уникальный индекс на `normalized_login`
- индекс на `slot`
- индекс на `last_tx_signature`
Правило использования:
- `login` хранит display-логин ровно в том регистре, как он записан в PDA;
- `normalized_login` хранит канонический lower-case логин;
- server runtime может использовать `normalized_login` для lookup и FK там, где внутренние записи живут в canonical lower-case.
### 4. `solana_user_pda_history`
Append-only история всех версий пользовательских PDA.
-467
View File
@@ -1,13 +1,8 @@
import {
navigate,
getRoute,
parseRouteFromPath,
PRE_AUTH_PAGES,
getSwipeNavigationTarget,
syncTrackedRouteHistory,
rememberToolbarRoute,
resetRememberedToolbarRoutes,
resolveToolbarActive,
} from './router.js';
import { renderToolbar } from './components/toolbar.js';
import { captureClientError, setClientErrorSentNotifier, setClientErrorTransport } from './services/client-error-reporter.js';
@@ -173,14 +168,6 @@ const SIGNED_DM_DECRYPT_CONTEXT_POLL_MS = 50;
const UI_VERSION_PERIODIC_CHECK_MS = 5 * 60 * 1000;
const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim();
const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/;
const KEEP_ALIVE_ROOTS = new Set(['messages-list', 'channels-list']);
const HORIZONTAL_SWIPE_MIN_DISTANCE_PX = 72;
const HORIZONTAL_SWIPE_MAX_VERTICAL_DRIFT_PX = 56;
const HORIZONTAL_SWIPE_DOMINANCE_RATIO = 1.35;
const HORIZONTAL_SWIPE_LOCK_DISTANCE_PX = 14;
const HORIZONTAL_SWIPE_COMMIT_RATIO = 0.32;
const HORIZONTAL_SWIPE_PREVIEW_EDGE_PX = 18;
const HORIZONTAL_SWIPE_MAX_DURATION_MS = 260;
let currentCleanup = null;
let pingIntervalId = null;
@@ -202,9 +189,6 @@ let hiddenDmAudioUnlocked = false;
let initialConnectionCompleted = false;
let orientationLockInFlight = false;
let currentChromeCleanup = null;
let currentMountState = null;
let activeSwipePreview = null;
const keepAliveEntries = new Map();
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
const GUEST_ALLOWED_PAGES = new Set([
'start-view',
@@ -319,403 +303,11 @@ function createChromeController(showAppChrome) {
};
}
function destroyMountState(entry) {
if (!entry) return;
if (entry.destroyed) return;
entry.destroyed = true;
try {
if (typeof entry.cleanup === 'function') {
entry.cleanup();
}
} finally {
entry.chrome?.dispose?.();
}
}
function clearKeepAliveEntries() {
teardownSwipePreview({ cancelOnly: true });
keepAliveEntries.forEach((entry) => destroyMountState(entry));
keepAliveEntries.clear();
resetRememberedToolbarRoutes();
currentMountState = null;
currentCleanup = null;
currentChromeCleanup = null;
}
function detachMountedScreen(entry) {
if (!entry) return;
entry.chrome?.suspend?.();
if (entry.screen?.parentNode === screenEl) {
screenEl.removeChild(entry.screen);
} else {
screenEl.innerHTML = '';
}
}
function mountExistingEntry(entry, { showAppChrome, pageId }) {
teardownSwipePreview({ cancelOnly: true });
screenEl.innerHTML = '';
screenEl.append(entry.screen);
entry.chrome?.resume?.();
currentMountState = entry;
currentCleanup = typeof entry.cleanup === 'function' ? entry.cleanup : null;
currentChromeCleanup = () => entry.chrome?.dispose?.();
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
}
function cloneScreenForSwipe(screen) {
const clone = screen?.cloneNode?.(true);
if (!(clone instanceof Node)) return null;
return clone;
}
function sanitizeSwipeClone(node) {
if (!(node instanceof Element)) return;
node.removeAttribute('id');
node.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
}
function cloneSlotChildForSwipe(slotEl) {
const child = slotEl?.firstElementChild;
if (!(child instanceof Node)) return null;
const clone = child.cloneNode(true);
if (clone instanceof Element) sanitizeSwipeClone(clone);
return clone;
}
function createSwipeFrameSlot(className, contentNode = null) {
const slot = document.createElement('div');
slot.className = className;
if (contentNode instanceof Node) {
slot.append(contentNode);
slot.hidden = false;
} else {
slot.hidden = true;
}
return slot;
}
function buildSwipePane({
topbarNode = null,
screenNode = null,
composerNode = null,
screenClassName = '',
screenScrollTop = 0,
}) {
const pane = document.createElement('div');
pane.className = 'screen-swipe-pane';
const topbarSlot = createSwipeFrameSlot('topbar-slot screen-swipe-slot screen-swipe-slot--topbar', topbarNode);
const screenSlot = document.createElement('main');
screenSlot.className = `${screenClassName || 'screen-content'} screen-swipe-slot screen-swipe-slot--content`;
if (screenNode instanceof Node) {
screenSlot.append(screenNode);
}
const composerSlot = createSwipeFrameSlot('composer-slot screen-swipe-slot screen-swipe-slot--composer', composerNode);
pane.append(topbarSlot, screenSlot, composerSlot);
requestAnimationFrame(() => {
screenSlot.scrollTop = Math.max(0, Number(screenScrollTop || 0));
});
return pane;
}
function createSwipePreviewTarget(targetPath) {
const route = parseRouteFromPath(`/${String(targetPath || '').replace(/^\/+/, '')}`);
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
const page = routes[pageId] || routes['start-view'];
const rootPageId = resolveToolbarActive(pageId);
const cachedEntry = keepAliveEntries.get(rootPageId);
if (cachedEntry && cachedEntry.routePath === `/${String(targetPath || '').replace(/^\/+/, '')}`) {
return {
screen: cloneScreenForSwipe(cachedEntry.screen),
cleanup: null,
};
}
let previewTopbarNode = null;
let previewComposerNode = null;
const chrome = {
setTopbar(node = null) {
previewTopbarNode = node instanceof Node ? node : null;
},
setComposer(node = null) {
previewComposerNode = node instanceof Node ? node : null;
},
clear() {
previewTopbarNode = null;
previewComposerNode = null;
},
suspend() {},
resume() {},
dispose() {},
};
const screen = page.render({ route, navigate, chrome });
if (!(screen instanceof Node)) {
chrome.dispose();
throw new Error('Swipe preview render returned invalid node');
}
const cleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
return {
screen,
topbarNode: previewTopbarNode,
composerNode: previewComposerNode,
cleanup: () => {
try {
if (typeof cleanup === 'function') cleanup();
} finally {
chrome.dispose();
}
},
};
}
function applySwipePreviewOffset(session, revealPx) {
if (!session) return;
const width = Math.max(1, session.width);
const clamped = Math.max(0, Math.min(width, revealPx));
session.revealPx = clamped;
const currentX = session.direction === 'left' ? -clamped : clamped;
const targetX = session.direction === 'left'
? width - clamped + HORIZONTAL_SWIPE_PREVIEW_EDGE_PX
: -width + clamped - HORIZONTAL_SWIPE_PREVIEW_EDGE_PX;
const dividerX = session.direction === 'left'
? width - clamped
: clamped;
session.currentPane.style.transform = `translate3d(${currentX}px, 0, 0)`;
session.targetPane.style.transform = `translate3d(${targetX}px, 0, 0)`;
session.divider.style.transform = `translate3d(${dividerX}px, 0, 0)`;
const overlayOpacity = Math.max(0.08, Math.min(0.24, (clamped / width) * 0.24));
session.overlay.style.setProperty('--swipe-overlay-opacity', overlayOpacity.toFixed(3));
}
function teardownSwipePreview({ cancelOnly = false } = {}) {
const session = activeSwipePreview;
if (!session) return;
activeSwipePreview = null;
appShellEl?.classList.remove('app-shell--swiping');
topbarEl?.classList.remove('topbar-slot--swipe-hidden');
screenEl.classList.remove('screen-content--swipe-hidden');
composerEl?.classList.remove('composer-slot--swipe-hidden');
session.overlay.remove();
if (typeof session.targetCleanup === 'function') {
session.targetCleanup();
}
if (!cancelOnly) {
session.onComplete?.();
}
}
function animateSwipePreviewTo(session, revealPx, { complete = false } = {}) {
const width = Math.max(1, session.width);
const currentReveal = Number(session.revealPx || 0);
const remaining = Math.abs(revealPx - currentReveal);
const duration = Math.max(140, Math.min(HORIZONTAL_SWIPE_MAX_DURATION_MS, Math.round((remaining / width) * HORIZONTAL_SWIPE_MAX_DURATION_MS)));
[session.currentPane, session.targetPane, session.divider].forEach((node) => {
node.style.transition = `transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)`;
});
session.overlay.style.transition = `opacity ${duration}ms ease`;
requestAnimationFrame(() => {
applySwipePreviewOffset(session, revealPx);
if (!complete) {
session.overlay.style.opacity = '0';
}
});
window.setTimeout(() => {
teardownSwipePreview({ cancelOnly: !complete });
}, duration + 24);
}
function beginSwipePreview(direction, targetPath) {
if (!currentMountState?.screen || activeSwipePreview) return null;
const currentTopbarClone = cloneSlotChildForSwipe(topbarEl);
const currentScreenClone = cloneScreenForSwipe(currentMountState.screen);
const currentComposerClone = cloneSlotChildForSwipe(composerEl);
if (!(currentScreenClone instanceof Node)) return null;
if (currentTopbarClone instanceof Element) sanitizeSwipeClone(currentTopbarClone);
if (currentScreenClone instanceof Element) sanitizeSwipeClone(currentScreenClone);
if (currentComposerClone instanceof Element) sanitizeSwipeClone(currentComposerClone);
const targetPreview = createSwipePreviewTarget(targetPath);
if (!(targetPreview?.screen instanceof Node)) {
targetPreview?.cleanup?.();
return null;
}
if (targetPreview.topbarNode instanceof Element) sanitizeSwipeClone(targetPreview.topbarNode);
if (targetPreview.screen instanceof Element) sanitizeSwipeClone(targetPreview.screen);
if (targetPreview.composerNode instanceof Element) sanitizeSwipeClone(targetPreview.composerNode);
const overlay = document.createElement('div');
overlay.className = 'screen-swipe-overlay';
const currentPane = buildSwipePane({
topbarNode: currentTopbarClone,
screenNode: currentScreenClone,
composerNode: currentComposerClone,
screenClassName: screenEl.className,
screenScrollTop: screenEl.scrollTop,
});
currentPane.classList.add('screen-swipe-pane--current');
const targetPane = buildSwipePane({
topbarNode: targetPreview.topbarNode || null,
screenNode: targetPreview.screen,
composerNode: targetPreview.composerNode || null,
screenClassName: screenEl.className,
screenScrollTop: 0,
});
targetPane.classList.add('screen-swipe-pane--target', `screen-swipe-pane--${direction}`);
const divider = document.createElement('div');
divider.className = 'screen-swipe-divider';
overlay.append(currentPane, targetPane, divider);
appShellEl.append(overlay);
appShellEl?.classList.add('app-shell--swiping');
topbarEl?.classList.add('topbar-slot--swipe-hidden');
screenEl.classList.add('screen-content--swipe-hidden');
composerEl?.classList.add('composer-slot--swipe-hidden');
const session = {
direction,
targetPath,
width: screenEl.clientWidth || 1,
overlay,
currentPane,
targetPane,
divider,
targetCleanup: targetPreview.cleanup || null,
revealPx: 0,
onComplete: () => navigate(targetPath),
};
activeSwipePreview = session;
applySwipePreviewOffset(session, 0);
return session;
}
function installHorizontalTabSwipe() {
if (!screenEl) return;
let touchStartX = 0;
let touchStartY = 0;
let touchActive = false;
let touchBlocked = false;
let swipeLocked = false;
let swipeDirection = '';
let swipeTargetPath = '';
let swipeSession = null;
const reset = () => {
touchActive = false;
touchBlocked = false;
swipeLocked = false;
swipeDirection = '';
swipeTargetPath = '';
swipeSession = null;
touchStartX = 0;
touchStartY = 0;
};
screenEl.addEventListener('touchstart', (event) => {
if (event.touches.length !== 1) {
reset();
return;
}
const target = event.target instanceof Element ? event.target : null;
touchBlocked = Boolean(target?.closest('input, textarea, select, button, a, [contenteditable="true"]'));
touchActive = !touchBlocked;
touchStartX = Number(event.touches[0]?.clientX || 0);
touchStartY = Number(event.touches[0]?.clientY || 0);
}, { passive: true });
screenEl.addEventListener('touchmove', (event) => {
if (!touchActive || touchBlocked) return;
const touch = event.touches?.[0];
const deltaX = Number(touch?.clientX || 0) - touchStartX;
const deltaY = Number(touch?.clientY || 0) - touchStartY;
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
if (!swipeLocked) {
if (absX < HORIZONTAL_SWIPE_LOCK_DISTANCE_PX && absY < HORIZONTAL_SWIPE_LOCK_DISTANCE_PX) return;
if (absX <= absY * 1.05) {
touchBlocked = true;
return;
}
const currentPageId = getRoute().pageId || '';
swipeDirection = deltaX < 0 ? 'left' : 'right';
swipeTargetPath = getSwipeNavigationTarget(currentPageId, swipeDirection);
if (!swipeTargetPath) {
touchBlocked = true;
return;
}
swipeSession = beginSwipePreview(swipeDirection, swipeTargetPath);
if (!swipeSession) {
touchBlocked = true;
return;
}
swipeLocked = true;
}
if (!swipeLocked || !swipeSession) return;
event.preventDefault();
const revealPx = swipeDirection === 'left'
? Math.max(0, -deltaX)
: Math.max(0, deltaX);
applySwipePreviewOffset(swipeSession, revealPx);
}, { passive: false });
screenEl.addEventListener('touchcancel', reset, { passive: true });
screenEl.addEventListener('touchend', (event) => {
if (!touchActive || touchBlocked) {
if (swipeSession) {
animateSwipePreviewTo(swipeSession, 0, { complete: false });
}
reset();
return;
}
const touch = event.changedTouches?.[0];
const endX = Number(touch?.clientX || 0);
const endY = Number(touch?.clientY || 0);
const deltaX = endX - touchStartX;
const deltaY = endY - touchStartY;
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);
const session = swipeSession;
const wasLocked = swipeLocked;
reset();
if (wasLocked && session) {
event.preventDefault();
const revealRatio = Number(session.revealPx || 0) / Math.max(1, session.width);
const shouldCommit = revealRatio >= HORIZONTAL_SWIPE_COMMIT_RATIO;
animateSwipePreviewTo(session, shouldCommit ? session.width : 0, { complete: shouldCommit });
return;
}
if (absX < HORIZONTAL_SWIPE_MIN_DISTANCE_PX) return;
if (absY > HORIZONTAL_SWIPE_MAX_VERTICAL_DRIFT_PX) return;
if (absX <= absY * HORIZONTAL_SWIPE_DOMINANCE_RATIO) return;
const currentPageId = getRoute().pageId || '';
const direction = deltaX < 0 ? 'left' : 'right';
const target = getSwipeNavigationTarget(currentPageId, direction);
if (!target) return;
navigate(target);
}, { passive: true });
}
async function unlockHiddenDmAudio() {
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
@@ -1396,7 +988,6 @@ function renderPageFailureFallback(pageId, error) {
});
screenEl.innerHTML = '';
teardownSwipePreview({ cancelOnly: true });
const wrap = document.createElement('section');
wrap.className = 'stack';
@@ -1433,7 +1024,6 @@ function renderPageFailureFallback(pageId, error) {
}
function renderApp() {
teardownSwipePreview({ cancelOnly: true });
syncTrackedRouteHistory(window.location.pathname || '/');
const route = getRoute();
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
@@ -1450,31 +1040,6 @@ function renderApp() {
const page = routes[pageId] || routes['start-view'];
const showAppChrome = page.pageMeta?.showAppChrome !== false;
const rootPageId = resolveToolbarActive(pageId);
const keepAliveEligible = showAppChrome && KEEP_ALIVE_ROOTS.has(rootPageId);
const currentRoutePath = String(window.location.pathname || '/');
rememberToolbarRoute(pageId);
if (currentMountState) {
const shouldPreserveCurrent = currentMountState.keepAlive && currentMountState.rootPageId !== rootPageId;
if (shouldPreserveCurrent) {
currentMountState.routePath = currentMountState.routePath || currentRoutePath;
keepAliveEntries.set(currentMountState.rootPageId, currentMountState);
detachMountedScreen(currentMountState);
currentMountState = null;
currentCleanup = null;
currentChromeCleanup = null;
} else {
destroyMountState(currentMountState);
if (currentMountState.keepAlive) {
keepAliveEntries.delete(currentMountState.rootPageId);
}
currentMountState = null;
currentCleanup = null;
currentChromeCleanup = null;
}
} else {
if (typeof currentCleanup === 'function') {
currentCleanup();
currentCleanup = null;
@@ -1483,24 +1048,6 @@ function renderApp() {
currentChromeCleanup();
currentChromeCleanup = null;
}
}
const cachedEntry = keepAliveEligible ? keepAliveEntries.get(rootPageId) : null;
if (cachedEntry && cachedEntry.routePath === currentRoutePath) {
mountExistingEntry(cachedEntry, { showAppChrome, pageId });
toolbarEl.innerHTML = '';
if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
toolbarHeightObserver?.sync?.();
refreshConnectionUi();
return;
}
if (cachedEntry) {
destroyMountState(cachedEntry);
keepAliveEntries.delete(rootPageId);
}
try {
screenEl.innerHTML = '';
@@ -1513,19 +1060,6 @@ function renderApp() {
screenEl.append(screen);
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
currentMountState = {
pageId,
rootPageId,
keepAlive: keepAliveEligible,
routePath: currentRoutePath,
screen,
cleanup: currentCleanup,
chrome,
destroyed: false,
};
if (keepAliveEligible) {
keepAliveEntries.set(rootPageId, currentMountState);
}
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
@@ -2008,7 +1542,6 @@ async function init() {
})();
window.addEventListener('popstate', renderApp);
installHorizontalTabSwipe();
document.addEventListener('pointerdown', () => {
void unlockHiddenDmAudio();
}, { passive: true });
@@ -284,10 +284,22 @@ export function openArweaveAttachmentManager({
onSelect,
selectedTxIds = [],
historyOnly = false,
persistToHistory = true,
allowHistorySelection = true,
allowExistingTxInput = true,
mode = 'attachment',
historyPurpose = '',
uploadTransport = 'turbo',
turboKeySource = 'client',
dialogTitle = '',
uploadButtonLabel = '',
initialFile = null,
initialSha256 = '',
initialName = '',
fixedFile = false,
autoOpenFileDialog = true,
shineType = '',
extraUploadTags = [],
} = {}) {
const cleanLogin = String(login || '').trim();
const cleanStoragePwd = String(storagePwd || '').trim();
@@ -312,7 +324,7 @@ export function openArweaveAttachmentManager({
let selectedPreviewPriceInfo = null;
let priceInfo = null;
let balanceInfo = null;
let autoOpenedFileDialog = false;
let autoOpenedFileDialogOnce = false;
const isAvatarMode = String(mode || '') === 'avatar';
if (isAvatarMode && !String(uploadTransport || '').trim()) {
selectedUploadTransport = 'turbo';
@@ -320,6 +332,16 @@ export function openArweaveAttachmentManager({
const historyPurposeMode = String(historyPurpose || '').trim();
const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment';
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
const effectiveDialogTitle = String(dialogTitle || '').trim();
const effectiveUploadButtonLabel = String(uploadButtonLabel || '').trim();
const forcedShineType = String(shineType || '').trim();
const normalizedExtraUploadTags = Array.isArray(extraUploadTags)
? extraUploadTags.filter((item) => item?.name && item?.value)
: [];
if (initialFile instanceof File) {
selectedFile = initialFile;
if (initialSha256) selectedSha256 = String(initialSha256 || '').trim().toLowerCase();
}
function isTurboUpload() {
return selectedUploadTransport === 'turbo';
@@ -337,10 +359,15 @@ export function openArweaveAttachmentManager({
}
function finish(resolve, attachment, { pendingPlacement = undefined } = {}) {
const item = addArweaveAttachmentToHistory(cleanLogin, attachment, {
const item = persistToHistory
? addArweaveAttachmentToHistory(cleanLogin, attachment, {
pendingPlacement,
markPlaced: false,
});
})
: {
...attachment,
...normalizeAttachment(attachment),
};
if (!pendingPlacement && typeof onSelect === 'function') onSelect(item);
close(resolve, item);
}
@@ -588,16 +615,21 @@ export function openArweaveAttachmentManager({
const showUpload = async () => {
const turboMode = isTurboUpload();
const titleText = effectiveDialogTitle
|| (turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')));
const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить');
const canShowHistory = allowHistorySelection;
const canShowExisting = allowExistingTxInput;
root.innerHTML = `
<div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card">
<h3 class="modal-title">${turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'))}</h3>
<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>
<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>
<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</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>
${turboMode
? `
@@ -611,8 +643,8 @@ export function openArweaveAttachmentManager({
<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>')}
`}
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
${fixedFile ? '' : '<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>'}
${fixedFile ? '' : `<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />`}
<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" />
@@ -622,7 +654,7 @@ export function openArweaveAttachmentManager({
<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>' : ''}
<button class="primary-btn" type="button" data-action="upload" disabled>${historyOnly ? 'Загрузить в журнал' : 'Загрузить'}</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>
@@ -818,27 +850,30 @@ export function openArweaveAttachmentManager({
storagePwd: cleanStoragePwd,
keySource: selectedTurboKeySource,
file: selectedFile,
shineType: isAvatarMode ? 'avatar' : 'attachment',
shineType: forcedShineType || (isAvatarMode ? 'avatar' : 'attachment'),
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
...normalizedExtraUploadTags,
],
})
: await uploadArweaveFile({
gateway: cleanGateway,
jwk: selectedWallet()?.jwk,
file: selectedFile,
shineType: isAvatarMode ? 'avatar' : 'attachment',
shineType: forcedShineType || (isAvatarMode ? 'avatar' : 'attachment'),
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
...normalizedExtraUploadTags,
],
});
finish(resolve, {
name: isAvatarMode ? 'Аватар' : (selectedFile.name || 'file'),
name: isAvatarMode ? 'Аватар' : (initialName || selectedFile.name || 'file'),
size: selectedFile.size,
sha256: selectedSha256,
ar: uploaded.id,
uploadTransport: turboMode ? 'turbo' : 'arweave',
preview: previewUpload?.id && selectedPreviewSha256
? {
ar: previewUpload.id,
@@ -854,8 +889,19 @@ export function openArweaveAttachmentManager({
}
});
if (!autoOpenedFileDialog) {
autoOpenedFileDialog = true;
if (selectedFile && selectedSha256) {
if (previewOptionEl && isPreviewEligibleForCurrentFile()) {
previewOptionEl.hidden = false;
}
if (turboMode) {
await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
} else {
await recalculateArweaveState(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
}
}
if (!fixedFile && autoOpenFileDialog && !autoOpenedFileDialogOnce) {
autoOpenedFileDialogOnce = true;
window.setTimeout(() => fileEl?.click(), 0);
}
};
+4 -4
View File
@@ -1,4 +1,4 @@
import { getToolbarNavigationTarget, resolveToolbarActive } from '../router.js';
import { resolveToolbarActive } from '../router.js';
import { state } from '../state.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
@@ -33,7 +33,7 @@ function getTotalUnreadMessages() {
function navigateWithGuestRules(pageId, navigate) {
if (state.session.isAuthorized) {
navigate(getToolbarNavigationTarget(pageId));
navigate(pageId);
return;
}
if (pageId === 'messages-list') {
@@ -57,7 +57,7 @@ function navigateWithGuestRules(pageId, navigate) {
});
return;
}
navigate(getToolbarNavigationTarget(pageId));
navigate(pageId);
}
export function renderToolbar(currentPageId, navigate) {
@@ -93,7 +93,7 @@ export function renderToolbar(currentPageId, navigate) {
btn.append(badge);
}
if (item.pageId === 'channels-list') {
btn.addEventListener('click', () => navigate(getToolbarNavigationTarget('channels-list')));
btn.addEventListener('click', () => navigate('channels-list'));
} else {
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
}
+1 -1
View File
@@ -973,8 +973,8 @@ function openTopChannelsMenu({
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
{ divider: true },
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
{ label: 'Создать канал', action: () => onSubscribeChannel?.() },
{ divider: true },
{ label: 'Добавить канал', action: () => onSubscribeChannel?.() },
{ label: 'Просмотреть канал', action: () => onFindChannel?.() },
];
+429 -84
View File
@@ -17,6 +17,8 @@ import {
} from '../services/arweave-wallet-service.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import { buildArweaveDataUrl, sha256HexFromArrayBuffer } from '../services/arweave-file-service.js';
import {
calcLimitTopupPriceLamports,
getLimitStepBytes,
@@ -24,6 +26,12 @@ import {
getShineUsersEconomyConfig,
updateShineUserPdaOnSolana,
} from '../services/shine-blockchain-wallet-service.js?v=202605300007';
import {
buildBlockchainSnapshotFile,
clearStoredBlockchainSnapshot,
readStoredBlockchainSnapshot,
saveStoredBlockchainSnapshot,
} from '../services/shine-blockchain-snapshot-service.js';
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
@@ -40,6 +48,17 @@ function formatKbFromBytes(rawBytes) {
return `${kb.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KB`;
}
function formatBytesRu(rawBytes) {
const bytes = typeof rawBytes === 'bigint' ? Number(rawBytes) : Number(rawBytes || 0);
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
return bytes.toLocaleString('ru-RU');
}
function normalizeHex64(value) {
const raw = String(value || '').trim().toLowerCase();
return /^[0-9a-f]{64}$/.test(raw) ? raw : '';
}
function lamportsToSolText(lamportsBigInt) {
const value = Number(lamportsBigInt || 0n) / 1_000_000_000;
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 9 });
@@ -461,6 +480,55 @@ export function render({ navigate }) {
arweaveWalletCtx = null;
}
async function fetchServerBlockchainState() {
const login = String(state.session.login || '').trim();
const user = await authService.getUser(login);
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
return {
login,
blockchainName: String(user.blockchainName || `${login}-001`).trim(),
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
lastNumber: Number(user.serverLastGlobalNumber ?? -1),
lastHash: String(user.serverLastGlobalHash || '').trim().toLowerCase(),
};
}
async function resolveWalletSigningMaterial() {
const { login, storagePwd } = sessionArgsOrThrow();
let saved;
try {
saved = await loadEncryptedUserSecrets(login, storagePwd);
} catch {
saved = null;
}
let rootKey = String(saved?.rootKey || '').trim();
let blockchainKey = String(saved?.blockchainKey || '').trim();
const clientKey = String(saved?.clientKey || '').trim();
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
if (rootKey && blockchainKey) {
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
}
const password = window.prompt(
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
'',
);
if (password == null) throw new Error('Операция отменена пользователем');
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
const shouldSave = window.confirm(
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
);
if (shouldSave) {
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
}
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
}
function styleSupportInputField(field) {
if (!field) return;
field.style.color = '#111111';
@@ -1245,6 +1313,14 @@ export function render({ navigate }) {
void renderShineBlockchainWallet();
});
const solanaPublishBtn = document.createElement('button');
solanaPublishBtn.className = 'primary-btn';
solanaPublishBtn.style.width = '100%';
solanaPublishBtn.textContent = 'Закрепление в Solana';
solanaPublishBtn.addEventListener('click', () => {
void renderSolanaPublishWallet();
});
const supportBtn = document.createElement('button');
supportBtn.className = 'primary-btn';
supportBtn.style.width = '100%';
@@ -1253,7 +1329,7 @@ export function render({ navigate }) {
void renderSupportHub();
});
card.append(solanaBtn, arweaveBtn, shineBchBtn, supportBtn);
card.append(solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn);
content.append(card);
setStatus('Выберите тип кошелька.');
}
@@ -1339,63 +1415,13 @@ export function render({ navigate }) {
actions.className = 'stack';
actions.innerHTML = `
<button class="ghost-btn" id="refresh-shine-bch" style="width:100%;">Обновить</button>
<button class="primary-btn" id="sync-shine-solana" style="width:100%;">Закрепить в Solana</button>
<button class="primary-btn" id="topup-shine-limit" style="width:100%;">Увеличить лимит</button>
`;
const refreshBtn = actions.querySelector('#refresh-shine-bch');
const syncBtn = actions.querySelector('#sync-shine-solana');
const topupBtn = actions.querySelector('#topup-shine-limit');
const fetchServerState = async () => {
const user = await authService.getUser(String(state.session.login || '').trim());
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
const lastNumber = Number(user.serverLastGlobalNumber ?? -1);
return {
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
lastNumber,
lastHash: String(user.serverLastGlobalHash || ''),
};
};
const resolveWalletSigningMaterial = async () => {
const { login, storagePwd } = sessionArgsOrThrow();
let saved;
try {
saved = await loadEncryptedUserSecrets(login, storagePwd);
} catch {
saved = null;
}
let rootKey = String(saved?.rootKey || '').trim();
let blockchainKey = String(saved?.blockchainKey || '').trim();
const clientKey = String(saved?.clientKey || '').trim();
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
if (rootKey && blockchainKey) {
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
}
const password = window.prompt(
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
'',
);
if (password == null) throw new Error('Операция отменена пользователем');
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
const shouldSave = window.confirm(
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
);
if (shouldSave) {
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
}
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
};
const setButtonsDisabled = (disabled) => {
refreshBtn.disabled = disabled;
syncBtn.disabled = disabled;
topupBtn.disabled = disabled;
};
@@ -1407,7 +1433,7 @@ export function render({ navigate }) {
login: String(state.session.login || '').trim(),
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerState(),
fetchServerBlockchainState(),
]);
if (modeToken !== activeModeToken) return;
limitValue.textContent = formatKbFromBytes(usage.paidLimitBytes);
@@ -1423,9 +1449,6 @@ export function render({ navigate }) {
serverLastLabel.textContent = `Крайний блок: ${serverState.lastNumber}`;
serverLastHashLabel.textContent = `Hash: ${serverState.lastHash || '—'}`;
solanaLastLabel.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
solanaLastHashLabel.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
setStatus('Данные лимита и состояния блокчейна обновлены.');
} catch (error) {
if (modeToken !== activeModeToken) return;
@@ -1435,35 +1458,6 @@ export function render({ navigate }) {
}
};
syncBtn.addEventListener('click', async () => {
setButtonsDisabled(true);
try {
const [serverState, signing] = await Promise.all([
fetchServerState(),
resolveWalletSigningMaterial(),
]);
const result = await updateShineUserPdaOnSolana({
login: String(state.session.login || '').trim(),
solanaEndpoint: state.entrySettings.solanaServer,
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
additionalLimitBytes: 0n,
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
nextLastBlockNumber: serverState.lastNumber,
nextLastBlockHashHex: serverState.lastHash,
});
if (modeToken !== activeModeToken) return;
setStatus(`Состояние закреплено в Solana. Tx: ${result.signature}`);
await refreshUsage();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось закрепить состояние в Solana: ${error?.message || 'unknown'}`);
} finally {
setButtonsDisabled(false);
}
});
topupBtn.addEventListener('click', async () => {
setButtonsDisabled(true);
try {
@@ -1517,6 +1511,357 @@ export function render({ navigate }) {
await refreshUsage();
}
async function renderSolanaPublishWallet() {
const modeToken = ++activeModeToken;
clearArweaveSecretsInMemory();
content.innerHTML = '';
const login = String(state.session.login || '').trim();
const backBtn = createModeBackButton(renderWalletChoice);
const summaryCard = document.createElement('div');
summaryCard.className = 'card stack';
summaryCard.innerHTML = `
<h2 style="margin:0;">Закрепление в Solana</h2>
<p class="meta-muted" style="margin:0;">Сначала делаем полный слепок пользовательского блокчейна в Arweave или Turbo, затем обновляем ссылку в PDA.</p>
`;
const publicationStatus = document.createElement('div');
publicationStatus.className = 'card stack';
publicationStatus.innerHTML = `
<h3 style="margin:0;">Статус публикации</h3>
<p class="meta-muted" id="publish-status-main"></p>
<p class="meta-muted" id="publish-status-local"></p>
`;
const publicationMainEl = publicationStatus.querySelector('#publish-status-main');
const publicationLocalEl = publicationStatus.querySelector('#publish-status-local');
const pdaCard = document.createElement('div');
pdaCard.className = 'card stack';
pdaCard.innerHTML = `
<h3 style="margin:0;">Что закреплено в PDA</h3>
<p class="meta-muted" id="pda-name">Блокчейн: </p>
<p class="meta-muted" id="pda-address" style="word-break:break-all;">PDA: </p>
<p class="meta-muted" id="pda-last">Крайний блок: </p>
<p class="meta-muted" id="pda-hash" style="word-break:break-all; font-size:11px;">Hash: </p>
<p class="meta-muted" id="pda-used">Размер: </p>
<p class="meta-muted" id="pda-limit">Лимит: </p>
<p class="meta-muted" id="pda-arweave" style="word-break:break-all;">Arweave tx: </p>
<a class="text-btn" id="pda-arweave-link" href="#" target="_blank" rel="noreferrer noopener" style="pointer-events:none; opacity:.55; padding:0;">Открыть Arweave</a>
`;
const serverCard = document.createElement('div');
serverCard.className = 'card stack';
serverCard.innerHTML = `
<h3 style="margin:0;">Что реально на сервере</h3>
<p class="meta-muted" id="server-name">Блокчейн: </p>
<p class="meta-muted" id="server-last">Крайний блок: </p>
<p class="meta-muted" id="server-hash" style="word-break:break-all; font-size:11px;">Hash: </p>
<p class="meta-muted" id="server-size">Размер: </p>
<p class="meta-muted" id="server-updated">Обновлено: </p>
`;
const localCard = document.createElement('div');
localCard.className = 'card stack';
localCard.innerHTML = `
<h3 style="margin:0;">Локальный слепок в этом браузере</h3>
<p class="meta-muted" id="local-state">Слепок: </p>
<p class="meta-muted" id="local-transport">Способ: </p>
<p class="meta-muted" id="local-last">Крайний блок: </p>
<p class="meta-muted" id="local-hash" style="word-break:break-all; font-size:11px;">Hash: </p>
<p class="meta-muted" id="local-size">Размер: </p>
<p class="meta-muted" id="local-arweave" style="word-break:break-all;">Arweave tx: </p>
<p class="meta-muted" id="local-created">Создан: </p>
<p class="meta-muted" id="local-solana">Solana: </p>
`;
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
<button class="ghost-btn" id="refresh-solana-publish" style="width:100%;">Обновить состояние</button>
<button class="primary-btn" id="create-solana-snapshot" style="width:100%;">Сделать слепок в Arweave / Turbo</button>
<button class="primary-btn" id="publish-solana-snapshot" style="width:100%;">Обновить Solana по готовому слепку</button>
<button class="text-btn" id="clear-solana-snapshot" style="width:100%;">Забыть локальный слепок в этом браузере</button>
`;
const refreshBtn = actions.querySelector('#refresh-solana-publish');
const createBtn = actions.querySelector('#create-solana-snapshot');
const publishBtn = actions.querySelector('#publish-solana-snapshot');
const clearBtn = actions.querySelector('#clear-solana-snapshot');
const pdaNameEl = pdaCard.querySelector('#pda-name');
const pdaAddressEl = pdaCard.querySelector('#pda-address');
const pdaLastEl = pdaCard.querySelector('#pda-last');
const pdaHashEl = pdaCard.querySelector('#pda-hash');
const pdaUsedEl = pdaCard.querySelector('#pda-used');
const pdaLimitEl = pdaCard.querySelector('#pda-limit');
const pdaArweaveEl = pdaCard.querySelector('#pda-arweave');
const pdaArweaveLinkEl = pdaCard.querySelector('#pda-arweave-link');
const serverNameEl = serverCard.querySelector('#server-name');
const serverLastEl = serverCard.querySelector('#server-last');
const serverHashEl = serverCard.querySelector('#server-hash');
const serverSizeEl = serverCard.querySelector('#server-size');
const serverUpdatedEl = serverCard.querySelector('#server-updated');
const localStateEl = localCard.querySelector('#local-state');
const localTransportEl = localCard.querySelector('#local-transport');
const localLastEl = localCard.querySelector('#local-last');
const localHashEl = localCard.querySelector('#local-hash');
const localSizeEl = localCard.querySelector('#local-size');
const localArweaveEl = localCard.querySelector('#local-arweave');
const localCreatedEl = localCard.querySelector('#local-created');
const localSolanaEl = localCard.querySelector('#local-solana');
function setButtonsDisabled(disabled) {
refreshBtn.disabled = disabled;
createBtn.disabled = disabled;
publishBtn.disabled = disabled;
clearBtn.disabled = disabled;
}
async function refreshState() {
setButtonsDisabled(true);
try {
const [usage, serverState] = await Promise.all([
getShineBlockchainUsage({
login,
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerBlockchainState(),
]);
const localSnapshot = readStoredBlockchainSnapshot(login);
if (modeToken !== activeModeToken) return;
const pdaDelta = Math.max(0, Number(serverState.lastNumber || 0) - Number(usage.lastBlockNumber || 0));
if (!usage.arweaveTxId) {
publicationMainEl.textContent = 'не опубликовано';
} else if (pdaDelta > 0) {
publicationMainEl.textContent = `опубликовано, отстаёт на ${pdaDelta} блоков`;
} else {
publicationMainEl.textContent = 'опубликовано и полностью актуально';
}
let localStatusText = 'Локального слепка нет.';
const localMatchesServer = localSnapshot
&& String(localSnapshot.blockchainName || '') === String(usage.blockchainName || serverState.blockchainName || '')
&& Number(localSnapshot.lastBlockNumber) === Number(serverState.lastNumber)
&& normalizeHex64(localSnapshot.lastBlockHash) === normalizeHex64(serverState.lastHash)
&& Number(localSnapshot.usedBytes || 0) === Number(serverState.sizeBytes || 0);
if (localSnapshot?.txId && localMatchesServer) {
if (String(localSnapshot.txId || '') === String(usage.arweaveTxId || '')) {
localStatusText = 'Локальный слепок уже совпадает с тем, что закреплено в Solana.';
} else {
localStatusText = 'Слепок уже есть, но Solana ещё не обновлена. Лучше подождать 1-2 минуты и затем обновить PDA.';
}
} else if (localSnapshot?.txId) {
localStatusText = 'Есть локальный слепок, но он уже устарел относительно сервера.';
}
publicationLocalEl.textContent = localStatusText;
pdaNameEl.textContent = `Блокчейн: ${usage.blockchainName || '—'}`;
pdaAddressEl.textContent = `PDA: ${usage.userPda || '—'}`;
pdaLastEl.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
pdaHashEl.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
pdaUsedEl.textContent = `Размер: ${formatBytesRu(usage.usedBytes)} байт`;
pdaLimitEl.textContent = `Лимит: ${formatBytesRu(usage.paidLimitBytes)} байт`;
pdaArweaveEl.textContent = `Arweave tx: ${usage.arweaveTxId || '—'}`;
if (usage.arweaveTxId) {
pdaArweaveLinkEl.href = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: usage.arweaveTxId });
pdaArweaveLinkEl.style.pointerEvents = 'auto';
pdaArweaveLinkEl.style.opacity = '1';
} else {
pdaArweaveLinkEl.href = '#';
pdaArweaveLinkEl.style.pointerEvents = 'none';
pdaArweaveLinkEl.style.opacity = '.55';
}
serverNameEl.textContent = `Блокчейн: ${serverState.blockchainName || usage.blockchainName || '—'}`;
serverLastEl.textContent = `Крайний блок: ${serverState.lastNumber}`;
serverHashEl.textContent = `Hash: ${serverState.lastHash || '—'}`;
serverSizeEl.textContent = `Размер: ${formatBytesRu(serverState.sizeBytes)} байт`;
serverUpdatedEl.textContent = `Обновлено: ${nowRu()}`;
if (localSnapshot?.txId) {
localStateEl.textContent = `Слепок: ${String(localSnapshot.txId || '') === String(usage.arweaveTxId || '') ? 'закреплён' : 'есть, но не закреплён в Solana'}`;
localTransportEl.textContent = `Способ: ${localSnapshot.uploadTransport === 'turbo' ? 'Turbo' : (localSnapshot.uploadTransport === 'arweave' ? 'Arweave' : '—')}`;
localLastEl.textContent = `Крайний блок: ${localSnapshot.lastBlockNumber ?? '—'}`;
localHashEl.textContent = `Hash: ${localSnapshot.lastBlockHash || '—'}`;
localSizeEl.textContent = `Размер: ${formatBytesRu(localSnapshot.usedBytes || 0)} байт`;
localArweaveEl.textContent = `Arweave tx: ${localSnapshot.txId}`;
localCreatedEl.textContent = `Создан: ${localSnapshot.uploadedAtMs ? new Date(localSnapshot.uploadedAtMs).toLocaleString('ru-RU') : '—'}`;
localSolanaEl.textContent = localSnapshot.solanaUpdatedAtMs
? `Solana обновлена: ${new Date(localSnapshot.solanaUpdatedAtMs).toLocaleString('ru-RU')}`
: 'Solana: слепок ещё не закреплён';
} else {
localStateEl.textContent = 'Слепок: отсутствует';
localTransportEl.textContent = 'Способ: —';
localLastEl.textContent = 'Крайний блок: —';
localHashEl.textContent = 'Hash: —';
localSizeEl.textContent = 'Размер: —';
localArweaveEl.textContent = 'Arweave tx: —';
localCreatedEl.textContent = 'Создан: —';
localSolanaEl.textContent = 'Solana: —';
}
publishBtn.disabled = !localSnapshot?.txId || !localMatchesServer || String(localSnapshot.txId || '') === String(usage.arweaveTxId || '');
clearBtn.disabled = !localSnapshot;
setStatus('Состояние публикации обновлено.');
} catch (error) {
if (modeToken !== activeModeToken) return;
publishBtn.disabled = true;
setStatus(`Не удалось обновить состояние публикации: ${error?.message || 'unknown'}`);
} finally {
refreshBtn.disabled = false;
createBtn.disabled = false;
}
}
async function createSnapshot() {
setButtonsDisabled(true);
try {
setStatus('Собираю полный слепок блокчейна с сервера...');
const [usage, serverState] = await Promise.all([
getShineBlockchainUsage({
login,
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerBlockchainState(),
]);
const snapshotFile = await buildBlockchainSnapshotFile({
authService,
login,
blockchainName: usage.blockchainName || serverState.blockchainName,
lastBlockNumber: serverState.lastNumber,
});
const snapshotSha256 = await sha256HexFromArrayBuffer(await snapshotFile.file.arrayBuffer());
if (modeToken !== activeModeToken) return;
setStatus('Слепок собран. Открываю менеджер загрузки Arweave / Turbo...');
const uploaded = await openArweaveAttachmentManager({
login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
historyOnly: false,
persistToHistory: false,
allowHistorySelection: false,
allowExistingTxInput: false,
uploadTransport: 'turbo',
dialogTitle: 'Сделать слепок блокчейна',
uploadButtonLabel: 'Сделать слепок',
initialFile: snapshotFile.file,
initialSha256: snapshotSha256,
initialName: `${snapshotFile.blockchainName}.shine-blockchain`,
fixedFile: true,
autoOpenFileDialog: false,
shineType: 'blockchain-snapshot',
extraUploadTags: [
{ name: 'SHiNE-Blockchain-Snapshot', value: '1' },
{ name: 'SHiNE-Login', value: login },
{ name: 'SHiNE-Blockchain-Name', value: snapshotFile.blockchainName },
{ name: 'SHiNE-Last-Block-Number', value: String(serverState.lastNumber) },
{ name: 'SHiNE-Last-Block-Hash', value: String(serverState.lastHash || '') },
],
});
if (modeToken !== activeModeToken) return;
if (!uploaded?.ar) {
setStatus('Загрузка слепка отменена.');
return;
}
saveStoredBlockchainSnapshot(login, {
blockchainName: snapshotFile.blockchainName,
txId: String(uploaded.ar || '').trim(),
sha256: String(uploaded.sha256 || snapshotSha256).trim().toLowerCase(),
uploadTransport: String(uploaded.uploadTransport || '').trim().toLowerCase(),
usedBytes: Number(serverState.sizeBytes || 0),
lastBlockNumber: Number(serverState.lastNumber),
lastBlockHash: String(serverState.lastHash || '').trim().toLowerCase(),
uploadedAtMs: Date.now(),
});
setStatus('Слепок создан. Лучше подождать 1-2 минуты и затем обновить PDA в Solana.');
await refreshState();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось сделать слепок: ${error?.message || 'unknown'}`);
} finally {
if (modeToken === activeModeToken) {
setButtonsDisabled(false);
}
}
}
async function publishSnapshotToSolana() {
setButtonsDisabled(true);
try {
const localSnapshot = readStoredBlockchainSnapshot(login);
if (!localSnapshot?.txId) throw new Error('Сначала сделайте слепок в Arweave или Turbo.');
const [usage, serverState, signing] = await Promise.all([
getShineBlockchainUsage({
login,
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerBlockchainState(),
resolveWalletSigningMaterial(),
]);
if (String(localSnapshot.blockchainName || '') !== String(usage.blockchainName || serverState.blockchainName || '')) {
throw new Error('Локальный слепок относится к другому блокчейну.');
}
if (Number(localSnapshot.lastBlockNumber) !== Number(serverState.lastNumber)
|| normalizeHex64(localSnapshot.lastBlockHash) !== normalizeHex64(serverState.lastHash)
|| Number(localSnapshot.usedBytes || 0) !== Number(serverState.sizeBytes || 0)) {
throw new Error('Локальный слепок устарел. Сделайте новый слепок для текущей вершины.');
}
const result = await updateShineUserPdaOnSolana({
login,
solanaEndpoint: state.entrySettings.solanaServer,
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
additionalLimitBytes: 0n,
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
nextLastBlockNumber: serverState.lastNumber,
nextLastBlockHashHex: serverState.lastHash,
nextArweaveTxId: localSnapshot.txId,
});
if (modeToken !== activeModeToken) return;
saveStoredBlockchainSnapshot(login, {
...localSnapshot,
solanaUpdatedAtMs: Date.now(),
solanaSignature: String(result.signature || '').trim(),
});
setStatus(`Слепок закреплён в Solana. Tx: ${result.signature}`);
await refreshState();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось обновить Solana: ${error?.message || 'unknown'}`);
} finally {
if (modeToken === activeModeToken) {
setButtonsDisabled(false);
}
}
}
refreshBtn.addEventListener('click', () => {
void refreshState();
});
createBtn.addEventListener('click', () => {
void createSnapshot();
});
publishBtn.addEventListener('click', () => {
void publishSnapshotToSolana();
});
clearBtn.addEventListener('click', () => {
clearStoredBlockchainSnapshot(login);
setStatus('Локальный слепок удалён из браузера.');
void refreshState();
});
content.append(backBtn, summaryCard, publicationStatus, pdaCard, serverCard, localCard, actions);
setStatus('Загрузка состояния публикации...');
await refreshState();
}
async function renderSolanaWallet() {
const modeToken = ++activeModeToken;
clearArweaveSecretsInMemory();
+7 -41
View File
@@ -1,8 +1,5 @@
import { parseShineRouteParts } from './services/shine-routes.js';
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
const SWIPEABLE_ROOT_PAGES = ['messages-list', 'channels-list', 'notifications-view', 'profile-view'];
const lastVisitedRouteByRoot = new Map();
let previousTrackedPath = '';
let currentTrackedPath = String(window.location.pathname || '').trim() || '/';
const PRETTY_PATHS = new Map([
@@ -365,12 +362,6 @@ export function navigate(path) {
window.dispatchEvent(new PopStateEvent('popstate'));
}
function normalizeCurrentPath() {
return String(window.location.pathname || '')
.replace(/^\/+/, '')
.replace(/\/+$/, '');
}
export function toPrettyPath(path) {
const raw = String(path || '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!raw) return '';
@@ -387,7 +378,13 @@ export function navigateBack() {
}
export function resolveToolbarActive(pageId) {
if (ROOT_PAGES.includes(pageId)) return pageId;
if (
pageId === 'messages-list'
|| pageId === 'channels-list'
|| pageId === 'network-view'
|| pageId === 'notifications-view'
|| pageId === 'profile-view'
) return pageId;
if (
pageId === 'profile-edit-view' ||
pageId === 'wallet-view' ||
@@ -416,37 +413,6 @@ export function resolveToolbarActive(pageId) {
return 'profile-view';
}
export function rememberToolbarRoute(pageId, explicitPath = '') {
const rootPageId = resolveToolbarActive(pageId);
if (!ROOT_PAGES.includes(rootPageId)) return;
const cleanPath = String(explicitPath || normalizeCurrentPath()).trim();
lastVisitedRouteByRoot.set(rootPageId, cleanPath || toPrettyPath(rootPageId) || rootPageId);
}
export function getToolbarNavigationTarget(pageId) {
const rootPageId = resolveToolbarActive(pageId);
return lastVisitedRouteByRoot.get(rootPageId) || toPrettyPath(rootPageId) || rootPageId;
}
export function resetRememberedToolbarRoutes() {
lastVisitedRouteByRoot.clear();
previousTrackedPath = '';
currentTrackedPath = String(window.location.pathname || '').trim() || '/';
}
export function getSwipeNavigationTarget(currentPageId, direction) {
const rootPageId = resolveToolbarActive(currentPageId);
const index = SWIPEABLE_ROOT_PAGES.indexOf(rootPageId);
if (index === -1) return '';
const step = direction === 'left' ? 1 : direction === 'right' ? -1 : 0;
if (!step) return '';
const nextIndex = index + step;
if (nextIndex < 0 || nextIndex >= SWIPEABLE_ROOT_PAGES.length) return '';
return getToolbarNavigationTarget(SWIPEABLE_ROOT_PAGES[nextIndex]);
}
export function syncTrackedRouteHistory(pathname = '') {
const nextPath = String(pathname || '').trim() || '/';
if (nextPath === currentTrackedPath) return;
+50 -19
View File
@@ -1022,6 +1022,18 @@ export class AuthService {
return response.payload || {};
}
async resolveCanonicalDisplayLogin(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) return '';
try {
const user = await this.getUser(cleanLogin);
const canonicalLogin = String(user?.login || '').trim();
return canonicalLogin || cleanLogin;
} catch {
return cleanLogin;
}
}
async ensureLoginFree(login) {
const payload = await this.getUser(login);
return payload.exists !== true;
@@ -1127,8 +1139,10 @@ export class AuthService {
const sessionId = createResp?.payload?.sessionId;
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
return {
login: cleanLogin,
login: canonicalLogin,
sessionId,
storagePwd,
sessionMaterial: {
@@ -1213,8 +1227,10 @@ export class AuthService {
const sessionId = createResp?.payload?.sessionId;
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
const canonicalLogin = await tempAuth.resolveCanonicalDisplayLogin(cleanLogin);
return {
login: cleanLogin,
login: canonicalLogin,
sessionId,
storagePwd,
sessionKey: cleanSessionKey,
@@ -1287,8 +1303,10 @@ export class AuthService {
const storagePwd = loginResp?.payload?.storagePwd;
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
const canonicalLogin = await this.resolveCanonicalDisplayLogin(cleanLogin);
return {
login: cleanLogin,
login: canonicalLogin,
sessionId: targetSessionId,
storagePwd,
};
@@ -2476,6 +2494,10 @@ export class AuthService {
return response.payload || {};
}
normalizeDmLogin(login) {
return String(login || '').trim().toLowerCase();
}
async buildSignedDmBlock({
signerLogin,
fromLogin,
@@ -2488,9 +2510,9 @@ export class AuthService {
reencryptedAtMs = 0,
bodyBytes = new Uint8Array(0),
}) {
const cleanSignerLogin = String(signerLogin || '').trim();
const cleanFromLogin = String(fromLogin || '').trim();
const cleanToLogin = String(toLogin || '').trim();
const cleanSignerLogin = this.normalizeDmLogin(signerLogin);
const cleanFromLogin = this.normalizeDmLogin(fromLogin);
const cleanToLogin = this.normalizeDmLogin(toLogin);
if (!cleanSignerLogin || !cleanFromLogin || !cleanToLogin) throw new Error('Не передан signerLogin/fromLogin/toLogin');
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
if (!(bodyBytes instanceof Uint8Array) || bodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
@@ -2590,8 +2612,8 @@ export class AuthService {
revisionTimeMs = 0,
reencryptedAtMs = 0,
}) {
const cleanFromLogin = String(login || '').trim();
const cleanToLogin = String(toLogin || '').trim();
const cleanFromLogin = this.normalizeDmLogin(login);
const cleanToLogin = this.normalizeDmLogin(toLogin);
const cleanText = String(text || '');
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
const normalizedTimeMs = Number(timeMs);
@@ -2674,8 +2696,8 @@ export class AuthService {
}
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs, deleteByRecipient = false }) {
const cleanLogin = String(login || '').trim();
const cleanPeerLogin = String(toLogin || '').trim();
const cleanLogin = this.normalizeDmLogin(login);
const cleanPeerLogin = this.normalizeDmLogin(toLogin);
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
const normalizedTimeMs = Number(timeMs);
const normalizedNonce = Number(nonce);
@@ -2706,14 +2728,23 @@ export class AuthService {
}
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce }) {
const cleanLogin = this.normalizeDmLogin(login);
const cleanToLogin = this.normalizeDmLogin(toLogin);
const cleanRefToLogin = this.normalizeDmLogin(refToLogin);
const cleanRefFromLogin = this.normalizeDmLogin(refFromLogin);
const timeMs = Date.now();
const nonce = Math.floor(Math.random() * 0x100000000);
const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce });
const payload = buildReadReceiptPayloadBytes({
refToLogin: cleanRefToLogin,
refFromLogin: cleanRefFromLogin,
refTimeMs,
refNonce,
});
const type3 = await this.buildSignedDmBlock({
signerLogin: login,
fromLogin: login,
toLogin,
signerLogin: cleanLogin,
fromLogin: cleanLogin,
toLogin: cleanToLogin,
storagePwd,
timeMs,
nonce,
@@ -2721,9 +2752,9 @@ export class AuthService {
bodyBytes: payload,
});
const type4 = await this.buildSignedDmBlock({
signerLogin: login,
fromLogin: login,
toLogin,
signerLogin: cleanLogin,
fromLogin: cleanLogin,
toLogin: cleanToLogin,
storagePwd,
timeMs,
nonce,
@@ -2737,8 +2768,8 @@ export class AuthService {
}
async deleteConversation({ login, toLogin, storagePwd, deleteByRecipient = false, timeMs = Date.now(), nonce = Math.floor(Math.random() * 0x100000000) }) {
const cleanLogin = String(login || '').trim();
const cleanPeerLogin = String(toLogin || '').trim();
const cleanLogin = this.normalizeDmLogin(login);
const cleanPeerLogin = this.normalizeDmLogin(toLogin);
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
const normalizedTimeMs = Number(timeMs);
const normalizedNonce = Number(nonce);
+17 -4
View File
@@ -8,6 +8,10 @@ const DB_VERSION = 1;
const STORE_SECRETS = 'encrypted-secrets';
const STORE_SESSIONS = 'session-keys';
function normalizeLoginStorageKey(login) {
return String(login || '').trim().toLowerCase();
}
function openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
@@ -50,15 +54,20 @@ async function get(storeName, key) {
export async function saveEncryptedUserSecrets(login, storagePwd, keys) {
const encrypted = await encryptJsonWithStoragePwd(keys, storagePwd);
const normalizedLogin = normalizeLoginStorageKey(login);
await put(STORE_SECRETS, {
login,
login: normalizedLogin,
encrypted,
updatedAtMs: Date.now(),
});
}
export async function loadEncryptedUserSecrets(login, storagePwd) {
const row = await get(STORE_SECRETS, login);
const normalizedLogin = normalizeLoginStorageKey(login);
let row = await get(STORE_SECRETS, normalizedLogin);
if (!row?.encrypted && normalizedLogin !== String(login || '').trim()) {
row = await get(STORE_SECRETS, String(login || '').trim());
}
if (!row?.encrypted) {
throw new Error('На устройстве нет сохранённых ключей для этого логина');
}
@@ -80,15 +89,19 @@ export async function updateEncryptedUserSecrets(login, storagePwd, updater) {
}
export async function saveSessionMaterial(login, material) {
const normalizedLogin = normalizeLoginStorageKey(login);
await put(STORE_SESSIONS, {
login,
login: normalizedLogin,
...material,
updatedAtMs: Date.now(),
});
}
export async function loadSessionMaterial(login) {
return get(STORE_SESSIONS, login);
const normalizedLogin = normalizeLoginStorageKey(login);
const row = await get(STORE_SESSIONS, normalizedLogin);
if (row || normalizedLogin === String(login || '').trim()) return row;
return get(STORE_SESSIONS, String(login || '').trim());
}
export async function clearClientAuthData() {
@@ -0,0 +1,108 @@
const SNAPSHOT_STORAGE_KEY = 'shine-ui-blockchain-snapshot-v1';
function normalizeLogin(login) {
return String(login || '').trim().toLowerCase();
}
function storageBucketKey(login) {
return normalizeLogin(login) || 'anonymous';
}
function decodeBase64ToBytes(base64) {
const raw = String(base64 || '').trim();
if (!raw) return new Uint8Array();
const bin = atob(raw);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
return out;
}
function readStorageMap() {
try {
const raw = localStorage.getItem(SNAPSHOT_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeStorageMap(value) {
try {
localStorage.setItem(SNAPSHOT_STORAGE_KEY, JSON.stringify(value || {}));
} catch {
// ignore localStorage errors
}
}
export function readStoredBlockchainSnapshot(login) {
const key = storageBucketKey(login);
const map = readStorageMap();
const value = map?.[key];
return value && typeof value === 'object' ? value : null;
}
export function saveStoredBlockchainSnapshot(login, snapshot) {
const key = storageBucketKey(login);
const map = readStorageMap();
map[key] = {
...snapshot,
savedAtMs: Date.now(),
};
writeStorageMap(map);
return map[key];
}
export function clearStoredBlockchainSnapshot(login) {
const key = storageBucketKey(login);
const map = readStorageMap();
delete map[key];
writeStorageMap(map);
}
export async function buildBlockchainSnapshotFile({
authService,
login,
blockchainName,
lastBlockNumber,
} = {}) {
const cleanLogin = normalizeLogin(login);
const cleanBlockchainName = String(blockchainName || '').trim();
const maxBlockNumber = Number(lastBlockNumber);
if (!authService?.ws?.request) throw new Error('Сервис сервера недоступен.');
if (!cleanLogin) throw new Error('Не указан логин.');
if (!cleanBlockchainName) throw new Error('Не указано имя блокчейна.');
if (!Number.isFinite(maxBlockNumber) || maxBlockNumber < 0) {
throw new Error('На сервере нет блоков для слепка.');
}
const parts = [];
let totalBytes = 0;
for (let blockNumber = 0; blockNumber <= maxBlockNumber; blockNumber += 1) {
const response = await authService.ws.request('GetBlockchainBlock', {
blockchainName: cleanBlockchainName,
blockNumber,
});
if (response?.status !== 200) {
const message = String(response?.payload?.message || response?.message || 'Не удалось получить блок.');
throw new Error(`Не удалось скачать блок ${blockNumber}: ${message}`);
}
const blockBytes = decodeBase64ToBytes(response?.payload?.blockBytesB64 || '');
parts.push(blockBytes);
totalBytes += blockBytes.length;
}
const file = new File(parts, `${cleanBlockchainName}.shine-blockchain`, {
type: 'application/octet-stream',
lastModified: Date.now(),
});
return {
file,
blockCount: maxBlockNumber + 1,
totalBytes,
lastBlockNumber: maxBlockNumber,
login: cleanLogin,
blockchainName: cleanBlockchainName,
};
}
+21 -10
View File
@@ -721,6 +721,8 @@ export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
paidLimitBytes: bch.paidLimitBytes,
usedBytes: bch.usedBytes,
leftBytes,
blockchainName: String(bch.blockchainName || ''),
arweaveTxId: String(bch.arweaveTxId || ''),
lastBlockNumber: bch.lastBlockNumber,
lastBlockHashHex: Array.from(bch.lastBlockHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
};
@@ -762,9 +764,10 @@ async function attachSolanaLogs(error, connection) {
}
async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
const rawLogin = String(login || '').trim();
const cleanLogin = normalizeLogin(login);
const endpoint = String(solanaEndpoint || '').trim();
if (!cleanLogin) throw new Error('Не указан логин');
if (!rawLogin || !cleanLogin) throw new Error('Не указан логин');
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
@@ -791,6 +794,7 @@ async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
return {
rawLogin,
cleanLogin,
endpoint,
solana,
@@ -832,18 +836,19 @@ async function createShineUserPdaOnSolana({
}
const cleanLogin = ctx.cleanLogin;
const displayLogin = ctx.rawLogin;
const cleanPromoCode = String(promoCode || '').trim();
const blockchainName = `${cleanLogin}-001`;
const zeroHash32 = new Uint8Array(32);
const createdAtMs = BigInt(Date.now());
const startBonusLimit = parseUsersEconomyConfig(ecoAccount.data).startBonusLimit;
const lastBlockStateBytes = buildLastBlockStateBytes(cleanLogin, blockchainName, 0, zeroHash32, 0n);
const lastBlockStateBytes = buildLastBlockStateBytes(displayLogin, blockchainName, 0, zeroHash32, 0n);
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
const lastBlockSig64 = await signBytes(ctx.bchPrivKey, lastBlockStateHash);
const initialState = createPdaState({
login: cleanLogin,
login: displayLogin,
createdAtMs,
updatedAtMs: createdAtMs,
recordNumber: 0,
@@ -910,7 +915,7 @@ async function createShineUserPdaOnSolana({
});
}
const ixData = serializeCreateUserPdaArgs({
login: cleanLogin,
login: displayLogin,
recoveryKey32: ctx.recoveryKey32,
rootKey32: ctx.rootKey32,
createdAtMs,
@@ -1028,6 +1033,7 @@ export async function updateShineUserPdaOnSolana({
nextUsedBytes,
nextLastBlockNumber,
nextLastBlockHashHex,
nextArweaveTxId,
serverProfile,
accessServers,
trustedCount,
@@ -1043,6 +1049,7 @@ export async function updateShineUserPdaOnSolana({
const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes);
const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber);
const effectiveLastHash = parseHex32(nextLastBlockHashHex) || currentBch.lastBlockHash;
const effectiveArweaveTxId = nextArweaveTxId == null ? currentBch.arweaveTxId : String(nextArweaveTxId || '').trim();
if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта');
const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64);
@@ -1060,7 +1067,7 @@ export async function updateShineUserPdaOnSolana({
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
const lastBlockStateBytes = buildLastBlockStateBytes(
cleanLogin,
current.login,
currentBch.blockchainName,
effectiveLastNum,
effectiveLastHash,
@@ -1085,7 +1092,10 @@ export async function updateShineUserPdaOnSolana({
const updatedAtMs = BigInt(Date.now());
const newPaid = currentBch.paidLimitBytes + addLimit;
const newRecordNumber = current.recordNumber + 1;
const prevHash = await sha256Bytes(serializeUnsignedRecordFromState(current));
// Для prev_hash нужно хэшировать точную unsigned-часть текущей PDA,
// а не пересобирать её из распарсенного состояния: иначе можно получить
// несовпадение байт и InvalidPrevHash в on-chain программе.
const prevHash = await sha256Bytes(current.unsignedBytes || serializeUnsignedRecordFromState(current));
const nextServerProfile = serverProfile
? {
@@ -1097,7 +1107,7 @@ export async function updateShineUserPdaOnSolana({
: current.serverProfile;
const nextState = createPdaState({
login: cleanLogin,
login: current.login,
createdAtMs: current.createdAtMs,
updatedAtMs,
recordNumber: newRecordNumber,
@@ -1113,7 +1123,7 @@ export async function updateShineUserPdaOnSolana({
lastBlockNumber: effectiveLastNum,
lastBlockHash: effectiveLastHash,
lastBlockSignature: lastBlockSig64,
arweaveTxId: currentBch.arweaveTxId,
arweaveTxId: effectiveArweaveTxId,
}),
isServer: Boolean(nextServerProfile),
addressFormatType: nextServerProfile?.addressFormatType ?? 0,
@@ -1131,7 +1141,7 @@ export async function updateShineUserPdaOnSolana({
const rootSig64 = await signBytes(rootPriv, unsignedNextHash);
const ixData = serializeUpdateUserPdaArgs({
login: cleanLogin,
login: current.login,
recoveryKey32: current.recoveryKey,
rootKey32: current.rootKey,
createdAtMs: current.createdAtMs,
@@ -1146,7 +1156,7 @@ export async function updateShineUserPdaOnSolana({
lastBlockNumber: effectiveLastNum,
lastBlockHash32: effectiveLastHash,
lastBlockSignature64: lastBlockSig64,
arweaveTxId: currentBch.arweaveTxId,
arweaveTxId: effectiveArweaveTxId,
isServer: nextState.isServer,
addressFormatType: nextState.addressFormatType,
addressFormatVersion: nextState.addressFormatVersion,
@@ -1205,6 +1215,7 @@ export async function updateShineUserPdaOnSolana({
leftBytes: newPaid > effectiveUsed ? (newPaid - effectiveUsed) : 0n,
lastBlockNumber: effectiveLastNum,
lastBlockHashHex: Array.from(effectiveLastHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
arweaveTxId: effectiveArweaveTxId,
};
}
-118
View File
@@ -59,124 +59,6 @@ body::before {
padding: 14px 14px 24px;
}
.screen-swipe-overlay {
--swipe-overlay-opacity: 0.12;
position: absolute;
top: var(--call-minimized-bar-height, 0px);
left: 0;
right: 0;
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
z-index: 8;
overflow: hidden;
pointer-events: none;
opacity: 1;
}
.screen-swipe-overlay::before {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(5, 10, 20, 0.06) 0%, rgba(5, 10, 20, 0.16) 100%),
radial-gradient(circle at center, rgba(89, 165, 255, 0.06) 0%, transparent 70%);
opacity: var(--swipe-overlay-opacity, 0.12);
}
.screen-swipe-pane {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
backface-visibility: hidden;
will-change: transform;
}
.screen-swipe-pane--current {
left: 0;
z-index: 1;
}
.screen-swipe-pane--target {
left: 0;
z-index: 2;
filter: saturate(1.03);
}
.screen-swipe-slot {
position: absolute;
left: 0;
right: 0;
}
.screen-swipe-slot--topbar {
top: 0;
padding: 0 12px;
pointer-events: none;
}
.screen-swipe-slot--topbar > * {
pointer-events: none;
}
.screen-swipe-slot--content {
top: calc(var(--topbar-height, 0px));
bottom: var(--composer-height, 0px);
overflow-y: auto;
padding: 14px 14px 24px;
}
.screen-swipe-slot--composer {
bottom: 0;
padding: 0 12px 8px;
pointer-events: none;
}
.screen-swipe-slot--composer > * {
pointer-events: none;
}
.screen-swipe-pane--target::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(8, 14, 28, 0.05) 0%, rgba(8, 14, 28, 0.12) 100%);
pointer-events: none;
}
.screen-swipe-pane--left {
box-shadow: -24px 0 34px rgba(3, 7, 18, 0.34);
}
.screen-swipe-pane--right {
box-shadow: 24px 0 34px rgba(3, 7, 18, 0.34);
}
.screen-swipe-divider {
position: absolute;
top: 0;
bottom: 0;
left: -1px;
width: 2px;
z-index: 3;
background:
linear-gradient(180deg, rgba(248, 251, 255, 0.68) 0%, rgba(137, 186, 255, 0.52) 48%, rgba(53, 90, 144, 0.34) 100%);
box-shadow:
0 0 0 1px rgba(14, 24, 42, 0.08),
0 0 18px rgba(80, 154, 255, 0.22);
pointer-events: none;
}
.app-shell--swiping .toolbar-slot {
pointer-events: none;
}
.topbar-slot--swipe-hidden,
.screen-content--swipe-hidden,
.composer-slot--swipe-hidden {
opacity: 0;
}
.screen-content.no-app-chrome {
top: var(--call-minimized-bar-height, 0px);
bottom: 0;
+11
View File
@@ -0,0 +1,11 @@
# SHiNE Solana Arweave Viewer
Основной рабочий файл:
- `index.html`
Подробное описание работы, параметров и готовых ссылок:
- `КАК_ЭТО_РАБОТАЕТ.md`
Viewer собран как автономный single-file HTML и запускается одним файлом без соседних зависимостей.
@@ -0,0 +1,11 @@
# SHiNE Solana Arweave Viewer
Основной рабочий файл:
- `index.html`
Подробное описание работы, параметров и готовых ссылок:
- `КАК_ЭТО_РАБОТАЕТ.md`
Viewer собран как автономный single-file HTML и запускается одним файлом без соседних зависимостей.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
# SHiNE Solana Arweave Viewer: как это работает
## Что лежит в папке
Основной и единственный рабочий файл viewer:
- `index.html`
Это автономный single-file viewer:
- весь HTML внутри одного файла;
- все стили внутри этого же файла;
- весь JavaScript внутри этого же файла;
- локальная копия `solana-web3` тоже встроена внутрь этого же файла.
Для запуска viewer достаточно открыть один `index.html`.
## Что делает viewer
Viewer открывается как обычная HTML-страница и работает полностью в браузере.
Он умеет:
- принимать логин пользователя SHiNE;
- принимать `Solana RPC` через форму или через URL-параметр;
- принимать `Arweave gateway` через форму или через URL-параметр;
- сохранять эти значения в `localStorage`;
- вычислять `user_pda` по логину для программы `shine_users`;
- читать `PDA` напрямую из `Solana`;
- распарсивать содержимое записи `shine_users`;
- показывать ключи, лимиты, номер записи, блокчейн пользователя и `Arweave tx id`;
- строить прямую ссылку на `Arweave`, если `tx id` есть в записи.
## Какие URL-параметры поддерживаются
Viewer понимает такие параметры:
- `login`
- `solanaRpc`
- `arweaveGateway`
Пример:
```text
index.html?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com&arweaveGateway=https%3A%2F%2Farweave.net
```
## Предустановленные адреса
### Solana RPC
- `https://solana-rpc.publicnode.com`
- `https://public.rpc.solanavibestation.com/`
### Arweave gateway
- `https://arweave.net`
## Почему одна из RPC может не работать
Адрес:
- `https://public.rpc.solanavibestation.com/`
может возвращать браузерную ошибку `403` с текстом про `Origin is not whitelisted`.
Это означает, что проблема обычно не в SHiNE viewer, а в CORS-политике самого RPC.
Для HTML-страницы, открытой прямо в браузере, это обычно не лечится со стороны клиента.
Практически для viewer лучше использовать:
- `https://solana-rpc.publicnode.com`
## Ссылки на загруженный файл в Arweave
Основная ссылка:
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI`
Та же ссылка с тестовым логином `aidartest`:
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest`
Та же ссылка с тестовым логином `aidartest` и явным рабочим RPC:
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com`
## Что будет, если поставить devnet RPC
Официальный публичный `Solana devnet RPC`:
- `https://api.devnet.solana.com`
Если подставить `devnet` RPC, viewer начнёт читать именно сеть `devnet`, а не `mainnet`.
Но это не означает автоматически, что вся система SHiNE "стала тестовой".
Для реальной работы в `devnet` должны совпасть сразу несколько условий:
- программа `shine_users` должна быть задеплоена в `devnet`;
- в `devnet` должен существовать нужный `PDA` для логина;
- формат записи должен совпадать;
- данные пользователя должны реально лежать в этой тестовой сети.
Если этого нет, viewer просто не найдёт запись.
## Источник по devnet RPC
Официальная документация Solana:
- `https://solana.com/docs/references/clusters`
Там указан devnet endpoint:
- `https://api.devnet.solana.com`
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
# SHiNE Solana Arweave Viewer: как это работает
## Что лежит в папке
Основной и единственный рабочий файл viewer:
- `index.html`
Это автономный single-file viewer:
- весь HTML внутри одного файла;
- все стили внутри этого же файла;
- весь JavaScript внутри этого же файла;
- локальная копия `solana-web3` тоже встроена внутрь этого же файла.
Для запуска viewer достаточно открыть один `index.html`.
## Что делает viewer
Viewer открывается как обычная HTML-страница и работает полностью в браузере.
Он умеет:
- принимать логин пользователя SHiNE;
- принимать `Solana RPC` через форму или через URL-параметр;
- принимать `Arweave gateway` через форму или через URL-параметр;
- сохранять эти значения в `localStorage`;
- вычислять `user_pda` по логину для программы `shine_users`;
- читать `PDA` напрямую из `Solana`;
- распарсивать содержимое записи `shine_users`;
- показывать ключи, лимиты, номер записи, блокчейн пользователя и `Arweave tx id`;
- строить прямую ссылку на `Arweave`, если `tx id` есть в записи.
## Какие URL-параметры поддерживаются
Viewer понимает такие параметры:
- `login`
- `solanaRpc`
- `arweaveGateway`
Пример:
```text
index.html?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com&arweaveGateway=https%3A%2F%2Farweave.net
```
## Предустановленные адреса
### Solana RPC
- `https://solana-rpc.publicnode.com`
- `https://public.rpc.solanavibestation.com/`
### Arweave gateway
- `https://arweave.net`
## Почему одна из RPC может не работать
Адрес:
- `https://public.rpc.solanavibestation.com/`
может возвращать браузерную ошибку `403` с текстом про `Origin is not whitelisted`.
Это означает, что проблема обычно не в SHiNE viewer, а в CORS-политике самого RPC.
Для HTML-страницы, открытой прямо в браузере, это обычно не лечится со стороны клиента.
Практически для viewer лучше использовать:
- `https://solana-rpc.publicnode.com`
## Ссылки на загруженный файл в Arweave
Основная ссылка:
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI`
Та же ссылка с тестовым логином `aidartest`:
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest`
Та же ссылка с тестовым логином `aidartest` и явным рабочим RPC:
- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com`
## Что будет, если поставить devnet RPC
Официальный публичный `Solana devnet RPC`:
- `https://api.devnet.solana.com`
Если подставить `devnet` RPC, viewer начнёт читать именно сеть `devnet`, а не `mainnet`.
Но это не означает автоматически, что вся система SHiNE "стала тестовой".
Для реальной работы в `devnet` должны совпасть сразу несколько условий:
- программа `shine_users` должна быть задеплоена в `devnet`;
- в `devnet` должен существовать нужный `PDA` для логина;
- формат записи должен совпадать;
- данные пользователя должны реально лежать в этой тестовой сети.
Если этого нет, viewer просто не найдёт запись.
## Источник по devnet RPC
Официальная документация Solana:
- `https://solana.com/docs/references/clusters`
Там указан devnet endpoint:
- `https://api.devnet.solana.com`