Compare commits

...
6 Commits
37 changed files with 1974 additions and 59 deletions
@@ -19,8 +19,10 @@ public final class DatabaseInitializer {
public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version";
public static final int SCHEMA_VERSION_1 = 1;
public static final int SCHEMA_VERSION_2 = 2;
public static final int SCHEMA_VERSION_3 = 3;
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";
private DatabaseInitializer() {}
@@ -82,6 +84,10 @@ public final class DatabaseInitializer {
int currentVersion = readCurrentSchemaVersion(conn);
if (currentVersion < SCHEMA_VERSION_2) {
runSqlScript(conn, POSTGRES_MIGRATION_V2_RESOURCE);
currentVersion = SCHEMA_VERSION_2;
}
if (currentVersion < SCHEMA_VERSION_3) {
runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE);
}
}
}
@@ -154,6 +154,11 @@ public final class CurrentUsersDAO {
/** Поиск по префиксу с внешним соединением. Соединение НЕ закрывает. */
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix) throws SQLException {
return searchByLoginPrefix(c, prefix, null);
}
/** Поиск по префиксу с optional-фильтром server PDA. Соединение НЕ закрывает. */
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix, Boolean isServer) throws SQLException {
String sql = """
SELECT
login,
@@ -163,6 +168,7 @@ public final class CurrentUsersDAO {
client_key
FROM %s
WHERE LOWER(login) LIKE ?
AND (? IS NULL OR is_server = ?)
ORDER BY login
LIMIT 5
""".formatted(CurrentUsersSql.usersSubquery("su"));
@@ -171,6 +177,13 @@ public final class CurrentUsersDAO {
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, prefix.toLowerCase() + "%");
if (isServer == null) {
ps.setNull(2, Types.BOOLEAN);
ps.setNull(3, Types.BOOLEAN);
} else {
ps.setBoolean(2, isServer);
ps.setBoolean(3, isServer);
}
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) result.add(mapRow(rs));
}
@@ -181,8 +194,13 @@ public final class CurrentUsersDAO {
/** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */
public List<CurrentUserEntry> searchByLoginPrefix(String prefix) throws SQLException {
return searchByLoginPrefix(prefix, null);
}
/** Поиск по префиксу без внешнего соединения с optional-фильтром server PDA. */
public List<CurrentUserEntry> searchByLoginPrefix(String prefix, Boolean isServer) throws SQLException {
try (Connection c = db.getConnection()) {
return searchByLoginPrefix(c, prefix);
return searchByLoginPrefix(c, prefix, isServer);
}
}
@@ -0,0 +1,173 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.DmSyncPeerStateEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public final class DmSyncPeerStateDAO {
private static volatile DmSyncPeerStateDAO instance;
private final DbController db = DbController.getInstance();
private DmSyncPeerStateDAO() {}
public static DmSyncPeerStateDAO getInstance() {
if (instance == null) {
synchronized (DmSyncPeerStateDAO.class) {
if (instance == null) instance = new DmSyncPeerStateDAO();
}
}
return instance;
}
public DmSyncPeerStateEntry getOrCreate(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
DmSyncPeerStateEntry existing = get(ownerLogin, remoteServerLogin);
if (existing != null) {
if (remoteServerUrl != null && !remoteServerUrl.isBlank()
&& !remoteServerUrl.equals(existing.getRemoteServerUrl())) {
touchRemoteUrl(ownerLogin, remoteServerLogin, remoteServerUrl);
existing.setRemoteServerUrl(remoteServerUrl);
}
return existing;
}
long nowMs = System.currentTimeMillis();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
INSERT INTO dm_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?)
ON CONFLICT DO NOTHING
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
ps.setLong(4, nowMs);
ps.executeUpdate();
}
existing = get(ownerLogin, remoteServerLogin);
if (existing == null) {
throw new SQLException("Failed to create dm_sync_peer_state row");
}
return existing;
}
public void updateSuccess(
String ownerLogin,
String remoteServerLogin,
String remoteServerUrl,
long cursorStoredAtMs,
String cursorMessageKey,
boolean bootstrapCompleted
) throws SQLException {
long nowMs = System.currentTimeMillis();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
INSERT INTO dm_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
remote_server_url = EXCLUDED.remote_server_url,
cursor_stored_at_ms = EXCLUDED.cursor_stored_at_ms,
cursor_message_key = EXCLUDED.cursor_message_key,
bootstrap_completed = EXCLUDED.bootstrap_completed,
last_sync_at_ms = EXCLUDED.last_sync_at_ms,
last_error = NULL,
updated_at_ms = EXCLUDED.updated_at_ms
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
ps.setLong(4, Math.max(0L, cursorStoredAtMs));
ps.setString(5, cursorMessageKey == null ? "" : cursorMessageKey);
ps.setBoolean(6, bootstrapCompleted);
ps.setLong(7, nowMs);
ps.setLong(8, nowMs);
ps.executeUpdate();
}
}
public void updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException {
long nowMs = System.currentTimeMillis();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
INSERT INTO dm_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?)
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
remote_server_url = EXCLUDED.remote_server_url,
last_error = EXCLUDED.last_error,
updated_at_ms = EXCLUDED.updated_at_ms
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
ps.setString(4, truncate(error));
ps.setLong(5, nowMs);
ps.executeUpdate();
}
}
private DmSyncPeerStateEntry get(String ownerLogin, String remoteServerLogin) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
SELECT owner_login, remote_server_login, remote_server_url,
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
FROM dm_sync_peer_state
WHERE LOWER(owner_login) = LOWER(?)
AND LOWER(remote_server_login) = LOWER(?)
LIMIT 1
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
DmSyncPeerStateEntry e = new DmSyncPeerStateEntry();
e.setOwnerLogin(rs.getString("owner_login"));
e.setRemoteServerLogin(rs.getString("remote_server_login"));
e.setRemoteServerUrl(rs.getString("remote_server_url"));
e.setCursorStoredAtMs(rs.getLong("cursor_stored_at_ms"));
e.setCursorMessageKey(rs.getString("cursor_message_key"));
e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed"));
long lastSyncAt = rs.getLong("last_sync_at_ms");
e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAt);
e.setLastError(rs.getString("last_error"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
return e;
}
}
}
private void touchRemoteUrl(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE dm_sync_peer_state
SET remote_server_url = ?, updated_at_ms = ?
WHERE LOWER(owner_login) = LOWER(?)
AND LOWER(remote_server_login) = LOWER(?)
""")) {
ps.setString(1, remoteServerUrl);
ps.setLong(2, System.currentTimeMillis());
ps.setString(3, ownerLogin);
ps.setString(4, remoteServerLogin);
ps.executeUpdate();
}
}
private static String truncate(String value) {
if (value == null) return null;
String clean = value.trim();
if (clean.length() <= 500) return clean;
return clean.substring(0, 500);
}
}
@@ -416,6 +416,72 @@ public final class SignedMessagesDAO {
}
}
public SyncBatch listSyncBatch(
String ownerLogin,
long afterStoredAtMs,
String afterMessageKey,
int limit,
int maxBytes
) throws Exception {
int safeLimit = Math.max(1, Math.min(limit, 500));
int sqlLimit = safeLimit + 1;
String safeAfterMessageKey = afterMessageKey == null ? "" : afterMessageKey;
try (Connection c = db.getConnection()) {
String sql = """
SELECT
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
raw_block, created_at_ms, source_api, origin_session_id,
receipt_ref_base_key, receipt_ref_type, read_at_ms
FROM %s
WHERE (
LOWER(target_login) = LOWER(?)
OR (
message_type IN (5, 6, 7, 8)
AND (LOWER(from_login) = LOWER(?) OR LOWER(to_login) = LOWER(?))
)
)
AND (
created_at_ms > ?
OR (created_at_ms = ? AND (? = '' OR message_key > ?))
)
ORDER BY created_at_ms ASC, message_key ASC
LIMIT ?
""".formatted(messagesTable());
List<SignedMessageEntry> out = new ArrayList<>();
boolean hasMore = false;
int usedBytes = 0;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setString(2, ownerLogin);
ps.setString(3, ownerLogin);
ps.setLong(4, Math.max(0L, afterStoredAtMs));
ps.setLong(5, Math.max(0L, afterStoredAtMs));
ps.setString(6, safeAfterMessageKey);
ps.setString(7, safeAfterMessageKey);
ps.setInt(8, sqlLimit);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
SignedMessageEntry row = mapRow(rs);
byte[] raw = row.getRawBlock();
int rowBytes = raw == null ? 0 : raw.length;
if (out.size() >= safeLimit) {
hasMore = true;
break;
}
if (!out.isEmpty() && maxBytes > 0 && usedBytes + rowBytes > maxBytes) {
hasMore = true;
break;
}
out.add(row);
usedBytes += rowBytes;
}
}
}
return new SyncBatch(out, hasMore, usedBytes);
}
}
private void upsertMessage(Connection c, SignedMessageEntry e) throws SQLException {
String sql = """
INSERT INTO %s (
@@ -748,6 +814,8 @@ public final class SignedMessagesDAO {
}
}
public record SyncBatch(List<SignedMessageEntry> items, boolean hasMore, int rawBytes) {}
@FunctionalInterface
private interface SqlWork<T> {
T run() throws Exception;
@@ -0,0 +1,86 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.KeyEncodingUtil;
import shine.db.entities.UserAccessServerRouteEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/**
* DAO локальной routing-проекции access servers пользователей.
*/
public final class UserAccessServersCurrentDAO {
private static volatile UserAccessServersCurrentDAO instance;
private final DbController db = DbController.getInstance();
private UserAccessServersCurrentDAO() {}
public static UserAccessServersCurrentDAO getInstance() {
if (instance == null) {
synchronized (UserAccessServersCurrentDAO.class) {
if (instance == null) instance = new UserAccessServersCurrentDAO();
}
}
return instance;
}
public List<UserAccessServerRouteEntry> listByUserLogin(String userLogin) throws SQLException {
try (Connection c = db.getConnection()) {
return listByUserLogin(c, userLogin);
}
}
public List<UserAccessServerRouteEntry> listByUserLogin(Connection c, String userLogin) throws SQLException {
String sql = """
SELECT user_login, server_login, server_url, server_client_key
FROM user_access_servers_current
WHERE LOWER(user_login) = LOWER(?)
ORDER BY server_login
""";
List<UserAccessServerRouteEntry> result = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, userLogin);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
result.add(mapRow(rs));
}
}
}
return result;
}
public List<String> listUserLoginsByServerLogin(String serverLogin) throws SQLException {
String sql = """
SELECT user_login
FROM user_access_servers_current
WHERE LOWER(server_login) = LOWER(?)
ORDER BY user_login
""";
List<String> result = new ArrayList<>();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, serverLogin);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
result.add(rs.getString("user_login"));
}
}
}
return result;
}
private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException {
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
entry.setUserLogin(rs.getString("user_login"));
entry.setServerLogin(rs.getString("server_login"));
entry.setServerUrl(rs.getString("server_url"));
entry.setServerClientKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("server_client_key")));
return entry;
}
}
@@ -0,0 +1,32 @@
package shine.db.entities;
public class DmSyncPeerStateEntry {
private String ownerLogin;
private String remoteServerLogin;
private String remoteServerUrl;
private long cursorStoredAtMs;
private String cursorMessageKey;
private boolean bootstrapCompleted;
private Long lastSyncAtMs;
private String lastError;
private long updatedAtMs;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public String getRemoteServerLogin() { return remoteServerLogin; }
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
public String getRemoteServerUrl() { return remoteServerUrl; }
public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; }
public long getCursorStoredAtMs() { return cursorStoredAtMs; }
public void setCursorStoredAtMs(long cursorStoredAtMs) { this.cursorStoredAtMs = cursorStoredAtMs; }
public String getCursorMessageKey() { return cursorMessageKey; }
public void setCursorMessageKey(String cursorMessageKey) { this.cursorMessageKey = cursorMessageKey; }
public boolean isBootstrapCompleted() { return bootstrapCompleted; }
public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; }
public Long getLastSyncAtMs() { return lastSyncAtMs; }
public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
}
@@ -0,0 +1,27 @@
package shine.db.entities;
/**
* Локальная routing-проекция access server пользователя.
*
* Источник:
* - user_access_servers_current
*/
public class UserAccessServerRouteEntry {
private String userLogin;
private String serverLogin;
private String serverUrl;
private String serverClientKey;
public String getUserLogin() { return userLogin; }
public void setUserLogin(String userLogin) { this.userLogin = userLogin; }
public String getServerLogin() { return serverLogin; }
public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; }
public String getServerUrl() { return serverUrl; }
public void setServerUrl(String serverUrl) { this.serverUrl = serverUrl; }
public String getServerClientKey() { return serverClientKey; }
public void setServerClientKey(String serverClientKey) { this.serverClientKey = serverClientKey; }
}
@@ -15,7 +15,8 @@ public final class CurrentUsersSql {
current_users.blockchain_name AS blockchain_name,
current_users.client_key AS solana_key,
current_users.blockchain_key AS blockchain_key,
current_users.client_key AS client_key
current_users.client_key AS client_key,
current_users.is_server AS is_server
FROM solana_user_pda_current current_users
) %s
""".formatted(alias);
@@ -0,0 +1,47 @@
BEGIN;
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor
ON signed_messages(target_login, created_at_ms, message_key);
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower
ON signed_messages(LOWER(target_login), created_at_ms, message_key);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor
ON signed_messages(from_login, created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor
ON signed_messages(to_login, created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower
ON signed_messages(LOWER(from_login), created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower
ON signed_messages(LOWER(to_login), created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
owner_login TEXT NOT NULL,
remote_server_login TEXT NOT NULL,
remote_server_url TEXT NOT NULL,
cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0,
cursor_message_key TEXT NOT NULL DEFAULT '',
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
last_sync_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, remote_server_login)
);
CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner
ON dm_sync_peer_state(owner_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 3, 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, 2, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
VALUES (1, 3, 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;
@@ -652,6 +652,28 @@ CREATE INDEX IF NOT EXISTS idx_signed_messages_target
CREATE INDEX IF NOT EXISTS idx_signed_messages_base
ON signed_messages(base_key, message_type);
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor
ON signed_messages(target_login, created_at_ms, message_key);
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower
ON signed_messages(LOWER(target_login), created_at_ms, message_key);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor
ON signed_messages(from_login, created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor
ON signed_messages(to_login, created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower
ON signed_messages(LOWER(from_login), created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower
ON signed_messages(LOWER(to_login), created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_receipt_incoming
ON signed_messages(target_login, receipt_ref_base_key)
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
@@ -672,6 +694,22 @@ CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
ON signed_message_session_delivery(session_id, delivered);
CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
owner_login TEXT NOT NULL,
remote_server_login TEXT NOT NULL,
remote_server_url TEXT NOT NULL,
cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0,
cursor_message_key TEXT NOT NULL DEFAULT '',
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
last_sync_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, remote_server_login)
);
CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner
ON dm_sync_peer_state(owner_login);
CREATE TABLE IF NOT EXISTS message_views_state (
viewer_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL,
@@ -88,6 +88,7 @@ import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
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;
@@ -100,6 +101,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
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;
@@ -198,6 +200,7 @@ public final class JsonHandlerRegistry {
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()),
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
@@ -278,6 +281,7 @@ public final class JsonHandlerRegistry {
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class),
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
@@ -35,10 +35,11 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler {
}
String prefix = req.getPrefix().trim();
Boolean isServer = req.getIsServer();
try {
CurrentUsersDAO dao = CurrentUsersDAO.getInstance();
List<CurrentUserEntry> users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5
List<CurrentUserEntry> users = dao.searchByLoginPrefix(prefix, isServer); // case-insensitive + LIMIT 5
List<String> logins = new ArrayList<>();
for (CurrentUserEntry u : users) {
@@ -53,7 +54,7 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler {
resp.setStatus(WireCodes.Status.OK);
resp.setLogins(logins);
log.info("✅ SearchUsers ok: prefix='{}' -> {}", prefix, logins.size());
log.info("✅ SearchUsers ok: prefix='{}', isServer={} -> {}", prefix, isServer, logins.size());
return resp;
} catch (SQLException e) {
@@ -18,7 +18,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_SearchUsers_Request extends Net_Request {
private String prefix;
private Boolean isServer;
public String getPrefix() { return prefix; }
public void setPrefix(String prefix) { this.prefix = prefix; }
}
public Boolean getIsServer() { return isServer; }
public void setIsServer(Boolean isServer) { this.isServer = isServer; }
}
@@ -0,0 +1,50 @@
package server.logic.ws_protocol.JSON.messages;
import shine.db.dao.SignedMessagesDAO;
import shine.db.entities.SignedMessageEntry;
public final class DmSyncApplySupport {
private DmSyncApplySupport() {}
public static ApplyResult applySyncedBlob(String ownerLogin, String blobB64) throws Exception {
if (ownerLogin == null || ownerLogin.isBlank()) {
throw new IllegalArgumentException("EMPTY_OWNER_LOGIN");
}
if (blobB64 == null || blobB64.isBlank()) {
throw new IllegalArgumentException("EMPTY_BLOB");
}
SignedMessageBlock block = SignedMessagesCore.parseFromB64(blobB64);
SignedMessagesCore.verifyUsersAndSignature(block);
SignedMessageEntry entry = SignedMessagesCore.toEntry(block, "DmSyncBatch", null);
String owner = ownerLogin.trim();
boolean ownerMatchesTarget = entry.getTargetLogin().equalsIgnoreCase(owner);
boolean ownerMatchesDeletePair = block.isDeleteType()
&& (entry.getFromLogin().equalsIgnoreCase(owner) || entry.getToLogin().equalsIgnoreCase(owner));
if (!ownerMatchesTarget && !ownerMatchesDeletePair) {
throw new IllegalArgumentException("TARGET_LOGIN_MISMATCH");
}
SignedMessagesDAO.ApplyStatus status;
if (block.isContentType()) {
status = SignedMessagesDAO.getInstance().upsertIncomingCopy(entry);
} else if (block.isReadReceiptType()) {
status = SignedMessagesDAO.getInstance().insertIfAbsent(entry);
} else if (block.isMessageDeleteType()) {
status = SignedMessagesDAO.getInstance().applyDeleteMessage(entry);
} else if (block.isConversationDeleteType()) {
status = SignedMessagesDAO.getInstance().applyDeleteConversation(entry);
} else {
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
}
return new ApplyResult(entry.getMessageKey(), entry.getBaseKey(), entry.getMessageType(), status);
}
public record ApplyResult(
String messageKey,
String baseKey,
int messageType,
SignedMessagesDAO.ApplyStatus status
) {}
}
@@ -0,0 +1,114 @@
package server.logic.ws_protocol.JSON.messages;
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_DmSyncBatch_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.SignedMessageEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
public class Net_DmSyncBatch_Handler implements JsonMessageHandler {
private static final int DEFAULT_LIMIT = 500;
private static final int MAX_LIMIT = 500;
private static final int DEFAULT_MAX_BYTES = 3_000_000;
private static final int MAX_BYTES_CAP = 5_000_000;
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_DmSyncBatch_Request req = (Net_DmSyncBatch_Request) baseRequest;
String ownerLogin = normalizeOriginal(req.getOwnerLogin());
if (ownerLogin == null) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_OWNER_LOGIN", "ownerLogin обязателен");
}
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
if (ownServerLogin == null) {
return NetExceptionResponseFactory.error(req, 500, "LOCAL_SERVER_NOT_CONFIGURED", "server.SHiNE.login не настроен");
}
if (!isLocalAccessServer(ownerLogin, ownServerLogin)) {
return NetExceptionResponseFactory.error(req, 403, "LOCAL_SERVER_NOT_ACCESS_SERVER", "Локальный сервер не является access-сервером пользователя");
}
int limit = clamp(req.getLimit() == null ? DEFAULT_LIMIT : req.getLimit(), 1, MAX_LIMIT);
int maxBytes = clamp(req.getMaxBytes() == null ? DEFAULT_MAX_BYTES : req.getMaxBytes(), 64_000, MAX_BYTES_CAP);
long afterStoredAtMs = Math.max(0L, req.getAfterStoredAtMs() == null ? 0L : req.getAfterStoredAtMs());
String afterMessageKey = req.getAfterMessageKey() == null ? "" : req.getAfterMessageKey().trim();
SignedMessagesDAO.SyncBatch batch = SignedMessagesDAO.getInstance().listSyncBatch(
ownerLogin,
afterStoredAtMs,
afterMessageKey,
limit,
maxBytes
);
Net_DmSyncBatch_Response resp = new Net_DmSyncBatch_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setOwnerLogin(ownerLogin);
resp.setLimit(limit);
resp.setRawBytes(batch.rawBytes());
resp.setHasMore(batch.hasMore());
resp.setNextStoredAtMs(afterStoredAtMs);
resp.setNextMessageKey(afterMessageKey);
List<Net_DmSyncBatch_Response.Item> items = new ArrayList<>();
Base64.Encoder encoder = Base64.getEncoder();
for (SignedMessageEntry entry : batch.items()) {
Net_DmSyncBatch_Response.Item item = new Net_DmSyncBatch_Response.Item();
item.setMessageKey(entry.getMessageKey());
item.setBaseKey(entry.getBaseKey());
item.setTargetLogin(entry.getTargetLogin());
item.setFromLogin(entry.getFromLogin());
item.setToLogin(entry.getToLogin());
item.setMessageType(entry.getMessageType());
item.setTimeMs(entry.getTimeMs());
item.setStoredAtMs(entry.getCreatedAtMs());
item.setBlobB64(encoder.encodeToString(entry.getRawBlock()));
items.add(item);
resp.setNextStoredAtMs(entry.getCreatedAtMs());
resp.setNextMessageKey(entry.getMessageKey());
}
resp.setItems(items);
return resp;
}
private boolean isLocalAccessServer(String ownerLogin, String ownServerLogin) throws Exception {
for (UserAccessServerRouteEntry route : UserAccessServersCurrentDAO.getInstance().listByUserLogin(ownerLogin)) {
if (route == null || route.getServerLogin() == null) continue;
if (ownServerLogin.equals(normalize(route.getServerLogin()))) {
return true;
}
}
return false;
}
private static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
private static String normalize(String value) {
if (value == null) return null;
String s = value.trim().toLowerCase();
return s.isEmpty() ? null : s;
}
private static String normalizeOriginal(String value) {
if (value == null) return null;
String s = value.trim();
return s.isEmpty() ? null : s;
}
}
@@ -53,6 +53,11 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
if (status.applied()) {
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
server.sync.DmFederationService.fanOutIncomingToRecipientAccessServers(
incoming.toLogin,
req.getIncomingBlobB64().trim(),
req.getSourceServerLogin()
);
}
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
@@ -0,0 +1,22 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_DmSyncBatch_Request extends Net_Request {
private String ownerLogin;
private Long afterStoredAtMs;
private String afterMessageKey;
private Integer limit;
private Integer maxBytes;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public Long getAfterStoredAtMs() { return afterStoredAtMs; }
public void setAfterStoredAtMs(Long afterStoredAtMs) { this.afterStoredAtMs = afterStoredAtMs; }
public String getAfterMessageKey() { return afterMessageKey; }
public void setAfterMessageKey(String afterMessageKey) { this.afterMessageKey = afterMessageKey; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public Integer getMaxBytes() { return maxBytes; }
public void setMaxBytes(Integer maxBytes) { this.maxBytes = maxBytes; }
}
@@ -0,0 +1,62 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public class Net_DmSyncBatch_Response extends Net_Response {
private String ownerLogin;
private int limit;
private int rawBytes;
private boolean hasMore;
private long nextStoredAtMs;
private String nextMessageKey;
private List<Item> items = new ArrayList<>();
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public int getLimit() { return limit; }
public void setLimit(int limit) { this.limit = limit; }
public int getRawBytes() { return rawBytes; }
public void setRawBytes(int rawBytes) { this.rawBytes = rawBytes; }
public boolean isHasMore() { return hasMore; }
public void setHasMore(boolean hasMore) { this.hasMore = hasMore; }
public long getNextStoredAtMs() { return nextStoredAtMs; }
public void setNextStoredAtMs(long nextStoredAtMs) { this.nextStoredAtMs = nextStoredAtMs; }
public String getNextMessageKey() { return nextMessageKey; }
public void setNextMessageKey(String nextMessageKey) { this.nextMessageKey = nextMessageKey; }
public List<Item> getItems() { return items; }
public void setItems(List<Item> items) { this.items = items; }
public static class Item {
private String messageKey;
private String baseKey;
private String targetLogin;
private String fromLogin;
private String toLogin;
private int messageType;
private long timeMs;
private long storedAtMs;
private String blobB64;
public String getMessageKey() { return messageKey; }
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
public String getBaseKey() { return baseKey; }
public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
public String getTargetLogin() { return targetLogin; }
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
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 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 getStoredAtMs() { return storedAtMs; }
public void setStoredAtMs(long storedAtMs) { this.storedAtMs = storedAtMs; }
public String getBlobB64() { return blobB64; }
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
}
}
@@ -4,7 +4,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_ReceiveIncomingMessage_Request extends Net_Request {
private String incomingBlobB64;
private String sourceServerLogin;
public String getIncomingBlobB64() { return incomingBlobB64; }
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
public String getSourceServerLogin() { return sourceServerLogin; }
public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; }
}
@@ -5,9 +5,12 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_SendMessagePair_Request extends Net_Request {
private String incomingBlobB64;
private String outgoingBlobB64;
private String sourceServerLogin;
public String getIncomingBlobB64() { return incomingBlobB64; }
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
public String getOutgoingBlobB64() { return outgoingBlobB64; }
public void setOutgoingBlobB64(String outgoingBlobB64) { this.outgoingBlobB64 = outgoingBlobB64; }
public String getSourceServerLogin() { return sourceServerLogin; }
public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; }
}
@@ -2,7 +2,8 @@ package server.sync;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
import java.util.LinkedHashMap;
@@ -18,26 +19,48 @@ public final class DmFederationService {
public static void fanOutPair(String fromLogin, String toLogin, String incomingBlobB64, String outgoingBlobB64) {
try {
Map<String, SolanaUserPdaImportService.ParsedServerRoute> senderRoutes =
routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin));
Map<String, SolanaUserPdaImportService.ParsedServerRoute> recipientRoutes =
routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin));
Map<String, UserAccessServerRouteEntry> senderRoutes =
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin));
Map<String, UserAccessServerRouteEntry> recipientRoutes =
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin));
String ownServerLogin = ownServerLogin();
for (SolanaUserPdaImportService.ParsedServerRoute route : senderRoutes.values()) {
for (UserAccessServerRouteEntry route : senderRoutes.values()) {
if (isOwnServer(route, ownServerLogin)) continue;
REMOTE.sendMessagePair(route.serverAddress(), incomingBlobB64, outgoingBlobB64);
REMOTE.sendMessagePair(route.getServerUrl(), incomingBlobB64, outgoingBlobB64, ownServerLogin);
}
for (SolanaUserPdaImportService.ParsedServerRoute route : recipientRoutes.values()) {
for (UserAccessServerRouteEntry route : recipientRoutes.values()) {
if (isOwnServer(route, ownServerLogin)) continue;
if (senderRoutes.containsKey(route.login())) continue;
REMOTE.receiveIncomingMessage(route.serverAddress(), incomingBlobB64);
if (senderRoutes.containsKey(normalize(route.getServerLogin()))) continue;
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin);
}
} catch (Exception e) {
log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e);
}
}
public static void fanOutIncomingToRecipientAccessServers(
String toLogin,
String incomingBlobB64,
String sourceServerLogin
) {
try {
Map<String, UserAccessServerRouteEntry> recipientRoutes =
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin));
String ownServerLogin = ownServerLogin();
String normalizedSource = normalize(sourceServerLogin);
for (UserAccessServerRouteEntry route : recipientRoutes.values()) {
String routeLogin = normalize(route.getServerLogin());
if (routeLogin == null) continue;
if (ownServerLogin != null && ownServerLogin.equalsIgnoreCase(routeLogin)) continue;
if (normalizedSource != null && normalizedSource.equalsIgnoreCase(routeLogin)) continue;
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin);
}
} catch (Exception e) {
log.warn("DM federation incoming relay failed: to={}", toLogin, e);
}
}
public static void fanOutDeleteMessage(String fromLogin, String toLogin, int messageType, String blobB64) {
fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true);
}
@@ -48,17 +71,17 @@ public final class DmFederationService {
private static void fanOutSingleDelete(String fromLogin, String toLogin, int messageType, String blobB64, boolean oneMessageDelete) {
try {
Map<String, SolanaUserPdaImportService.ParsedServerRoute> routes = routesByLogin(
SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin),
SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin)
Map<String, UserAccessServerRouteEntry> routes = routesByLogin(
UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin),
UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin)
);
String ownServerLogin = ownServerLogin();
for (SolanaUserPdaImportService.ParsedServerRoute route : routes.values()) {
for (UserAccessServerRouteEntry route : routes.values()) {
if (isOwnServer(route, ownServerLogin)) continue;
if (oneMessageDelete) {
REMOTE.deleteMessage(route.serverAddress(), blobB64);
REMOTE.deleteMessage(route.getServerUrl(), blobB64);
} else {
REMOTE.deleteConversation(route.serverAddress(), blobB64);
REMOTE.deleteConversation(route.getServerUrl(), blobB64);
}
}
} catch (Exception e) {
@@ -68,15 +91,15 @@ public final class DmFederationService {
}
@SafeVarargs
private static Map<String, SolanaUserPdaImportService.ParsedServerRoute> routesByLogin(
List<SolanaUserPdaImportService.ParsedServerRoute>... routeLists
private static Map<String, UserAccessServerRouteEntry> routesByLogin(
List<UserAccessServerRouteEntry>... routeLists
) {
Map<String, SolanaUserPdaImportService.ParsedServerRoute> out = new LinkedHashMap<>();
for (List<SolanaUserPdaImportService.ParsedServerRoute> routeList : routeLists) {
for (SolanaUserPdaImportService.ParsedServerRoute route : routeList) {
Map<String, UserAccessServerRouteEntry> out = new LinkedHashMap<>();
for (List<UserAccessServerRouteEntry> routeList : routeLists) {
for (UserAccessServerRouteEntry route : routeList) {
if (route == null) continue;
String login = normalize(route.login());
String address = route.serverAddress() == null ? "" : route.serverAddress().trim();
String login = normalize(route.getServerLogin());
String address = route.getServerUrl() == null ? "" : route.getServerUrl().trim();
if (login == null || address.isBlank()) continue;
out.putIfAbsent(login, route);
}
@@ -84,8 +107,11 @@ public final class DmFederationService {
return out;
}
private static boolean isOwnServer(SolanaUserPdaImportService.ParsedServerRoute route, String ownServerLogin) {
return ownServerLogin != null && ownServerLogin.equalsIgnoreCase(route.login());
private static boolean isOwnServer(UserAccessServerRouteEntry route, String ownServerLogin) {
return ownServerLogin != null
&& route != null
&& route.getServerLogin() != null
&& ownServerLogin.equalsIgnoreCase(route.getServerLogin());
}
private static String ownServerLogin() {
@@ -8,6 +8,8 @@ import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
@@ -21,36 +23,83 @@ public final class RemoteDmSyncClient {
.connectTimeout(Duration.ofSeconds(6))
.build();
public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64) throws Exception {
public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64, String sourceServerLogin) throws Exception {
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64);
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
JsonNode response = send(serverAddressRaw, """
{
"op":"ReceiveOutcomingMessage",
"requestId":%s,
"payload":{
"incomingBlobB64":%s,
"outgoingBlobB64":%s
"outgoingBlobB64":%s%s
}
}
""".formatted("%s", incomingJson, outgoingJson));
""".formatted("%s", incomingJson, outgoingJson, sourceServerLoginJson));
ensureOk("ReceiveOutcomingMessage", response);
}
public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64) throws Exception {
public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64, String sourceServerLogin) throws Exception {
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
JsonNode response = send(serverAddressRaw, """
{
"op":"ReceiveIncomingMessage",
"requestId":%s,
"payload":{
"incomingBlobB64":%s
"incomingBlobB64":%s%s
}
}
""".formatted("%s", incomingJson));
""".formatted("%s", incomingJson, sourceServerLoginJson));
ensureOk("ReceiveIncomingMessage", response);
}
public RemoteDmBatch dmSyncBatch(
String serverAddressRaw,
String ownerLogin,
long afterStoredAtMs,
String afterMessageKey,
int limit,
int maxBytes
) throws Exception {
String ownerLoginJson = MAPPER.writeValueAsString(ownerLogin);
String afterMessageKeyJson = MAPPER.writeValueAsString(afterMessageKey == null ? "" : afterMessageKey);
JsonNode response = send(serverAddressRaw, """
{
"op":"DmSyncBatch",
"requestId":%s,
"payload":{
"ownerLogin":%s,
"afterStoredAtMs":%d,
"afterMessageKey":%s,
"limit":%d,
"maxBytes":%d
}
}
""".formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson, limit, maxBytes));
ensureOk("DmSyncBatch", response);
JsonNode payload = response.path("payload");
List<RemoteDmItem> items = new ArrayList<>();
JsonNode arr = payload.path("items");
if (arr.isArray()) {
for (JsonNode item : arr) {
items.add(new RemoteDmItem(
item.path("messageKey").asText(""),
item.path("storedAtMs").asLong(0L),
item.path("blobB64").asText("")
));
}
}
return new RemoteDmBatch(
payload.path("nextStoredAtMs").asLong(afterStoredAtMs),
payload.path("nextMessageKey").asText(afterMessageKey == null ? "" : afterMessageKey),
payload.path("hasMore").asBoolean(false),
items
);
}
public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception {
String blobJson = MAPPER.writeValueAsString(blobB64);
JsonNode response = send(serverAddressRaw, """
@@ -107,6 +156,13 @@ public final class RemoteDmSyncClient {
return MAPPER.readTree(responseJson);
}
private String toOptionalJsonField(String fieldName, String value) throws Exception {
if (value == null || value.isBlank()) {
return "";
}
return ",\n \"" + fieldName + "\":" + MAPPER.writeValueAsString(value.trim());
}
private void ensureOk(String op, JsonNode response) {
int status = response.path("status").asInt(500);
if (status >= 200 && status < 300) return;
@@ -126,6 +182,19 @@ public final class RemoteDmSyncClient {
}
}
public record RemoteDmBatch(
long nextStoredAtMs,
String nextMessageKey,
boolean hasMore,
List<RemoteDmItem> items
) {}
public record RemoteDmItem(
String messageKey,
long storedAtMs,
String blobB64
) {}
private static final class SyncWsListener implements WebSocket.Listener {
private final CompletableFuture<String> responseFuture;
private final CountDownLatch openLatch;
@@ -0,0 +1,185 @@
package server.sync;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.messages.DmSyncApplySupport;
import shine.db.dao.DmSyncPeerStateDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.DmSyncPeerStateEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Периодическая догоняющая синхронизация личной переписки между access-серверами пользователя.
*/
public final class PeriodicDmSyncService {
private static final Logger log = LoggerFactory.getLogger(PeriodicDmSyncService.class);
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient();
private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance();
private static final DmSyncPeerStateDAO STATE_DAO = DmSyncPeerStateDAO.getInstance();
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "periodic-dm-sync");
t.setDaemon(true);
return t;
}
});
private PeriodicDmSyncService() {}
public static void startOrLog() {
if (!isEnabled()) {
log.info("Periodic DM sync disabled by dm.sync.enabled=false");
return;
}
if (!STARTED.compareAndSet(false, true)) {
return;
}
long initialDelaySec = configLong("dm.sync.initialDelaySeconds", 60L, 0L, 3600L);
long periodHours = configLong("dm.sync.periodHours", 6L, 1L, 168L);
EXECUTOR.scheduleWithFixedDelay(
PeriodicDmSyncService::runCycleSafe,
initialDelaySec,
TimeUnit.HOURS.toSeconds(periodHours),
TimeUnit.SECONDS
);
log.info("Periodic DM sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours);
}
private static void runCycleSafe() {
try {
runCycle();
} catch (Exception e) {
log.error("Periodic DM sync failed unexpectedly", e);
}
}
private static void runCycle() throws Exception {
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
if (ownServerLogin == null) {
log.warn("Periodic DM sync skipped: {} is empty", SERVER_LOGIN_CONFIG);
return;
}
List<String> ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin);
Set<String> owners = new LinkedHashSet<>(ownersRaw);
if (owners.isEmpty()) {
log.info("Periodic DM sync skipped: no local access-server users for {}", ownServerLogin);
return;
}
int syncedPeers = 0;
int appliedEvents = 0;
for (String ownerLogin : owners) {
List<UserAccessServerRouteEntry> routes = ACCESS_DAO.listByUserLogin(ownerLogin);
for (UserAccessServerRouteEntry route : routes) {
if (route == null) continue;
String remoteLogin = normalize(route.getServerLogin());
String remoteUrl = route.getServerUrl();
if (remoteLogin == null || remoteUrl == null || remoteUrl.isBlank()) continue;
if (remoteLogin.equals(ownServerLogin)) continue;
try {
appliedEvents += syncOwnerFromRemote(ownerLogin, route);
syncedPeers++;
} catch (Exception e) {
STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e));
log.warn("Periodic DM sync peer failed: owner={} remoteServer={} reason={}",
ownerLogin, route.getServerLogin(), String.valueOf(e));
}
}
}
log.info("Periodic DM sync cycle finished: owners={} syncedPeers={} appliedEvents={}",
owners.size(), syncedPeers, appliedEvents);
}
private static int syncOwnerFromRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception {
int limit = (int) configLong("dm.sync.batchLimit", 500L, 1L, 500L);
int maxBytes = (int) configLong("dm.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L);
int maxPages = (int) configLong("dm.sync.maxPagesPerPeer", 50L, 1L, 500L);
DmSyncPeerStateEntry state = STATE_DAO.getOrCreate(ownerLogin, route.getServerLogin(), route.getServerUrl());
long cursorStoredAtMs = state.getCursorStoredAtMs();
String cursorMessageKey = state.getCursorMessageKey() == null ? "" : state.getCursorMessageKey();
int applied = 0;
boolean bootstrapCompleted = false;
for (int page = 0; page < maxPages; page++) {
RemoteDmSyncClient.RemoteDmBatch batch = REMOTE.dmSyncBatch(
route.getServerUrl(),
ownerLogin,
cursorStoredAtMs,
cursorMessageKey,
limit,
maxBytes
);
for (RemoteDmSyncClient.RemoteDmItem item : batch.items()) {
if (item == null || item.blobB64() == null || item.blobB64().isBlank()) continue;
DmSyncApplySupport.ApplyResult result = DmSyncApplySupport.applySyncedBlob(ownerLogin, item.blobB64());
if (result.status().applied()) {
applied++;
}
}
cursorStoredAtMs = Math.max(cursorStoredAtMs, batch.nextStoredAtMs());
cursorMessageKey = batch.nextMessageKey() == null ? "" : batch.nextMessageKey();
bootstrapCompleted = !batch.hasMore();
STATE_DAO.updateSuccess(
ownerLogin,
route.getServerLogin(),
route.getServerUrl(),
cursorStoredAtMs,
cursorMessageKey,
bootstrapCompleted
);
if (!batch.hasMore() || batch.items().isEmpty()) {
break;
}
}
if (!bootstrapCompleted) {
log.info("Periodic DM sync peer paused by page limit: owner={} remoteServer={} maxPages={}",
ownerLogin, route.getServerLogin(), maxPages);
}
return applied;
}
private static boolean isEnabled() {
String raw = AppConfig.getInstance().getParam("dm.sync.enabled");
return raw == null || raw.isBlank() || Boolean.parseBoolean(raw.trim());
}
private static long configLong(String key, long defaultValue, long min, long max) {
String raw = AppConfig.getInstance().getParam(key);
if (raw == null || raw.isBlank()) return defaultValue;
try {
long parsed = Long.parseLong(raw.trim());
return Math.max(min, Math.min(max, parsed));
} catch (Exception ignored) {
return defaultValue;
}
}
private static String normalize(String value) {
if (value == null) return null;
String s = value.trim().toLowerCase(Locale.ROOT);
return s.isEmpty() ? null : s;
}
}
@@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory;
import server.debug.DebugApiConfigurator;
import server.sync.BlockchainResyncRecoveryOnStartup;
import server.sync.PeriodicBlockchainSyncService;
import server.sync.PeriodicDmSyncService;
import server.sync.SolanaUsersSyncStartupService;
import server.sync.SyncServersBootstrapService;
import utils.config.AppConfig;
@@ -102,6 +103,7 @@ public final class WsServer {
server.start();
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
PeriodicDmSyncService.startOrLog();
server.join();
}
}
@@ -33,6 +33,14 @@ sync.importUserProfileFromPartner.enabled=false
# Если какое-то значение не задано, сервер вернёт пустую строку.
# ------------------------------------------------------------
server.version=${projectVersion}
# Межсерверная догоняющая синхронизация личных сообщений.
dm.sync.enabled=true
dm.sync.initialDelaySeconds=60
dm.sync.periodHours=6
dm.sync.batchLimit=500
dm.sync.batchMaxBytes=3000000
dm.sync.maxPagesPerPeer=50
server.info.url=
server.info.physicalRegion=
server.info.description=
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.2.355
server.version=1.2.339
client.version=1.2.359
server.version=1.2.342
+8 -2
View File
@@ -5,7 +5,7 @@
Сейчас здесь два метода:
- `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных;
- `SearchUsers` — dev/test поиск логинов по префиксу.
- `SearchUsers` — dev/test поиск логинов по префиксу, при необходимости с фильтром только по server PDA.
Регистрация выполняется только через Solana.
@@ -111,6 +111,7 @@
### Назначение
Поиск пользователей по префиксу логина. Операция зарегистрирована в серверном API и используется как вспомогательная dev/test операция.
При необходимости можно включить фильтр только по server PDA.
### Запрос
@@ -119,11 +120,16 @@
"op": "SearchUsers",
"requestId": "search-001",
"payload": {
"prefix": "an"
"prefix": "an",
"isServer": true
}
}
```
- `prefix` — обязательный префикс логина.
- `isServer` — необязательный boolean-флаг; если `true`, сервер вернёт только логины,
у которых в `solana_user_pda_current` стоит `is_server = true`.
### Успешный ответ
```json
+1
View File
@@ -61,6 +61,7 @@
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки |
| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | межсерверная догоняющая синхронизация DM по курсору |
| `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога |
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
+73 -7
View File
@@ -10,7 +10,8 @@
Важно:
- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API;
- для DM v1 нужно использовать только `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`.
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
## 1. `UpsertPushToken`
@@ -139,11 +140,14 @@
"op": "ReceiveIncomingMessage",
"requestId": "dm-in-001",
"payload": {
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK"
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK",
"sourceServerLogin": "server-a"
}
}
```
`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику.
## 5. `DeleteMessage`
Принимает один signed DM-блок `type=5` или `type=6`.
@@ -241,7 +245,69 @@
Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа.
## 8. `AckSessionDelivery`
## 8. `DmSyncBatch`
Межсерверная операция для догоняющей синхронизации истории одного пользователя. В текущей реализации не требует авторизации сервера-источника, но удалённый сервер отдаёт данные только если сам является access-сервером `ownerLogin` по `user_access_servers_current`.
### Запрос
```json
{
"op": "DmSyncBatch",
"requestId": "dm-sync-001",
"payload": {
"ownerLogin": "alice",
"afterStoredAtMs": 1774700000000,
"afterMessageKey": "alice|bob|1774699999000|123456780|2",
"limit": 500,
"maxBytes": 3000000
}
}
```
`afterStoredAtMs` и `afterMessageKey` образуют курсор. Если курсора нет, сервер передаёт `0` и пустую строку. `limit` ограничен максимумом `500`.
### Успешный ответ
```json
{
"op": "DmSyncBatch",
"requestId": "dm-sync-001",
"status": 200,
"ok": true,
"payload": {
"ownerLogin": "alice",
"limit": 500,
"rawBytes": 84512,
"hasMore": true,
"nextStoredAtMs": 1774700100000,
"nextMessageKey": "alice|bob|1774700000123|123456789|1",
"items": [
{
"messageKey": "alice|bob|1774700000123|123456789|1",
"baseKey": "alice|bob|1774700000123|123456789",
"targetLogin": "alice",
"fromLogin": "bob",
"toLogin": "alice",
"messageType": 1,
"timeMs": 1774700000123,
"storedAtMs": 1774700100000,
"blobB64": "BASE64_SIGNED_BLOCK"
}
]
}
}
```
События в `items` идут по `storedAtMs ASC, messageKey ASC`. В пачке могут быть сообщения любых диалогов пользователя, read-receipt и delete/tombstone типов `5/6/7/8`.
Ошибки:
- `400 / EMPTY_OWNER_LOGIN` — не передан `ownerLogin`
- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER` — этот сервер не является access-сервером пользователя
- `500 / LOCAL_SERVER_NOT_CONFIGURED` — не настроен `server.SHiNE.login`
## 9. `AckSessionDelivery`
Требует авторизации. Подтверждает доставку в текущую сессию.
@@ -257,7 +323,7 @@
}
```
## 9. Событие `SignedMessageArrived`
## 10. Событие `SignedMessageArrived`
Сервер присылает его по WebSocket в активные сессии адресата.
@@ -282,15 +348,15 @@
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
## 10. `CallInviteBroadcast`
## 11. `CallInviteBroadcast`
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
## 10. `CallSignalToSession`
## 12. `CallSignalToSession`
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
## 11. Замечания
## 13. Замечания
- все DM-типы `1..8` используют `SHiNE_DM`
- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE
@@ -446,11 +446,14 @@ Request:
"op": "ReceiveIncomingMessage",
"requestId": "req-456",
"payload": {
"incomingBlobB64": "..."
"incomingBlobB64": "...",
"sourceServerLogin": "server-a"
}
}
```
`sourceServerLogin` необязателен и используется как best-effort подсказка, чтобы сервер при дальнейшей пересылке не отправлял то же событие обратно серверу-источнику.
### 10.3. `DeleteMessage`
Назначение:
@@ -559,7 +562,50 @@ UI-следствие для клиента:
Идемпотентность обязательна.
### 11.3. Ошибки доставки
### 11.3. Догоняющая синхронизация истории
Для восстановления пропущенных DM-событий между access-серверами используется отдельная операция:
- `DmSyncBatch`
Сервер-получатель синхронизации запрашивает у другого access-сервера историю одного пользователя по курсору:
- `ownerLogin`;
- `afterStoredAtMs`;
- `afterMessageKey`;
- `limit`, максимум `500`;
- `maxBytes`, ограничение суммарного размера raw-блоков пачки.
Удалённый сервер отдаёт все DM-события, относящиеся к этому пользователю:
- контентные копии и read-receipt по `target_login`;
- tombstone типов `5/6/7/8`, где пользователь участвует как `fromLogin` или `toLogin`.
Порядок пачки:
- `created_at_ms ASC`;
- `message_key ASC`.
Курсор хранится локально для пары:
- пользователь;
- удалённый access-сервер.
При первом добавлении сервера или отсутствии курсора синхронизация стартует с `0` и постепенно подтягивает всю доступную историю пачками.
При применении событий, полученных через `DmSyncBatch`, сервер:
- проверяет формат `SHiNE_DM`;
- проверяет подпись;
- применяет существующие правила ревизий, read-receipt и tombstone;
- не отправляет realtime/push-уведомления клиентам;
- не запускает повторный fan-out, чтобы не создавать циклы.
Плановый sync запускается фоном после старта WebSocket-сервера и повторяется раз в 6 часов.
В текущей реализации межсерверная авторизация для `DmSyncBatch` ещё не включена. Сервер отдаёт пачку только если сам локально является access-сервером `ownerLogin` по актуальной таблице `user_access_servers_current`.
### 11.4. Ошибки доставки
Если часть серверов временно недоступна:
@@ -571,7 +617,7 @@ UI-следствие для клиента:
Основная таблица остаётся:
- `signed_messages_v2`
- `signed_messages`
В ней должны сохраняться:
@@ -590,6 +636,12 @@ UI-следствие для клиента:
Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются.
Для догоняющей межсерверной синхронизации дополнительно используются:
- индекс по `target_login`, `created_at_ms`, `message_key`;
- отдельные индексы по delete-событиям для `from_login` и `to_login`;
- таблица `dm_sync_peer_state` с курсором чтения для пары `ownerLogin + remoteServerLogin`.
## 13. Что обязательно должно измениться в коде относительно v0.5
- сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`;
@@ -610,4 +662,4 @@ UI-следствие для клиента:
- хранение отдельного `keyId` шифрования в DM;
- ротация `clientKey`;
- финальная конкретная UI-реализация массовой перешифровки;
- физическая полная реализация DM federation в текущем коде.
- межсерверная авторизация `DmSyncBatch`.
@@ -327,3 +327,5 @@ ReadReceiptBody_v1_0
## 13. Примечание о поддержке
В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`.
Межсерверная операция `DmSyncBatch` не вводит новый байтовый формат DM. Она передаёт уже сохранённые raw-контейнеры `SHiNE_DM` в Base64 вместе с серверными метаданными курсора (`storedAtMs`, `messageKey`), а принимающий сервер заново проверяет подпись и применяет тот же контейнер по его `messageType`.
+2
View File
@@ -56,6 +56,7 @@ import * as profileView from './pages/profile-view.js?v=202607150910';
import * as profileEditView from './pages/profile-edit-view.js';
import * as walletView from './pages/wallet-view.js?v=202606281930';
import * as settingsView from './pages/settings-view.js';
import * as accessServersView from './pages/access-servers-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
@@ -116,6 +117,7 @@ const routes = {
'profile-edit-view': profileEditView,
'wallet-view': walletView,
'settings-view': settingsView,
'access-servers-view': accessServersView,
'developer-settings-view': developerSettingsView,
'server-settings-view': serverSettingsView,
'remote-addblock-session-view': remoteAddBlockSessionView,
+703
View File
@@ -0,0 +1,703 @@
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { base64ToBytes, bytesToBase58, publicKeyB64FromPkcs8Ed25519 } from '../services/crypto-utils.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
import { resolveShineServerByServerLogin } from '../services/shine-server-resolver.js';
import { readShineUserPda, updateShineUserPdaOnSolana } from '../services/shine-user-pda-service.js';
import { getTopupSiteUrl } from '../services/solana-wallet-service.js';
export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' };
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function normalizeLogin(value) {
return String(value || '').trim().toLowerCase();
}
function equalBytes(a, b) {
const left = a instanceof Uint8Array ? a : new Uint8Array(a || []);
const right = b instanceof Uint8Array ? b : new Uint8Array(b || []);
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
if (left[i] !== right[i]) return false;
}
return true;
}
function uniqueLogins(values) {
const seen = new Set();
const result = [];
(Array.isArray(values) ? values : []).forEach((value) => {
const login = normalizeLogin(value);
if (!login || seen.has(login)) return;
seen.add(login);
result.push(login);
});
return result;
}
function shortenSignature(value) {
const text = String(value || '').trim();
if (text.length <= 16) return text;
return `${text.slice(0, 8)}...${text.slice(-8)}`;
}
function isInsufficientFundsForRentError(error) {
const text = [
error?.message,
error?.transactionMessage,
Array.isArray(error?.logs) ? error.logs.join('\n') : '',
Array.isArray(error?.transactionLogs) ? error.transactionLogs.join('\n') : '',
Array.isArray(error?.simulationLogs) ? error.simulationLogs.join('\n') : '',
].filter(Boolean).join('\n').toLowerCase();
return text.includes('insufficient funds') && text.includes('rent');
}
function clientAddressFromPublicB64(publicKeyB64) {
const bytes = base64ToBytes(publicKeyB64);
if (bytes.length !== 32) throw new Error('client public key должен быть 32 байта');
return bytesToBase58(bytes);
}
async function clientAddressFromPrivatePkcs8(privatePkcs8B64) {
return clientAddressFromPublicB64(await publicKeyB64FromPkcs8Ed25519(privatePkcs8B64));
}
function createConfirmModal() {
const root = document.getElementById('modal-root');
if (!(root instanceof HTMLElement)) return null;
let onConfirm = null;
let onCancel = null;
const close = () => {
root.innerHTML = '';
onConfirm = null;
onCancel = null;
};
return {
open({
title,
text,
note = '',
confirmLabel = 'Да',
cancelLabel = 'Нет',
onConfirm: confirmHandler,
onCancel: cancelHandler,
}) {
root.innerHTML = `
<div class="modal" id="access-servers-confirm-modal">
<div class="modal-card stack" style="max-width:min(94vw,34rem);">
<h3 class="modal-title" id="access-servers-dialog-title"></h3>
<p class="meta-muted" id="access-servers-dialog-text" style="white-space:pre-wrap; line-height:1.45;"></p>
<p class="meta-muted" id="access-servers-dialog-note"${note ? '' : ' hidden'} style="white-space:pre-wrap; line-height:1.45;"></p>
<div class="auth-footer-actions">
<button class="ghost-btn" type="button" id="access-servers-dialog-cancel"></button>
<button class="primary-btn" type="button" id="access-servers-dialog-confirm"></button>
</div>
</div>
</div>
`;
const modal = root.querySelector('#access-servers-confirm-modal');
const titleEl = root.querySelector('#access-servers-dialog-title');
const textEl = root.querySelector('#access-servers-dialog-text');
const noteEl = root.querySelector('#access-servers-dialog-note');
const cancelBtn = root.querySelector('#access-servers-dialog-cancel');
const confirmBtn = root.querySelector('#access-servers-dialog-confirm');
if (!(modal instanceof HTMLElement)
|| !(titleEl instanceof HTMLElement)
|| !(textEl instanceof HTMLElement)
|| !(noteEl instanceof HTMLElement)
|| !(cancelBtn instanceof HTMLButtonElement)
|| !(confirmBtn instanceof HTMLButtonElement)) {
close();
return;
}
titleEl.textContent = String(title || 'Подтверждение');
textEl.textContent = String(text || '');
if (note) {
noteEl.hidden = false;
noteEl.textContent = String(note);
} else {
noteEl.hidden = true;
noteEl.textContent = '';
}
cancelBtn.textContent = String(cancelLabel || 'Нет');
confirmBtn.textContent = String(confirmLabel || 'Да');
onConfirm = confirmHandler || null;
onCancel = cancelHandler || null;
modal.addEventListener('click', (event) => {
if (event.target === modal) close();
});
cancelBtn.addEventListener('click', async () => {
const handler = onCancel;
close();
if (typeof handler === 'function') await handler();
});
confirmBtn.addEventListener('click', async () => {
const handler = onConfirm;
close();
if (typeof handler === 'function') await handler();
});
},
destroy() {
close();
},
};
}
function createPasswordModal() {
const root = document.getElementById('modal-root');
if (!(root instanceof HTMLElement)) return null;
return {
open({ title, text, note = '' }) {
root.innerHTML = `
<div class="modal" id="access-servers-password-modal">
<div class="modal-card stack" style="max-width:min(94vw,36rem);">
<h3 class="modal-title" id="access-servers-password-title"></h3>
<p class="meta-muted" id="access-servers-password-text" style="white-space:pre-wrap; line-height:1.45;"></p>
<p class="meta-muted" id="access-servers-password-note"${note ? '' : ' hidden'} style="white-space:pre-wrap; line-height:1.45;"></p>
<label class="stack" style="gap:0.35rem;">
<span class="field-label">Пароль аккаунта</span>
<input class="input" id="access-servers-password-input" type="password" autocomplete="current-password" placeholder="Введите пароль" />
</label>
<div class="stack" style="gap:0.45rem;">
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="once" checked />
<span>Использовать root key только сейчас</span>
</label>
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="save" />
<span>Сохранить root key на этом устройстве</span>
</label>
</div>
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button>
<button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button>
</div>
</div>
</div>
`;
return new Promise((resolve) => {
const modal = root.querySelector('#access-servers-password-modal');
const titleEl = root.querySelector('#access-servers-password-title');
const textEl = root.querySelector('#access-servers-password-text');
const noteEl = root.querySelector('#access-servers-password-note');
const inputEl = root.querySelector('#access-servers-password-input');
const cancelBtn = root.querySelector('#access-servers-password-cancel');
const confirmBtn = root.querySelector('#access-servers-password-confirm');
if (!(modal instanceof HTMLElement)
|| !(titleEl instanceof HTMLElement)
|| !(textEl instanceof HTMLElement)
|| !(noteEl instanceof HTMLElement)
|| !(inputEl instanceof HTMLInputElement)
|| !(cancelBtn instanceof HTMLButtonElement)
|| !(confirmBtn instanceof HTMLButtonElement)) {
root.innerHTML = '';
resolve(null);
return;
}
titleEl.textContent = String(title || 'Введите пароль');
textEl.textContent = String(text || '');
if (note) {
noteEl.hidden = false;
noteEl.textContent = String(note);
} else {
noteEl.hidden = true;
noteEl.textContent = '';
}
const close = (result = null) => {
root.innerHTML = '';
resolve(result);
};
modal.addEventListener('click', (event) => {
if (event.target === modal) close(null);
});
cancelBtn.addEventListener('click', () => close(null));
inputEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
confirmBtn.click();
}
});
confirmBtn.addEventListener('click', () => {
const password = String(inputEl.value || '');
if (!password.trim()) {
inputEl.focus();
return;
}
const mode = root.querySelector('input[name="access-servers-key-mode"]:checked');
close({
password,
saveRoot: mode instanceof HTMLInputElement && mode.value === 'save',
});
});
window.setTimeout(() => inputEl.focus(), 0);
});
},
destroy() {
root.innerHTML = '';
},
};
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const sessionLogin = normalizeLogin(state.session.login);
const solanaEndpoint = String(state.entrySettings.solanaServer || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
let disposed = false;
let operationBusy = false;
let currentAccessServers = [];
let selectedCandidate = null;
let suggestionsLoading = false;
const confirmModal = createConfirmModal();
const passwordModal = createPasswordModal();
const introCard = document.createElement('div');
introCard.className = 'card stack';
introCard.innerHTML = `
<p class="field-label">Где хранятся личные данные</p>
<p class="meta-muted">
Серверы доступа хранят зашифрованную личную переписку пользователя и участвуют в звонках.
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
а доступ пользователя и приватная переписка хранятся только на этих серверах.
</p>
`;
const listCard = document.createElement('div');
listCard.className = 'card stack';
const listTitle = document.createElement('p');
listTitle.className = 'field-label';
listTitle.textContent = 'Текущий список серверов доступа';
const listHint = document.createElement('p');
listHint.className = 'meta-muted';
listHint.textContent = sessionLogin
? `Пользователь: @${sessionLogin}`
: 'В текущей сессии не найден логин пользователя.';
const listBody = document.createElement('div');
listBody.className = 'stack';
const listStatus = document.createElement('p');
listStatus.className = 'meta-muted';
listStatus.textContent = 'Загрузка данных из PDA...';
listCard.append(listTitle, listHint, listBody, listStatus);
const addCard = document.createElement('div');
addCard.className = 'card stack';
const addTitle = document.createElement('p');
addTitle.className = 'field-label';
addTitle.textContent = 'Добавить сервер доступа';
const addHint = document.createElement('p');
addHint.className = 'meta-muted';
addHint.textContent = 'Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.';
const addInput = document.createElement('input');
addInput.className = 'input';
addInput.type = 'text';
addInput.autocomplete = 'off';
addInput.placeholder = 'Например: shineup';
const suggestEl = document.createElement('div');
suggestEl.className = 'profile-relative-search-suggest';
suggestEl.hidden = true;
const addStatus = document.createElement('p');
addStatus.className = 'meta-muted';
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
const addButton = document.createElement('button');
addButton.className = 'primary-btn';
addButton.type = 'button';
addButton.textContent = 'Добавить сервер';
addButton.disabled = true;
addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton);
const refreshAddButton = () => {
addButton.disabled = operationBusy || (!selectedCandidate && !normalizeLogin(addInput.value));
};
const setOperationBusy = (busy) => {
operationBusy = busy;
addInput.disabled = busy;
refreshAddButton();
listBody.querySelectorAll('button').forEach((button) => {
button.disabled = busy;
});
};
const setSelectedCandidate = (candidate) => {
selectedCandidate = candidate;
refreshAddButton();
if (candidate) {
addInput.value = candidate.login;
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
} else {
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
}
};
const renderServerList = () => {
listBody.innerHTML = '';
if (!currentAccessServers.length) {
const empty = document.createElement('p');
empty.className = 'meta-muted';
empty.textContent = 'Список серверов доступа пока пуст.';
listBody.append(empty);
return;
}
currentAccessServers.forEach((server, index) => {
const button = document.createElement('button');
button.className = 'text-btn';
button.type = 'button';
button.disabled = operationBusy;
button.innerHTML = `
<span style="display:block; text-align:left;">
<strong>@${escapeHtml(server.login)}</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(server.url || 'URL не указан')}</span>
</span>
`;
button.addEventListener('click', () => {
if (operationBusy) return;
const isLast = currentAccessServers.length <= 1;
const performDisable = async () => {
const nextList = currentAccessServers
.map((item) => item.login)
.filter((login) => login !== server.login);
try {
await updateAccessServers(nextList, {
statusTarget: listStatus,
successText: `Сервер доступа @${server.login} отключён.`,
inFlightText: `Обновляем PDA и отключаем сервер @${server.login}...`,
});
} catch {
// Сообщение уже показано в статусе.
}
};
const askFinal = (noteText = '') => {
confirmModal?.open({
title: isLast ? 'Последний сервер доступа' : 'Отключить сервер доступа?',
text: isLast
? 'Это последний сервер доступа пользователя. Если его отключить, личная переписка пользователя на серверах доступа будет удалена.'
: `Хотите изменить запись в блокчейне Solana и отключить сервер доступа @${server.login}?`,
note: noteText,
confirmLabel: isLast ? 'Понимаю' : 'Да',
cancelLabel: isLast ? 'Отмена' : 'Нет',
onConfirm: performDisable,
});
};
if (isLast) {
confirmModal?.open({
title: 'Отключить последний сервер?',
text: `У пользователя остался только один сервер доступа: @${server.login}.`,
note: 'После отключения последнего сервера доступа личная переписка пользователя на серверах доступа будет удалена.',
confirmLabel: 'Продолжить',
cancelLabel: 'Отмена',
onConfirm: () => askFinal('Это повторное предупреждение перед записью нового списка серверов в Solana PDA.'),
});
return;
}
askFinal();
});
listBody.append(button);
if (index < currentAccessServers.length - 1) {
const divider = document.createElement('div');
divider.style.height = '1px';
divider.style.background = 'rgba(255,255,255,0.08)';
listBody.append(divider);
}
});
};
const loadCurrentServers = async () => {
if (!sessionLogin) {
currentAccessServers = [];
renderServerList();
listStatus.textContent = 'Нет активной пользовательской сессии.';
return;
}
if (!solanaEndpoint) {
currentAccessServers = [];
renderServerList();
listStatus.textContent = 'Не задан Solana RPC endpoint.';
return;
}
listStatus.textContent = 'Читаем серверы доступа из Solana PDA...';
try {
const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
const logins = uniqueLogins(parsed?.accessServers);
const rows = [];
for (const login of logins) {
try {
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
rows.push({ login: resolved.serverLogin, url: resolved.httpBase });
} catch {
rows.push({ login, url: '' });
}
}
currentAccessServers = rows;
renderServerList();
listStatus.textContent = rows.length
? `Найдено серверов доступа: ${rows.length}`
: 'В PDA пользователя пока нет серверов доступа.';
} catch (error) {
currentAccessServers = [];
renderServerList();
listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.';
}
};
const renderSuggestions = (items) => {
suggestEl.innerHTML = '';
if (!items.length) {
suggestEl.hidden = true;
return;
}
suggestEl.hidden = false;
suggestEl.innerHTML = items.map((item) => (
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
@${escapeHtml(item.login)}
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(item.url || 'URL не указан')}</span>
</button>`
)).join('');
};
const showTopupRequiredStatus = (target, clientAddress) => {
const address = String(clientAddress || '').trim();
const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup';
target.innerHTML = `
<span style="display:block; margin-bottom:0.55rem;">
Не хватает SOL на client key для оплаты Solana rent/fee при обновлении user PDA.
</span>
${address ? `<span style="display:block; overflow-wrap:anywhere; margin-bottom:0.55rem;">Кошелёк: ${escapeHtml(address)}</span>` : ''}
<a class="primary-btn" href="${escapeHtml(topupUrl)}" target="_blank" rel="noopener" style="display:inline-flex; text-decoration:none;">Пополнить DEVNET кошелёк</a>
`;
};
const loadSuggestions = async () => {
const prefix = normalizeLogin(addInput.value);
if (suggestionsLoading || prefix.length < 2 || operationBusy) {
suggestEl.hidden = true;
suggestEl.innerHTML = '';
return;
}
suggestionsLoading = true;
try {
const logins = await authService.searchUsers(prefix, { isServer: true });
const items = [];
for (const login of logins.slice(0, 6)) {
try {
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
items.push({ login: resolved.serverLogin, url: resolved.httpBase });
} catch {
// Если server PDA повреждена, не показываем подсказку.
}
}
if (!disposed) renderSuggestions(items);
} catch (error) {
if (!disposed) {
suggestEl.hidden = true;
suggestEl.innerHTML = '';
addStatus.textContent = error?.message || 'Не удалось получить список серверов.';
}
} finally {
suggestionsLoading = false;
}
};
const resolveAccessServerSigningMaterial = async (currentPda) => {
if (!sessionLogin) throw new Error('Нет активной пользовательской сессии.');
if (!storagePwd) throw new Error('В памяти сессии нет storagePwd. Выполните вход заново.');
let saved = null;
try {
saved = await loadEncryptedUserSecrets(sessionLogin, storagePwd);
} catch {
saved = null;
}
const savedRoot = String(saved?.rootKey || '').trim();
const savedClient = String(saved?.clientKey || '').trim();
if (savedRoot && savedClient) {
return {
rootPrivatePkcs8B64: savedRoot,
clientPrivatePkcs8B64: savedClient,
clientAddress: await clientAddressFromPrivatePkcs8(savedClient),
};
}
const passwordResult = await passwordModal?.open({
title: 'Нужен пароль для обновления серверов доступа',
text: 'Чтобы изменить список серверов доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
note: savedClient
? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.'
: 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.',
});
if (!passwordResult) {
throw new Error('Операция отменена пользователем.');
}
const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password);
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64);
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
throw new Error('Пароль не подходит: root key не совпал с user PDA.');
}
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
throw new Error('Пароль не подходит: client key не совпал с user PDA.');
}
if (passwordResult.saveRoot) {
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
saveRoot: true,
saveBlockchain: false,
});
}
return {
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64),
};
};
const updateAccessServers = async (nextLogins, {
statusTarget,
successText,
inFlightText,
} = {}) => {
const target = statusTarget instanceof HTMLElement ? statusTarget : listStatus;
if (!sessionLogin) throw new Error('Нет активной пользовательской сессии.');
if (!solanaEndpoint) throw new Error('Не задан Solana RPC endpoint.');
if (operationBusy) return;
const normalizedList = uniqueLogins(nextLogins);
setOperationBusy(true);
target.textContent = String(inFlightText || 'Обновляем список серверов доступа...');
let signingMaterial = null;
try {
const currentPda = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
signingMaterial = await resolveAccessServerSigningMaterial(currentPda);
const tx = await updateShineUserPdaOnSolana({
login: sessionLogin,
solanaEndpoint,
rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64,
clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64,
accessServers: normalizedList,
});
await loadCurrentServers();
target.textContent = `${String(successText || 'Список серверов доступа обновлён.')} Tx: ${shortenSignature(tx?.signature)}`;
setSelectedCandidate(null);
suggestEl.hidden = true;
suggestEl.innerHTML = '';
refreshAddButton();
} catch (error) {
if (isInsufficientFundsForRentError(error)) {
showTopupRequiredStatus(target, signingMaterial?.clientAddress);
} else {
target.textContent = error?.message || 'Не удалось обновить список серверов доступа.';
}
throw error;
} finally {
setOperationBusy(false);
}
};
addInput.addEventListener('input', () => {
setSelectedCandidate(null);
refreshAddButton();
void loadSuggestions();
});
addInput.addEventListener('focus', () => {
void loadSuggestions();
});
suggestEl.addEventListener('click', (event) => {
const target = event.target instanceof HTMLElement ? event.target.closest('[data-login]') : null;
if (!(target instanceof HTMLElement)) return;
setSelectedCandidate({
login: String(target.dataset.login || ''),
url: String(target.dataset.url || ''),
});
suggestEl.hidden = true;
suggestEl.innerHTML = '';
});
addButton.addEventListener('click', async () => {
if (operationBusy) return;
const login = normalizeLogin(selectedCandidate?.login || addInput.value);
if (!login) {
setSelectedCandidate(null);
addStatus.textContent = 'Сначала укажите логин сервера доступа.';
return;
}
if (currentAccessServers.some((item) => item.login === login)) {
addStatus.textContent = `Сервер @${login} уже есть в списке.`;
return;
}
try {
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
confirmModal?.open({
title: 'Добавить сервер доступа?',
text: `Вы хотите добавить сервер доступа @${resolved.serverLogin}?`,
note: resolved.httpBase
? `Адрес сервера: ${resolved.httpBase}\nИзменение будет записано в Solana user PDA.`
: 'Изменение будет записано в Solana user PDA.',
onConfirm: async () => {
const nextList = uniqueLogins([...currentAccessServers.map((item) => item.login), resolved.serverLogin]);
try {
await updateAccessServers(nextList, {
statusTarget: addStatus,
successText: `Сервер доступа @${resolved.serverLogin} добавлен.`,
inFlightText: `Обновляем PDA и добавляем сервер @${resolved.serverLogin}...`,
});
} catch {
// Сообщение уже показано в статусе.
}
},
});
} catch (error) {
addStatus.textContent = error?.message || 'Не удалось проверить выбранный сервер.';
}
});
screen.append(
renderHeader({
title: 'Серверы доступа',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
introCard,
listCard,
addCard,
);
void loadCurrentServers();
screen.cleanup = () => {
disposed = true;
confirmModal?.destroy();
passwordModal?.destroy();
};
return screen;
}
+14 -3
View File
@@ -2,7 +2,7 @@ import { renderHeader } from '../components/header.js';
import { saveEntrySettings, state } from '../state.js';
import { checkServerAvailabilityByKey, resolveAndCheckShineServerLogin } from '../services/server-health-service.js';
export const pageMeta = { id: 'server-settings-view', title: 'Настройки серверов' };
export const pageMeta = { id: 'server-settings-view', title: 'Серверы блокчейнов' };
const SERVER_FIELDS = [
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
@@ -26,6 +26,16 @@ export function render({ navigate }) {
const timers = new Map();
const introCard = document.createElement('div');
introCard.className = 'card stack';
introCard.innerHTML = `
<p class="field-label">Серверы блокчейнов и публичных данных</p>
<p class="meta-muted">
Здесь настраиваются Solana, SHiNE и Arweave для чтения публичных данных и доступа к блокчейну.
Эти настройки не меняют список личных серверов доступа пользователя.
</p>
`;
const body = document.createElement('div');
body.className = 'card stack';
@@ -168,7 +178,7 @@ export function render({ navigate }) {
await saveEntrySettings(draft);
navigate('settings-view');
} catch (error) {
window.alert(error?.message || 'Не удалось сохранить настройки серверов.');
window.alert(error?.message || 'Не удалось сохранить настройки серверов блокчейнов.');
}
});
@@ -186,9 +196,10 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Настройки серверов',
title: 'Серверы блокчейнов',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
introCard,
body,
actions,
help,
+14 -2
View File
@@ -42,14 +42,26 @@ export function render({ navigate }) {
card.innerHTML = `
<button class="text-btn" type="button" id="settings-device">Устройства</button>
<button class="text-btn" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
<button class="text-btn" type="button" id="settings-access-servers">
<span style="display:block; text-align:left;">
<strong>Серверы доступа</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Личная переписка, звонки и зашифрованные данные</span>
</span>
</button>
<button class="text-btn" type="button" id="settings-blockchain-servers">
<span style="display:block; text-align:left;">
<strong>Серверы блокчейнов</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
</span>
</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
`;
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
const signOutBtn = card.querySelector('#settings-signout');
+3
View File
@@ -29,6 +29,7 @@ const PRETTY_PATHS = new Map([
['network-view', 'network'],
['notifications-view', 'notifications'],
['settings-view', 'settings'],
['access-servers-view', 'settings/access-servers'],
['server-settings-view', 'settings/servers'],
['developer-settings-view', 'settings/developer'],
['trusted-device-login-settings-view', 'settings/device-login'],
@@ -258,6 +259,7 @@ export function getRoute() {
if (pageId === 'settings') {
const sub = decodePart(segments[1] || '').toLowerCase();
if (sub === 'access-servers') return { pageId: 'access-servers-view', params: {} };
if (sub === 'servers') return { pageId: 'server-settings-view', params: {} };
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} };
@@ -368,6 +370,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'profile-edit-view' ||
pageId === 'wallet-view' ||
pageId === 'settings-view' ||
pageId === 'access-servers-view' ||
pageId === 'developer-settings-view' ||
pageId === 'server-settings-view' ||
pageId === 'remote-addblock-session-view' ||
+6 -2
View File
@@ -2603,8 +2603,12 @@ export class AuthService {
return response.payload || {};
}
async searchUsers(prefix) {
const response = await this.ws.request('SearchUsers', { prefix });
async searchUsers(prefix, options = {}) {
const payload = { prefix };
if (typeof options?.isServer === 'boolean') {
payload.isServer = options.isServer;
}
const response = await this.ws.request('SearchUsers', payload);
if (response.status !== 200) throw opError('SearchUsers', response);
return response.payload?.logins || [];
}