diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 85800e61..5d00f285 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -19,8 +19,10 @@ public final class DatabaseInitializer { public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version"; public static final int SCHEMA_VERSION_1 = 1; public static final int SCHEMA_VERSION_2 = 2; + public static final int SCHEMA_VERSION_3 = 3; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; + public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; private DatabaseInitializer() {} @@ -82,6 +84,10 @@ public final class DatabaseInitializer { int currentVersion = readCurrentSchemaVersion(conn); if (currentVersion < SCHEMA_VERSION_2) { runSqlScript(conn, POSTGRES_MIGRATION_V2_RESOURCE); + currentVersion = SCHEMA_VERSION_2; + } + if (currentVersion < SCHEMA_VERSION_3) { + runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE); } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmSyncPeerStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmSyncPeerStateDAO.java new file mode 100644 index 00000000..b5fe6410 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmSyncPeerStateDAO.java @@ -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); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesDAO.java index d065e818..e8d11c6f 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesDAO.java @@ -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 out = new ArrayList<>(); + boolean hasMore = false; + int usedBytes = 0; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerLogin); + ps.setString(2, ownerLogin); + ps.setString(3, ownerLogin); + ps.setLong(4, Math.max(0L, afterStoredAtMs)); + ps.setLong(5, Math.max(0L, afterStoredAtMs)); + ps.setString(6, safeAfterMessageKey); + ps.setString(7, safeAfterMessageKey); + ps.setInt(8, sqlLimit); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + SignedMessageEntry row = mapRow(rs); + byte[] raw = row.getRawBlock(); + int rowBytes = raw == null ? 0 : raw.length; + if (out.size() >= safeLimit) { + hasMore = true; + break; + } + if (!out.isEmpty() && maxBytes > 0 && usedBytes + rowBytes > maxBytes) { + hasMore = true; + break; + } + out.add(row); + usedBytes += rowBytes; + } + } + } + return new SyncBatch(out, hasMore, usedBytes); + } + } + private void upsertMessage(Connection c, SignedMessageEntry e) throws SQLException { String sql = """ INSERT INTO %s ( @@ -748,6 +814,8 @@ public final class SignedMessagesDAO { } } + public record SyncBatch(List items, boolean hasMore, int rawBytes) {} + @FunctionalInterface private interface SqlWork { T run() throws Exception; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java index 042de2a5..e95a6df4 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java @@ -55,6 +55,26 @@ public final class UserAccessServersCurrentDAO { return result; } + public List 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 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")); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmSyncPeerStateEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmSyncPeerStateEntry.java new file mode 100644 index 00000000..7730e349 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmSyncPeerStateEntry.java @@ -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; } +} diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v3.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v3.sql new file mode 100644 index 00000000..97f55e62 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v3.sql @@ -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; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index ff882997..364d5d4c 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version ( ); INSERT INTO db_schema_version (id, schema_version, updated_at_ms) -VALUES (1, 2, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) ON CONFLICT (id) DO UPDATE SET schema_version = EXCLUDED.schema_version, updated_at_ms = EXCLUDED.updated_at_ms; @@ -652,6 +652,28 @@ CREATE INDEX IF NOT EXISTS idx_signed_messages_target CREATE INDEX IF NOT EXISTS idx_signed_messages_base ON signed_messages(base_key, message_type); +CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor + ON signed_messages(target_login, created_at_ms, message_key); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower + ON signed_messages(LOWER(target_login), created_at_ms, message_key); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor + ON signed_messages(from_login, created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor + ON signed_messages(to_login, created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower + ON signed_messages(LOWER(from_login), created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower + ON signed_messages(LOWER(to_login), created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_receipt_incoming ON signed_messages(target_login, receipt_ref_base_key) WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL; @@ -672,6 +694,22 @@ CREATE TABLE IF NOT EXISTS signed_message_session_delivery ( CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session ON signed_message_session_delivery(session_id, delivered); +CREATE TABLE IF NOT EXISTS dm_sync_peer_state ( + owner_login TEXT NOT NULL, + remote_server_login TEXT NOT NULL, + remote_server_url TEXT NOT NULL, + cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0, + cursor_message_key TEXT NOT NULL DEFAULT '', + bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE, + last_sync_at_ms BIGINT, + last_error TEXT, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (owner_login, remote_server_login) +); + +CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner + ON dm_sync_peer_state(owner_login); + CREATE TABLE IF NOT EXISTS message_views_state ( viewer_login TEXT NOT NULL, to_bch_name TEXT NOT NULL, diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 5a7a7cca..787efd20 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -88,6 +88,7 @@ import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler; import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler; import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler; +import server.logic.ws_protocol.JSON.messages.Net_DmSyncBatch_Handler; import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler; import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler; @@ -100,6 +101,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request; @@ -198,6 +200,7 @@ public final class JsonHandlerRegistry { Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()), Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()), Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()), + Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()), Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()), Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()), Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()), @@ -278,6 +281,7 @@ public final class JsonHandlerRegistry { Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class), Map.entry("DeleteMessage", Net_DeleteMessage_Request.class), Map.entry("DeleteConversation", Net_DeleteConversation_Request.class), + Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class), Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class), Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class), Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java new file mode 100644 index 00000000..37a1f0e0 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java @@ -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 + ) {} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DmSyncBatch_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DmSyncBatch_Handler.java new file mode 100644 index 00000000..81192f17 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DmSyncBatch_Handler.java @@ -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 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; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Request.java new file mode 100644 index 00000000..fdaea682 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Request.java @@ -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; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Response.java new file mode 100644 index 00000000..edab6afd --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Response.java @@ -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 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 getItems() { return items; } + public void setItems(List 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; } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java index a302e6fb..cdd7c1df 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java @@ -8,6 +8,8 @@ import java.net.http.HttpClient; import java.net.http.WebSocket; import java.nio.ByteBuffer; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -53,6 +55,51 @@ public final class RemoteDmSyncClient { 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 items = new ArrayList<>(); + JsonNode arr = payload.path("items"); + if (arr.isArray()) { + for (JsonNode item : arr) { + items.add(new RemoteDmItem( + item.path("messageKey").asText(""), + item.path("storedAtMs").asLong(0L), + item.path("blobB64").asText("") + )); + } + } + return new RemoteDmBatch( + payload.path("nextStoredAtMs").asLong(afterStoredAtMs), + payload.path("nextMessageKey").asText(afterMessageKey == null ? "" : afterMessageKey), + payload.path("hasMore").asBoolean(false), + items + ); + } + public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception { String blobJson = MAPPER.writeValueAsString(blobB64); JsonNode response = send(serverAddressRaw, """ @@ -135,6 +182,19 @@ public final class RemoteDmSyncClient { } } + public record RemoteDmBatch( + long nextStoredAtMs, + String nextMessageKey, + boolean hasMore, + List items + ) {} + + public record RemoteDmItem( + String messageKey, + long storedAtMs, + String blobB64 + ) {} + private static final class SyncWsListener implements WebSocket.Listener { private final CompletableFuture responseFuture; private final CountDownLatch openLatch; diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicDmSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicDmSyncService.java new file mode 100644 index 00000000..94b6c626 --- /dev/null +++ b/SHiNE-server/src/main/java/server/sync/PeriodicDmSyncService.java @@ -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 ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin); + Set 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 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; + } +} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 56fc8a5a..42a9aff2 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory; import server.debug.DebugApiConfigurator; import server.sync.BlockchainResyncRecoveryOnStartup; import server.sync.PeriodicBlockchainSyncService; +import server.sync.PeriodicDmSyncService; import server.sync.SolanaUsersSyncStartupService; import server.sync.SyncServersBootstrapService; import utils.config.AppConfig; @@ -102,6 +103,7 @@ public final class WsServer { server.start(); log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); + PeriodicDmSyncService.startOrLog(); server.join(); } } diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 53f74ae4..17aa12c4 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -33,6 +33,14 @@ sync.importUserProfileFromPartner.enabled=false # Если какое-то значение не задано, сервер вернёт пустую строку. # ------------------------------------------------------------ server.version=${projectVersion} + +# Межсерверная догоняющая синхронизация личных сообщений. +dm.sync.enabled=true +dm.sync.initialDelaySeconds=60 +dm.sync.periodHours=6 +dm.sync.batchLimit=500 +dm.sync.batchMaxBytes=3000000 +dm.sync.maxPagesPerPeer=50 server.info.url= server.info.physicalRegion= server.info.description= diff --git a/VERSION.properties b/VERSION.properties index b690bcaa..c1faf6a6 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ client.version=1.2.359 -server.version=1.2.341 +server.version=1.2.342 diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 2ace872f..69a01682 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -61,6 +61,7 @@ | `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока | | `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон | | `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки | +| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | межсерверная догоняющая синхронизация DM по курсору | | `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога | | `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию | | `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку | diff --git a/docs/API/12_Direct_Messages_Push_Calls_API.md b/docs/API/12_Direct_Messages_Push_Calls_API.md index 114b0d03..f576bb2e 100644 --- a/docs/API/12_Direct_Messages_Push_Calls_API.md +++ b/docs/API/12_Direct_Messages_Push_Calls_API.md @@ -10,7 +10,8 @@ Важно: - legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API; -- для DM v1 нужно использовать только `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`. +- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`; +- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI. ## 1. `UpsertPushToken` @@ -139,11 +140,14 @@ "op": "ReceiveIncomingMessage", "requestId": "dm-in-001", "payload": { - "incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK" + "incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK", + "sourceServerLogin": "server-a" } } ``` +`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику. + ## 5. `DeleteMessage` Принимает один signed DM-блок `type=5` или `type=6`. @@ -241,7 +245,69 @@ Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа. -## 8. `AckSessionDelivery` +## 8. `DmSyncBatch` + +Межсерверная операция для догоняющей синхронизации истории одного пользователя. В текущей реализации не требует авторизации сервера-источника, но удалённый сервер отдаёт данные только если сам является access-сервером `ownerLogin` по `user_access_servers_current`. + +### Запрос + +```json +{ + "op": "DmSyncBatch", + "requestId": "dm-sync-001", + "payload": { + "ownerLogin": "alice", + "afterStoredAtMs": 1774700000000, + "afterMessageKey": "alice|bob|1774699999000|123456780|2", + "limit": 500, + "maxBytes": 3000000 + } +} +``` + +`afterStoredAtMs` и `afterMessageKey` образуют курсор. Если курсора нет, сервер передаёт `0` и пустую строку. `limit` ограничен максимумом `500`. + +### Успешный ответ + +```json +{ + "op": "DmSyncBatch", + "requestId": "dm-sync-001", + "status": 200, + "ok": true, + "payload": { + "ownerLogin": "alice", + "limit": 500, + "rawBytes": 84512, + "hasMore": true, + "nextStoredAtMs": 1774700100000, + "nextMessageKey": "alice|bob|1774700000123|123456789|1", + "items": [ + { + "messageKey": "alice|bob|1774700000123|123456789|1", + "baseKey": "alice|bob|1774700000123|123456789", + "targetLogin": "alice", + "fromLogin": "bob", + "toLogin": "alice", + "messageType": 1, + "timeMs": 1774700000123, + "storedAtMs": 1774700100000, + "blobB64": "BASE64_SIGNED_BLOCK" + } + ] + } +} +``` + +События в `items` идут по `storedAtMs ASC, messageKey ASC`. В пачке могут быть сообщения любых диалогов пользователя, read-receipt и delete/tombstone типов `5/6/7/8`. + +Ошибки: + +- `400 / EMPTY_OWNER_LOGIN` — не передан `ownerLogin` +- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER` — этот сервер не является access-сервером пользователя +- `500 / LOCAL_SERVER_NOT_CONFIGURED` — не настроен `server.SHiNE.login` + +## 9. `AckSessionDelivery` Требует авторизации. Подтверждает доставку в текущую сессию. @@ -257,7 +323,7 @@ } ``` -## 9. Событие `SignedMessageArrived` +## 10. Событие `SignedMessageArrived` Сервер присылает его по WebSocket в активные сессии адресата. @@ -282,15 +348,15 @@ Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`. -## 10. `CallInviteBroadcast` +## 11. `CallInviteBroadcast` Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`. -## 10. `CallSignalToSession` +## 12. `CallSignalToSession` Требует авторизации. Шлёт сигнал звонка в конкретную сессию. -## 11. Замечания +## 13. Замечания - все DM-типы `1..8` используют `SHiNE_DM` - `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 6afe722e..3798f1bb 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -446,11 +446,14 @@ Request: "op": "ReceiveIncomingMessage", "requestId": "req-456", "payload": { - "incomingBlobB64": "..." + "incomingBlobB64": "...", + "sourceServerLogin": "server-a" } } ``` +`sourceServerLogin` необязателен и используется как best-effort подсказка, чтобы сервер при дальнейшей пересылке не отправлял то же событие обратно серверу-источнику. + ### 10.3. `DeleteMessage` Назначение: @@ -559,7 +562,50 @@ UI-следствие для клиента: Идемпотентность обязательна. -### 11.3. Ошибки доставки +### 11.3. Догоняющая синхронизация истории + +Для восстановления пропущенных DM-событий между access-серверами используется отдельная операция: + +- `DmSyncBatch` + +Сервер-получатель синхронизации запрашивает у другого access-сервера историю одного пользователя по курсору: + +- `ownerLogin`; +- `afterStoredAtMs`; +- `afterMessageKey`; +- `limit`, максимум `500`; +- `maxBytes`, ограничение суммарного размера raw-блоков пачки. + +Удалённый сервер отдаёт все DM-события, относящиеся к этому пользователю: + +- контентные копии и read-receipt по `target_login`; +- tombstone типов `5/6/7/8`, где пользователь участвует как `fromLogin` или `toLogin`. + +Порядок пачки: + +- `created_at_ms ASC`; +- `message_key ASC`. + +Курсор хранится локально для пары: + +- пользователь; +- удалённый access-сервер. + +При первом добавлении сервера или отсутствии курсора синхронизация стартует с `0` и постепенно подтягивает всю доступную историю пачками. + +При применении событий, полученных через `DmSyncBatch`, сервер: + +- проверяет формат `SHiNE_DM`; +- проверяет подпись; +- применяет существующие правила ревизий, read-receipt и tombstone; +- не отправляет realtime/push-уведомления клиентам; +- не запускает повторный fan-out, чтобы не создавать циклы. + +Плановый sync запускается фоном после старта WebSocket-сервера и повторяется раз в 6 часов. + +В текущей реализации межсерверная авторизация для `DmSyncBatch` ещё не включена. Сервер отдаёт пачку только если сам локально является access-сервером `ownerLogin` по актуальной таблице `user_access_servers_current`. + +### 11.4. Ошибки доставки Если часть серверов временно недоступна: @@ -571,7 +617,7 @@ UI-следствие для клиента: Основная таблица остаётся: -- `signed_messages_v2` +- `signed_messages` В ней должны сохраняться: @@ -590,6 +636,12 @@ UI-следствие для клиента: Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются. +Для догоняющей межсерверной синхронизации дополнительно используются: + +- индекс по `target_login`, `created_at_ms`, `message_key`; +- отдельные индексы по delete-событиям для `from_login` и `to_login`; +- таблица `dm_sync_peer_state` с курсором чтения для пары `ownerLogin + remoteServerLogin`. + ## 13. Что обязательно должно измениться в коде относительно v0.5 - сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`; @@ -610,4 +662,4 @@ UI-следствие для клиента: - хранение отдельного `keyId` шифрования в DM; - ротация `clientKey`; - финальная конкретная UI-реализация массовой перешифровки; -- физическая полная реализация DM federation в текущем коде. +- межсерверная авторизация `DmSyncBatch`. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index 538cb19f..98b75add 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -327,3 +327,5 @@ ReadReceiptBody_v1_0 ## 13. Примечание о поддержке В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`. + +Межсерверная операция `DmSyncBatch` не вводит новый байтовый формат DM. Она передаёт уже сохранённые raw-контейнеры `SHiNE_DM` в Base64 вместе с серверными метаданными курсора (`storedAtMs`, `messageKey`), а принимающий сервер заново проверяет подпись и применяет тот же контейнер по его `messageType`.