SHA256
Compare commits
6
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
143adcbbe4 | ||
|
|
ca8b6a33ba | ||
|
|
2f73cdb0f9 | ||
|
|
d1e06d9ef1 | ||
|
|
0ed78e2202 | ||
|
|
64818c586b |
@@ -19,8 +19,10 @@ public final class DatabaseInitializer {
|
|||||||
public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version";
|
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_1 = 1;
|
||||||
public static final int SCHEMA_VERSION_2 = 2;
|
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_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||||
|
|
||||||
private DatabaseInitializer() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -82,6 +84,10 @@ public final class DatabaseInitializer {
|
|||||||
int currentVersion = readCurrentSchemaVersion(conn);
|
int currentVersion = readCurrentSchemaVersion(conn);
|
||||||
if (currentVersion < SCHEMA_VERSION_2) {
|
if (currentVersion < SCHEMA_VERSION_2) {
|
||||||
runSqlScript(conn, POSTGRES_MIGRATION_V2_RESOURCE);
|
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 {
|
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix) throws SQLException {
|
||||||
|
return searchByLoginPrefix(c, prefix, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поиск по префиксу с optional-фильтром server PDA. Соединение НЕ закрывает. */
|
||||||
|
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix, Boolean isServer) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
login,
|
login,
|
||||||
@@ -163,6 +168,7 @@ public final class CurrentUsersDAO {
|
|||||||
client_key
|
client_key
|
||||||
FROM %s
|
FROM %s
|
||||||
WHERE LOWER(login) LIKE ?
|
WHERE LOWER(login) LIKE ?
|
||||||
|
AND (? IS NULL OR is_server = ?)
|
||||||
ORDER BY login
|
ORDER BY login
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||||
@@ -171,6 +177,13 @@ public final class CurrentUsersDAO {
|
|||||||
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, prefix.toLowerCase() + "%");
|
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()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) result.add(mapRow(rs));
|
while (rs.next()) result.add(mapRow(rs));
|
||||||
}
|
}
|
||||||
@@ -181,8 +194,13 @@ public final class CurrentUsersDAO {
|
|||||||
|
|
||||||
/** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */
|
/** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */
|
||||||
public List<CurrentUserEntry> searchByLoginPrefix(String prefix) throws SQLException {
|
public List<CurrentUserEntry> searchByLoginPrefix(String prefix) throws SQLException {
|
||||||
|
return searchByLoginPrefix(prefix, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поиск по префиксу без внешнего соединения с optional-фильтром server PDA. */
|
||||||
|
public List<CurrentUserEntry> searchByLoginPrefix(String prefix, Boolean isServer) throws SQLException {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
return searchByLoginPrefix(c, prefix);
|
return searchByLoginPrefix(c, prefix, isServer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
private void upsertMessage(Connection c, SignedMessageEntry e) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO %s (
|
INSERT INTO %s (
|
||||||
@@ -748,6 +814,8 @@ public final class SignedMessagesDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record SyncBatch(List<SignedMessageEntry> items, boolean hasMore, int rawBytes) {}
|
||||||
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
private interface SqlWork<T> {
|
private interface SqlWork<T> {
|
||||||
T run() throws Exception;
|
T run() throws Exception;
|
||||||
|
|||||||
+86
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
public class DmSyncPeerStateEntry {
|
||||||
|
private String ownerLogin;
|
||||||
|
private String remoteServerLogin;
|
||||||
|
private String remoteServerUrl;
|
||||||
|
private long cursorStoredAtMs;
|
||||||
|
private String cursorMessageKey;
|
||||||
|
private boolean bootstrapCompleted;
|
||||||
|
private Long lastSyncAtMs;
|
||||||
|
private String lastError;
|
||||||
|
private long updatedAtMs;
|
||||||
|
|
||||||
|
public String getOwnerLogin() { return ownerLogin; }
|
||||||
|
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||||
|
public String getRemoteServerLogin() { return remoteServerLogin; }
|
||||||
|
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
|
||||||
|
public String getRemoteServerUrl() { return remoteServerUrl; }
|
||||||
|
public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; }
|
||||||
|
public long getCursorStoredAtMs() { return cursorStoredAtMs; }
|
||||||
|
public void setCursorStoredAtMs(long cursorStoredAtMs) { this.cursorStoredAtMs = cursorStoredAtMs; }
|
||||||
|
public String getCursorMessageKey() { return cursorMessageKey; }
|
||||||
|
public void setCursorMessageKey(String cursorMessageKey) { this.cursorMessageKey = cursorMessageKey; }
|
||||||
|
public boolean isBootstrapCompleted() { return bootstrapCompleted; }
|
||||||
|
public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; }
|
||||||
|
public Long getLastSyncAtMs() { return lastSyncAtMs; }
|
||||||
|
public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; }
|
||||||
|
public String getLastError() { return lastError; }
|
||||||
|
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||||
|
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||||
|
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локальная routing-проекция access server пользователя.
|
||||||
|
*
|
||||||
|
* Источник:
|
||||||
|
* - user_access_servers_current
|
||||||
|
*/
|
||||||
|
public class UserAccessServerRouteEntry {
|
||||||
|
|
||||||
|
private String userLogin;
|
||||||
|
private String serverLogin;
|
||||||
|
private String serverUrl;
|
||||||
|
private String serverClientKey;
|
||||||
|
|
||||||
|
public String getUserLogin() { return userLogin; }
|
||||||
|
public void setUserLogin(String userLogin) { this.userLogin = userLogin; }
|
||||||
|
|
||||||
|
public String getServerLogin() { return serverLogin; }
|
||||||
|
public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; }
|
||||||
|
|
||||||
|
public String getServerUrl() { return serverUrl; }
|
||||||
|
public void setServerUrl(String serverUrl) { this.serverUrl = serverUrl; }
|
||||||
|
|
||||||
|
public String getServerClientKey() { return serverClientKey; }
|
||||||
|
public void setServerClientKey(String serverClientKey) { this.serverClientKey = serverClientKey; }
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ public final class CurrentUsersSql {
|
|||||||
current_users.blockchain_name AS blockchain_name,
|
current_users.blockchain_name AS blockchain_name,
|
||||||
current_users.client_key AS solana_key,
|
current_users.client_key AS solana_key,
|
||||||
current_users.blockchain_key AS blockchain_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
|
FROM solana_user_pda_current current_users
|
||||||
) %s
|
) %s
|
||||||
""".formatted(alias);
|
""".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)
|
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
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
schema_version = EXCLUDED.schema_version,
|
schema_version = EXCLUDED.schema_version,
|
||||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
@@ -652,6 +652,28 @@ CREATE INDEX IF NOT EXISTS idx_signed_messages_target
|
|||||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_base
|
CREATE INDEX IF NOT EXISTS idx_signed_messages_base
|
||||||
ON signed_messages(base_key, message_type);
|
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
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_receipt_incoming
|
||||||
ON signed_messages(target_login, receipt_ref_base_key)
|
ON signed_messages(target_login, receipt_ref_base_key)
|
||||||
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
|
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
|
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
||||||
ON signed_message_session_delivery(session_id, delivered);
|
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 (
|
CREATE TABLE IF NOT EXISTS message_views_state (
|
||||||
viewer_login TEXT NOT NULL,
|
viewer_login TEXT NOT NULL,
|
||||||
to_bch_name TEXT NOT NULL,
|
to_bch_name TEXT NOT NULL,
|
||||||
|
|||||||
+4
@@ -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_CallSignalToSession_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_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_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_GetDirectMessages_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_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_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_CallSignalToSession_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_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_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_GetDirectMessages_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_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_ReceiveIncomingMessage_Request;
|
||||||
@@ -198,6 +200,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
||||||
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
||||||
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
||||||
|
Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()),
|
||||||
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
|
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
|
||||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_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("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
||||||
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
||||||
Map.entry("DeleteConversation", Net_DeleteConversation_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("GetDirectMessages", Net_GetDirectMessages_Request.class),
|
||||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||||
|
|||||||
+3
-2
@@ -35,10 +35,11 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String prefix = req.getPrefix().trim();
|
String prefix = req.getPrefix().trim();
|
||||||
|
Boolean isServer = req.getIsServer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
CurrentUsersDAO dao = CurrentUsersDAO.getInstance();
|
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<>();
|
List<String> logins = new ArrayList<>();
|
||||||
for (CurrentUserEntry u : users) {
|
for (CurrentUserEntry u : users) {
|
||||||
@@ -53,7 +54,7 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler {
|
|||||||
resp.setStatus(WireCodes.Status.OK);
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
resp.setLogins(logins);
|
resp.setLogins(logins);
|
||||||
|
|
||||||
log.info("✅ SearchUsers ok: prefix='{}' -> {}", prefix, logins.size());
|
log.info("✅ SearchUsers ok: prefix='{}', isServer={} -> {}", prefix, isServer, logins.size());
|
||||||
return resp;
|
return resp;
|
||||||
|
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
|
|||||||
+3
@@ -18,7 +18,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
|||||||
public class Net_SearchUsers_Request extends Net_Request {
|
public class Net_SearchUsers_Request extends Net_Request {
|
||||||
|
|
||||||
private String prefix;
|
private String prefix;
|
||||||
|
private Boolean isServer;
|
||||||
|
|
||||||
public String getPrefix() { return prefix; }
|
public String getPrefix() { return prefix; }
|
||||||
public void setPrefix(String prefix) { this.prefix = prefix; }
|
public void setPrefix(String prefix) { this.prefix = prefix; }
|
||||||
|
public Boolean getIsServer() { return isServer; }
|
||||||
|
public void setIsServer(Boolean isServer) { this.isServer = isServer; }
|
||||||
}
|
}
|
||||||
+50
@@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+114
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -53,6 +53,11 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
|||||||
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (status.applied()) {
|
if (status.applied()) {
|
||||||
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
||||||
|
server.sync.DmFederationService.fanOutIncomingToRecipientAccessServers(
|
||||||
|
incoming.toLogin,
|
||||||
|
req.getIncomingBlobB64().trim(),
|
||||||
|
req.getSourceServerLogin()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||||
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
|
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
|
||||||
|
|||||||
+22
@@ -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; }
|
||||||
|
}
|
||||||
+62
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -4,7 +4,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
|||||||
|
|
||||||
public class Net_ReceiveIncomingMessage_Request extends Net_Request {
|
public class Net_ReceiveIncomingMessage_Request extends Net_Request {
|
||||||
private String incomingBlobB64;
|
private String incomingBlobB64;
|
||||||
|
private String sourceServerLogin;
|
||||||
|
|
||||||
public String getIncomingBlobB64() { return incomingBlobB64; }
|
public String getIncomingBlobB64() { return incomingBlobB64; }
|
||||||
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
|
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
|
||||||
|
public String getSourceServerLogin() { return sourceServerLogin; }
|
||||||
|
public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; }
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -5,9 +5,12 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
|||||||
public class Net_SendMessagePair_Request extends Net_Request {
|
public class Net_SendMessagePair_Request extends Net_Request {
|
||||||
private String incomingBlobB64;
|
private String incomingBlobB64;
|
||||||
private String outgoingBlobB64;
|
private String outgoingBlobB64;
|
||||||
|
private String sourceServerLogin;
|
||||||
|
|
||||||
public String getIncomingBlobB64() { return incomingBlobB64; }
|
public String getIncomingBlobB64() { return incomingBlobB64; }
|
||||||
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
|
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
|
||||||
public String getOutgoingBlobB64() { return outgoingBlobB64; }
|
public String getOutgoingBlobB64() { return outgoingBlobB64; }
|
||||||
public void setOutgoingBlobB64(String outgoingBlobB64) { this.outgoingBlobB64 = outgoingBlobB64; }
|
public void setOutgoingBlobB64(String outgoingBlobB64) { this.outgoingBlobB64 = outgoingBlobB64; }
|
||||||
|
public String getSourceServerLogin() { return sourceServerLogin; }
|
||||||
|
public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; }
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-25
@@ -2,7 +2,8 @@ package server.sync;
|
|||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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 utils.config.AppConfig;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -18,26 +19,48 @@ public final class DmFederationService {
|
|||||||
|
|
||||||
public static void fanOutPair(String fromLogin, String toLogin, String incomingBlobB64, String outgoingBlobB64) {
|
public static void fanOutPair(String fromLogin, String toLogin, String incomingBlobB64, String outgoingBlobB64) {
|
||||||
try {
|
try {
|
||||||
Map<String, SolanaUserPdaImportService.ParsedServerRoute> senderRoutes =
|
Map<String, UserAccessServerRouteEntry> senderRoutes =
|
||||||
routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin));
|
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin));
|
||||||
Map<String, SolanaUserPdaImportService.ParsedServerRoute> recipientRoutes =
|
Map<String, UserAccessServerRouteEntry> recipientRoutes =
|
||||||
routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin));
|
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin));
|
||||||
|
|
||||||
String ownServerLogin = ownServerLogin();
|
String ownServerLogin = ownServerLogin();
|
||||||
for (SolanaUserPdaImportService.ParsedServerRoute route : senderRoutes.values()) {
|
for (UserAccessServerRouteEntry route : senderRoutes.values()) {
|
||||||
if (isOwnServer(route, ownServerLogin)) continue;
|
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 (isOwnServer(route, ownServerLogin)) continue;
|
||||||
if (senderRoutes.containsKey(route.login())) continue;
|
if (senderRoutes.containsKey(normalize(route.getServerLogin()))) continue;
|
||||||
REMOTE.receiveIncomingMessage(route.serverAddress(), incomingBlobB64);
|
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, 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) {
|
public static void fanOutDeleteMessage(String fromLogin, String toLogin, int messageType, String blobB64) {
|
||||||
fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true);
|
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) {
|
private static void fanOutSingleDelete(String fromLogin, String toLogin, int messageType, String blobB64, boolean oneMessageDelete) {
|
||||||
try {
|
try {
|
||||||
Map<String, SolanaUserPdaImportService.ParsedServerRoute> routes = routesByLogin(
|
Map<String, UserAccessServerRouteEntry> routes = routesByLogin(
|
||||||
SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin),
|
UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin),
|
||||||
SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin)
|
UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin)
|
||||||
);
|
);
|
||||||
String ownServerLogin = ownServerLogin();
|
String ownServerLogin = ownServerLogin();
|
||||||
for (SolanaUserPdaImportService.ParsedServerRoute route : routes.values()) {
|
for (UserAccessServerRouteEntry route : routes.values()) {
|
||||||
if (isOwnServer(route, ownServerLogin)) continue;
|
if (isOwnServer(route, ownServerLogin)) continue;
|
||||||
if (oneMessageDelete) {
|
if (oneMessageDelete) {
|
||||||
REMOTE.deleteMessage(route.serverAddress(), blobB64);
|
REMOTE.deleteMessage(route.getServerUrl(), blobB64);
|
||||||
} else {
|
} else {
|
||||||
REMOTE.deleteConversation(route.serverAddress(), blobB64);
|
REMOTE.deleteConversation(route.getServerUrl(), blobB64);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -68,15 +91,15 @@ public final class DmFederationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SafeVarargs
|
@SafeVarargs
|
||||||
private static Map<String, SolanaUserPdaImportService.ParsedServerRoute> routesByLogin(
|
private static Map<String, UserAccessServerRouteEntry> routesByLogin(
|
||||||
List<SolanaUserPdaImportService.ParsedServerRoute>... routeLists
|
List<UserAccessServerRouteEntry>... routeLists
|
||||||
) {
|
) {
|
||||||
Map<String, SolanaUserPdaImportService.ParsedServerRoute> out = new LinkedHashMap<>();
|
Map<String, UserAccessServerRouteEntry> out = new LinkedHashMap<>();
|
||||||
for (List<SolanaUserPdaImportService.ParsedServerRoute> routeList : routeLists) {
|
for (List<UserAccessServerRouteEntry> routeList : routeLists) {
|
||||||
for (SolanaUserPdaImportService.ParsedServerRoute route : routeList) {
|
for (UserAccessServerRouteEntry route : routeList) {
|
||||||
if (route == null) continue;
|
if (route == null) continue;
|
||||||
String login = normalize(route.login());
|
String login = normalize(route.getServerLogin());
|
||||||
String address = route.serverAddress() == null ? "" : route.serverAddress().trim();
|
String address = route.getServerUrl() == null ? "" : route.getServerUrl().trim();
|
||||||
if (login == null || address.isBlank()) continue;
|
if (login == null || address.isBlank()) continue;
|
||||||
out.putIfAbsent(login, route);
|
out.putIfAbsent(login, route);
|
||||||
}
|
}
|
||||||
@@ -84,8 +107,11 @@ public final class DmFederationService {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isOwnServer(SolanaUserPdaImportService.ParsedServerRoute route, String ownServerLogin) {
|
private static boolean isOwnServer(UserAccessServerRouteEntry route, String ownServerLogin) {
|
||||||
return ownServerLogin != null && ownServerLogin.equalsIgnoreCase(route.login());
|
return ownServerLogin != null
|
||||||
|
&& route != null
|
||||||
|
&& route.getServerLogin() != null
|
||||||
|
&& ownServerLogin.equalsIgnoreCase(route.getServerLogin());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String ownServerLogin() {
|
private static String ownServerLogin() {
|
||||||
|
|||||||
+75
-6
@@ -8,6 +8,8 @@ import java.net.http.HttpClient;
|
|||||||
import java.net.http.WebSocket;
|
import java.net.http.WebSocket;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.CompletionStage;
|
import java.util.concurrent.CompletionStage;
|
||||||
@@ -21,36 +23,83 @@ public final class RemoteDmSyncClient {
|
|||||||
.connectTimeout(Duration.ofSeconds(6))
|
.connectTimeout(Duration.ofSeconds(6))
|
||||||
.build();
|
.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 incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
||||||
String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64);
|
String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64);
|
||||||
|
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
||||||
JsonNode response = send(serverAddressRaw, """
|
JsonNode response = send(serverAddressRaw, """
|
||||||
{
|
{
|
||||||
"op":"ReceiveOutcomingMessage",
|
"op":"ReceiveOutcomingMessage",
|
||||||
"requestId":%s,
|
"requestId":%s,
|
||||||
"payload":{
|
"payload":{
|
||||||
"incomingBlobB64":%s,
|
"incomingBlobB64":%s,
|
||||||
"outgoingBlobB64":%s
|
"outgoingBlobB64":%s%s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
""".formatted("%s", incomingJson, outgoingJson));
|
""".formatted("%s", incomingJson, outgoingJson, sourceServerLoginJson));
|
||||||
ensureOk("ReceiveOutcomingMessage", response);
|
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 incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
||||||
|
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
||||||
JsonNode response = send(serverAddressRaw, """
|
JsonNode response = send(serverAddressRaw, """
|
||||||
{
|
{
|
||||||
"op":"ReceiveIncomingMessage",
|
"op":"ReceiveIncomingMessage",
|
||||||
"requestId":%s,
|
"requestId":%s,
|
||||||
"payload":{
|
"payload":{
|
||||||
"incomingBlobB64":%s
|
"incomingBlobB64":%s%s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
""".formatted("%s", incomingJson));
|
""".formatted("%s", incomingJson, sourceServerLoginJson));
|
||||||
ensureOk("ReceiveIncomingMessage", response);
|
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 {
|
public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception {
|
||||||
String blobJson = MAPPER.writeValueAsString(blobB64);
|
String blobJson = MAPPER.writeValueAsString(blobB64);
|
||||||
JsonNode response = send(serverAddressRaw, """
|
JsonNode response = send(serverAddressRaw, """
|
||||||
@@ -107,6 +156,13 @@ public final class RemoteDmSyncClient {
|
|||||||
return MAPPER.readTree(responseJson);
|
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) {
|
private void ensureOk(String op, JsonNode response) {
|
||||||
int status = response.path("status").asInt(500);
|
int status = response.path("status").asInt(500);
|
||||||
if (status >= 200 && status < 300) return;
|
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 static final class SyncWsListener implements WebSocket.Listener {
|
||||||
private final CompletableFuture<String> responseFuture;
|
private final CompletableFuture<String> responseFuture;
|
||||||
private final CountDownLatch openLatch;
|
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.debug.DebugApiConfigurator;
|
||||||
import server.sync.BlockchainResyncRecoveryOnStartup;
|
import server.sync.BlockchainResyncRecoveryOnStartup;
|
||||||
import server.sync.PeriodicBlockchainSyncService;
|
import server.sync.PeriodicBlockchainSyncService;
|
||||||
|
import server.sync.PeriodicDmSyncService;
|
||||||
import server.sync.SolanaUsersSyncStartupService;
|
import server.sync.SolanaUsersSyncStartupService;
|
||||||
import server.sync.SyncServersBootstrapService;
|
import server.sync.SyncServersBootstrapService;
|
||||||
import utils.config.AppConfig;
|
import utils.config.AppConfig;
|
||||||
@@ -102,6 +103,7 @@ public final class WsServer {
|
|||||||
|
|
||||||
server.start();
|
server.start();
|
||||||
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
|
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
|
||||||
|
PeriodicDmSyncService.startOrLog();
|
||||||
server.join();
|
server.join();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ sync.importUserProfileFromPartner.enabled=false
|
|||||||
# Если какое-то значение не задано, сервер вернёт пустую строку.
|
# Если какое-то значение не задано, сервер вернёт пустую строку.
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
server.version=${projectVersion}
|
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.url=
|
||||||
server.info.physicalRegion=
|
server.info.physicalRegion=
|
||||||
server.info.description=
|
server.info.description=
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.355
|
client.version=1.2.359
|
||||||
server.version=1.2.339
|
server.version=1.2.342
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
Сейчас здесь два метода:
|
Сейчас здесь два метода:
|
||||||
|
|
||||||
- `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных;
|
- `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных;
|
||||||
- `SearchUsers` — dev/test поиск логинов по префиксу.
|
- `SearchUsers` — dev/test поиск логинов по префиксу, при необходимости с фильтром только по server PDA.
|
||||||
|
|
||||||
Регистрация выполняется только через Solana.
|
Регистрация выполняется только через Solana.
|
||||||
|
|
||||||
@@ -111,6 +111,7 @@
|
|||||||
### Назначение
|
### Назначение
|
||||||
|
|
||||||
Поиск пользователей по префиксу логина. Операция зарегистрирована в серверном API и используется как вспомогательная dev/test операция.
|
Поиск пользователей по префиксу логина. Операция зарегистрирована в серверном API и используется как вспомогательная dev/test операция.
|
||||||
|
При необходимости можно включить фильтр только по server PDA.
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
@@ -119,11 +120,16 @@
|
|||||||
"op": "SearchUsers",
|
"op": "SearchUsers",
|
||||||
"requestId": "search-001",
|
"requestId": "search-001",
|
||||||
"payload": {
|
"payload": {
|
||||||
"prefix": "an"
|
"prefix": "an",
|
||||||
|
"isServer": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `prefix` — обязательный префикс логина.
|
||||||
|
- `isServer` — необязательный boolean-флаг; если `true`, сервер вернёт только логины,
|
||||||
|
у которых в `solana_user_pda_current` стоит `is_server = true`.
|
||||||
|
|
||||||
### Успешный ответ
|
### Успешный ответ
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
|
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
|
||||||
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
||||||
| `DeleteConversation` | `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` | постраничная загрузка истории личного диалога |
|
| `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога |
|
||||||
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
||||||
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
Важно:
|
Важно:
|
||||||
|
|
||||||
- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API;
|
- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API;
|
||||||
- для DM v1 нужно использовать только `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`.
|
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
|
||||||
|
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
|
||||||
|
|
||||||
## 1. `UpsertPushToken`
|
## 1. `UpsertPushToken`
|
||||||
|
|
||||||
@@ -139,11 +140,14 @@
|
|||||||
"op": "ReceiveIncomingMessage",
|
"op": "ReceiveIncomingMessage",
|
||||||
"requestId": "dm-in-001",
|
"requestId": "dm-in-001",
|
||||||
"payload": {
|
"payload": {
|
||||||
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK"
|
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK",
|
||||||
|
"sourceServerLogin": "server-a"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику.
|
||||||
|
|
||||||
## 5. `DeleteMessage`
|
## 5. `DeleteMessage`
|
||||||
|
|
||||||
Принимает один signed DM-блок `type=5` или `type=6`.
|
Принимает один signed DM-блок `type=5` или `type=6`.
|
||||||
@@ -241,7 +245,69 @@
|
|||||||
|
|
||||||
Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа.
|
Для следующей страницы клиент должен передать `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 в активные сессии адресата.
|
Сервер присылает его по WebSocket в активные сессии адресата.
|
||||||
|
|
||||||
@@ -282,15 +348,15 @@
|
|||||||
|
|
||||||
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
|
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
|
||||||
|
|
||||||
## 10. `CallInviteBroadcast`
|
## 11. `CallInviteBroadcast`
|
||||||
|
|
||||||
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
|
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
|
||||||
|
|
||||||
## 10. `CallSignalToSession`
|
## 12. `CallSignalToSession`
|
||||||
|
|
||||||
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
|
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
|
||||||
|
|
||||||
## 11. Замечания
|
## 13. Замечания
|
||||||
|
|
||||||
- все DM-типы `1..8` используют `SHiNE_DM`
|
- все DM-типы `1..8` используют `SHiNE_DM`
|
||||||
- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE
|
- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE
|
||||||
|
|||||||
@@ -446,11 +446,14 @@ Request:
|
|||||||
"op": "ReceiveIncomingMessage",
|
"op": "ReceiveIncomingMessage",
|
||||||
"requestId": "req-456",
|
"requestId": "req-456",
|
||||||
"payload": {
|
"payload": {
|
||||||
"incomingBlobB64": "..."
|
"incomingBlobB64": "...",
|
||||||
|
"sourceServerLogin": "server-a"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`sourceServerLogin` необязателен и используется как best-effort подсказка, чтобы сервер при дальнейшей пересылке не отправлял то же событие обратно серверу-источнику.
|
||||||
|
|
||||||
### 10.3. `DeleteMessage`
|
### 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
|
## 13. Что обязательно должно измениться в коде относительно v0.5
|
||||||
|
|
||||||
- сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`;
|
- сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`;
|
||||||
@@ -610,4 +662,4 @@ UI-следствие для клиента:
|
|||||||
- хранение отдельного `keyId` шифрования в DM;
|
- хранение отдельного `keyId` шифрования в DM;
|
||||||
- ротация `clientKey`;
|
- ротация `clientKey`;
|
||||||
- финальная конкретная UI-реализация массовой перешифровки;
|
- финальная конкретная UI-реализация массовой перешифровки;
|
||||||
- физическая полная реализация DM federation в текущем коде.
|
- межсерверная авторизация `DmSyncBatch`.
|
||||||
|
|||||||
@@ -327,3 +327,5 @@ ReadReceiptBody_v1_0
|
|||||||
## 13. Примечание о поддержке
|
## 13. Примечание о поддержке
|
||||||
|
|
||||||
В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`.
|
В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`.
|
||||||
|
|
||||||
|
Межсерверная операция `DmSyncBatch` не вводит новый байтовый формат DM. Она передаёт уже сохранённые raw-контейнеры `SHiNE_DM` в Base64 вместе с серверными метаданными курсора (`storedAtMs`, `messageKey`), а принимающий сервер заново проверяет подпись и применяет тот же контейнер по его `messageType`.
|
||||||
|
|||||||
@@ -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 profileEditView from './pages/profile-edit-view.js';
|
||||||
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||||
import * as settingsView from './pages/settings-view.js';
|
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 developerSettingsView from './pages/developer-settings-view.js';
|
||||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
||||||
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
|
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
|
||||||
@@ -116,6 +117,7 @@ const routes = {
|
|||||||
'profile-edit-view': profileEditView,
|
'profile-edit-view': profileEditView,
|
||||||
'wallet-view': walletView,
|
'wallet-view': walletView,
|
||||||
'settings-view': settingsView,
|
'settings-view': settingsView,
|
||||||
|
'access-servers-view': accessServersView,
|
||||||
'developer-settings-view': developerSettingsView,
|
'developer-settings-view': developerSettingsView,
|
||||||
'server-settings-view': serverSettingsView,
|
'server-settings-view': serverSettingsView,
|
||||||
'remote-addblock-session-view': remoteAddBlockSessionView,
|
'remote-addblock-session-view': remoteAddBlockSessionView,
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { renderHeader } from '../components/header.js';
|
|||||||
import { saveEntrySettings, state } from '../state.js';
|
import { saveEntrySettings, state } from '../state.js';
|
||||||
import { checkServerAvailabilityByKey, resolveAndCheckShineServerLogin } from '../services/server-health-service.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 = [
|
const SERVER_FIELDS = [
|
||||||
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
|
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
|
||||||
@@ -26,6 +26,16 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const timers = new Map();
|
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');
|
const body = document.createElement('div');
|
||||||
body.className = 'card stack';
|
body.className = 'card stack';
|
||||||
|
|
||||||
@@ -168,7 +178,7 @@ export function render({ navigate }) {
|
|||||||
await saveEntrySettings(draft);
|
await saveEntrySettings(draft);
|
||||||
navigate('settings-view');
|
navigate('settings-view');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(error?.message || 'Не удалось сохранить настройки серверов.');
|
window.alert(error?.message || 'Не удалось сохранить настройки серверов блокчейнов.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,9 +196,10 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Настройки серверов',
|
title: 'Серверы блокчейнов',
|
||||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||||
}),
|
}),
|
||||||
|
introCard,
|
||||||
body,
|
body,
|
||||||
actions,
|
actions,
|
||||||
help,
|
help,
|
||||||
|
|||||||
@@ -42,14 +42,26 @@ export function render({ navigate }) {
|
|||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<button class="text-btn" type="button" id="settings-device">Устройства</button>
|
<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-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-language">Язык / Language</button>
|
||||||
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
||||||
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-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'));
|
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
|
||||||
|
|
||||||
const signOutBtn = card.querySelector('#settings-signout');
|
const signOutBtn = card.querySelector('#settings-signout');
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const PRETTY_PATHS = new Map([
|
|||||||
['network-view', 'network'],
|
['network-view', 'network'],
|
||||||
['notifications-view', 'notifications'],
|
['notifications-view', 'notifications'],
|
||||||
['settings-view', 'settings'],
|
['settings-view', 'settings'],
|
||||||
|
['access-servers-view', 'settings/access-servers'],
|
||||||
['server-settings-view', 'settings/servers'],
|
['server-settings-view', 'settings/servers'],
|
||||||
['developer-settings-view', 'settings/developer'],
|
['developer-settings-view', 'settings/developer'],
|
||||||
['trusted-device-login-settings-view', 'settings/device-login'],
|
['trusted-device-login-settings-view', 'settings/device-login'],
|
||||||
@@ -258,6 +259,7 @@ export function getRoute() {
|
|||||||
|
|
||||||
if (pageId === 'settings') {
|
if (pageId === 'settings') {
|
||||||
const sub = decodePart(segments[1] || '').toLowerCase();
|
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 === 'servers') return { pageId: 'server-settings-view', params: {} };
|
||||||
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
|
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
|
||||||
if (sub === 'device-login') return { pageId: 'trusted-device-login-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 === 'profile-edit-view' ||
|
||||||
pageId === 'wallet-view' ||
|
pageId === 'wallet-view' ||
|
||||||
pageId === 'settings-view' ||
|
pageId === 'settings-view' ||
|
||||||
|
pageId === 'access-servers-view' ||
|
||||||
pageId === 'developer-settings-view' ||
|
pageId === 'developer-settings-view' ||
|
||||||
pageId === 'server-settings-view' ||
|
pageId === 'server-settings-view' ||
|
||||||
pageId === 'remote-addblock-session-view' ||
|
pageId === 'remote-addblock-session-view' ||
|
||||||
|
|||||||
@@ -2603,8 +2603,12 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchUsers(prefix) {
|
async searchUsers(prefix, options = {}) {
|
||||||
const response = await this.ws.request('SearchUsers', { prefix });
|
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);
|
if (response.status !== 200) throw opError('SearchUsers', response);
|
||||||
return response.payload?.logins || [];
|
return response.payload?.logins || [];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user