From fac166f18603d016c724e67a4bbfaa5ca38febd85f59cc9693f3c6c98c701298 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 19 Aug 2026 18:10:18 +0400 Subject: [PATCH 1/5] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=20legacy-=D1=81=D1=82=D0=B5=D0=BA=20=D0=BF=D1=80=D1=8F=D0=BC?= =?UTF-8?q?=D1=8B=D1=85=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/shine/db/dao/DirectMessagesDAO.java | 53 ----- .../dao/SignedDirectMessagesHistoryDAO.java | 47 ---- .../java/shine/db/dao/SignedDmReplayDAO.java | 51 ----- .../shine/db/entities/DirectMessageEntry.java | 24 -- .../SignedDirectMessageHistoryEntry.java | 35 --- .../ws_protocol/JSON/JsonHandlerRegistry.java | 2 - .../Net_SendDirectMessage_Handler.java | 208 ------------------ .../Net_SendDirectMessage_Request.java | 10 - .../Net_SendDirectMessage_Response.java | 19 -- VERSION.properties | 2 +- docs/API/09_Operations_Index.md | 1 - docs/API/12_Direct_Messages_Push_Calls_API.md | 1 - docs/Personal_Messages/Протокол_DM_v1.md | 3 - 13 files changed, 1 insertion(+), 455 deletions(-) delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDirectMessagesHistoryDAO.java delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDmReplayDAO.java delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DirectMessageEntry.java delete mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedDirectMessageHistoryEntry.java delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Request.java delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Response.java diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java deleted file mode 100644 index ae063da4..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java +++ /dev/null @@ -1,53 +0,0 @@ -package shine.db.dao; - -import shine.db.DbController; -import shine.db.entities.DirectMessageEntry; - -import java.sql.Connection; -import java.sql.PreparedStatement; - -public final class DirectMessagesDAO { - private static volatile DirectMessagesDAO instance; - private final DbController db = DbController.getInstance(); - - private DirectMessagesDAO() {} - - public static DirectMessagesDAO getInstance() { - if (instance == null) { - synchronized (DirectMessagesDAO.class) { - if (instance == null) instance = new DirectMessagesDAO(); - } - } - return instance; - } - - public void insert(DirectMessageEntry entry) throws Exception { - try (Connection c = db.getConnection()) { - String sql = """ - INSERT INTO direct_messages ( - message_id, from_login, to_login, text, created_at_ms - ) VALUES (?, ?, ?, ?, ?) - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, entry.getMessageId()); - ps.setString(2, entry.getFromLogin()); - ps.setString(3, entry.getToLogin()); - ps.setString(4, entry.getText()); - ps.setLong(5, entry.getCreatedAtMs()); - ps.executeUpdate(); - } - } - } - - public boolean existsFromTo(String fromLogin, String toLogin) throws Exception { - try (Connection c = db.getConnection()) { - String sql = "SELECT 1 FROM direct_messages WHERE from_login = ? AND to_login = ? LIMIT 1"; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, fromLogin); - ps.setString(2, toLogin); - return ps.executeQuery().next(); - } - } - } - -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDirectMessagesHistoryDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDirectMessagesHistoryDAO.java deleted file mode 100644 index 0c9f9064..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDirectMessagesHistoryDAO.java +++ /dev/null @@ -1,47 +0,0 @@ -package shine.db.dao; - -import shine.db.DbController; -import shine.db.entities.SignedDirectMessageHistoryEntry; - -import java.sql.Connection; -import java.sql.PreparedStatement; - -public final class SignedDirectMessagesHistoryDAO { - private static volatile SignedDirectMessagesHistoryDAO instance; - private final DbController db = DbController.getInstance(); - - private SignedDirectMessagesHistoryDAO() {} - - public static SignedDirectMessagesHistoryDAO getInstance() { - if (instance == null) { - synchronized (SignedDirectMessagesHistoryDAO.class) { - if (instance == null) instance = new SignedDirectMessagesHistoryDAO(); - } - } - return instance; - } - - public void insert(SignedDirectMessageHistoryEntry e) throws Exception { - try (Connection c = db.getConnection()) { - String sql = """ - INSERT INTO signed_direct_messages_history ( - message_id, from_login, to_login, target_mode, target_session_id, - message_type, time_ms, nonce, raw_packet, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, e.getMessageId()); - ps.setString(2, e.getFromLogin()); - ps.setString(3, e.getToLogin()); - ps.setInt(4, e.getTargetMode()); - ps.setString(5, e.getTargetSessionId()); - ps.setInt(6, e.getMessageType()); - ps.setLong(7, e.getTimeMs()); - ps.setLong(8, e.getNonce()); - ps.setBytes(9, e.getRawPacket()); - ps.setLong(10, e.getCreatedAtMs()); - ps.executeUpdate(); - } - } - } -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDmReplayDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDmReplayDAO.java deleted file mode 100644 index 9d11354b..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDmReplayDAO.java +++ /dev/null @@ -1,51 +0,0 @@ -package shine.db.dao; - -import shine.db.DbController; - -import java.sql.Connection; -import java.sql.PreparedStatement; - -public final class SignedDmReplayDAO { - private static volatile SignedDmReplayDAO instance; - private final DbController db = DbController.getInstance(); - - private SignedDmReplayDAO() {} - - public static SignedDmReplayDAO getInstance() { - if (instance == null) { - synchronized (SignedDmReplayDAO.class) { - if (instance == null) instance = new SignedDmReplayDAO(); - } - } - return instance; - } - - public boolean registerUnique(String fromLogin, long timeMs, long nonce, long nowMs) throws Exception { - cleanupExpired(nowMs - 15L * 60L * 1000L); - try (Connection c = db.getConnection()) { - String sql = """ - INSERT INTO signed_direct_message_replay ( - from_login, time_ms, nonce, created_at_ms - ) VALUES (?, ?, ?, ?) - ON CONFLICT DO NOTHING - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, fromLogin); - ps.setLong(2, timeMs); - ps.setLong(3, nonce); - ps.setLong(4, nowMs); - return ps.executeUpdate() > 0; - } - } - } - - public void cleanupExpired(long minCreatedAtMs) throws Exception { - try (Connection c = db.getConnection()) { - String sql = "DELETE FROM signed_direct_message_replay WHERE created_at_ms < ?"; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setLong(1, minCreatedAtMs); - ps.executeUpdate(); - } - } - } -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DirectMessageEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DirectMessageEntry.java deleted file mode 100644 index d916e6f6..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DirectMessageEntry.java +++ /dev/null @@ -1,24 +0,0 @@ -package shine.db.entities; - -public class DirectMessageEntry { - private String messageId; - private String fromLogin; - private String toLogin; - private String text; - private long createdAtMs; - - public String getMessageId() { return messageId; } - public void setMessageId(String messageId) { this.messageId = messageId; } - - public String getFromLogin() { return fromLogin; } - public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; } - - public String getToLogin() { return toLogin; } - public void setToLogin(String toLogin) { this.toLogin = toLogin; } - - public String getText() { return text; } - public void setText(String text) { this.text = text; } - - public long getCreatedAtMs() { return createdAtMs; } - public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedDirectMessageHistoryEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedDirectMessageHistoryEntry.java deleted file mode 100644 index ca069f9b..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedDirectMessageHistoryEntry.java +++ /dev/null @@ -1,35 +0,0 @@ -package shine.db.entities; - -public class SignedDirectMessageHistoryEntry { - private String messageId; - private String fromLogin; - private String toLogin; - private int targetMode; - private String targetSessionId; - private int messageType; - private long timeMs; - private long nonce; - private byte[] rawPacket; - private long createdAtMs; - - public String getMessageId() { return messageId; } - public void setMessageId(String messageId) { this.messageId = messageId; } - public String getFromLogin() { return fromLogin; } - public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; } - public String getToLogin() { return toLogin; } - public void setToLogin(String toLogin) { this.toLogin = toLogin; } - public int getTargetMode() { return targetMode; } - public void setTargetMode(int targetMode) { this.targetMode = targetMode; } - public String getTargetSessionId() { return targetSessionId; } - public void setTargetSessionId(String targetSessionId) { this.targetSessionId = targetSessionId; } - public int getMessageType() { return messageType; } - public void setMessageType(int messageType) { this.messageType = messageType; } - public long getTimeMs() { return timeMs; } - public void setTimeMs(long timeMs) { this.timeMs = timeMs; } - public long getNonce() { return nonce; } - public void setNonce(long nonce) { this.nonce = nonce; } - public byte[] getRawPacket() { return rawPacket; } - public void setRawPacket(byte[] rawPacket) { this.rawPacket = rawPacket; } - public long getCreatedAtMs() { return createdAtMs; } - public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } -} 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 65719bf4..18bea259 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 @@ -94,7 +94,6 @@ import server.logic.ws_protocol.JSON.messages.Net_DmSyncBatch_Handler; import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler; import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler; -import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler; import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler; @@ -107,7 +106,6 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request; -import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java deleted file mode 100644 index 32b80d64..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java +++ /dev/null @@ -1,208 +0,0 @@ -package server.logic.ws_protocol.JSON.messages; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -import server.logic.ws_protocol.JSON.ConnectionContext; -import server.logic.ws_protocol.JSON.entyties.Net_Request; -import server.logic.ws_protocol.JSON.entyties.Net_Response; -import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; -import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request; -import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Response; -import server.logic.ws_protocol.JSON.push.WebPushSender; -import server.logic.ws_protocol.JSON.push.WsEventSender; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.JSON.utils.NetIdGenerator; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.DirectMessagesDAO; -import shine.db.dao.SignedDirectMessagesHistoryDAO; -import shine.db.dao.SignedDmReplayDAO; -import shine.db.dao.CurrentUsersDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.DirectMessageEntry; -import shine.db.entities.SignedDirectMessageHistoryEntry; -import shine.db.entities.CurrentUserEntry; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; - -public class Net_SendDirectMessage_Handler implements JsonMessageHandler { - private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final long REPLAY_TTL_MS = 15L * 60L * 1000L; - private static final int MAX_MESSAGE_BYTES = 3000; - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { - Net_SendDirectMessage_Request req = (Net_SendDirectMessage_Request) baseRequest; - if (req.getBlobB64() == null || req.getBlobB64().isBlank()) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "blobB64 обязателен"); - } - - final byte[] raw; - final SignedDirectMessagePacket packet; - try { - raw = Base64.getDecoder().decode(req.getBlobB64().trim()); - packet = SignedDirectMessagePacket.parse(raw, MAX_MESSAGE_BYTES); - } catch (IllegalArgumentException ex) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат пакета"); - } - - CurrentUserEntry fromUser = CurrentUsersDAO.getInstance().getByLogin(packet.fromLogin); - CurrentUserEntry toUser = CurrentUsersDAO.getInstance().getByLogin(packet.toLogin); - if (fromUser == null || toUser == null) { - return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "from/to пользователь не найден"); - } - - byte[] publicKey32; - try { - publicKey32 = Ed25519Util.keyFromBase64(fromUser.getClientKey()); - } catch (Exception e) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_DEVICE_KEY", "Некорректный clientKey отправителя"); - } - if (!Ed25519Util.verify(packet.signedBody, packet.signature64, publicKey32)) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_SIGNATURE", "Подпись не прошла проверку"); - } - - long now = System.currentTimeMillis(); - if (Math.abs(now - packet.timeMs) > REPLAY_TTL_MS) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_TIME_WINDOW", "Время сообщения вышло за окно 15 минут"); - } - - boolean replayOk = SignedDmReplayDAO.getInstance().registerUnique(packet.fromLogin, packet.timeMs, packet.nonce, now); - if (!replayOk) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "REPLAY", "Повторное сообщение заблокировано"); - } - - String messageId = NetIdGenerator.eventId("msg"); - String textForUi = new String(packet.messageBytes, StandardCharsets.UTF_8); - - DirectMessageEntry entry = new DirectMessageEntry(); - entry.setMessageId(messageId); - entry.setFromLogin(packet.fromLogin); - entry.setToLogin(packet.toLogin); - entry.setText(textForUi); - entry.setCreatedAtMs(now); - DirectMessagesDAO.getInstance().insert(entry); - - SignedDirectMessageHistoryEntry history = new SignedDirectMessageHistoryEntry(); - history.setMessageId(messageId); - history.setFromLogin(packet.fromLogin); - history.setToLogin(packet.toLogin); - history.setTargetMode(packet.targetMode); - history.setTargetSessionId(packet.targetSessionId); - history.setMessageType(packet.messageType); - history.setTimeMs(packet.timeMs); - history.setNonce(packet.nonce); - history.setRawPacket(packet.rawPacket); - history.setCreatedAtMs(now); - SignedDirectMessagesHistoryDAO.getInstance().insert(history); - - DeliveryResult delivery = deliver(packet, req.getBlobB64().trim(), messageId, now); - - Net_SendDirectMessage_Response resp = new Net_SendDirectMessage_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setMessageId(messageId); - resp.setDeliveredWsSessions(delivery.wsDelivered); - resp.setDeliveredWebPushSessions(delivery.webPushDelivered); - resp.setSessionNotFound(delivery.sessionNotFound); - return resp; - } - - private DeliveryResult deliver(SignedDirectMessagePacket packet, String blobB64, String messageId, long createdAtMs) throws Exception { - DeliveryResult result = new DeliveryResult(); - - Set selectedSessionIds = new HashSet<>(); - if (packet.targetMode == SignedDirectMessagePacket.TARGET_ONE_SESSION) { - ActiveSessionEntry byId = ActiveSessionsDAO.getInstance().getBySessionId(packet.targetSessionId); - if (byId == null || !packet.toLogin.equalsIgnoreCase(byId.getLogin())) { - result.sessionNotFound = true; - return result; - } - selectedSessionIds.add(byId.getSessionId()); - deliverToSession(packet, blobB64, messageId, createdAtMs, byId.getSessionId(), result); - return result; - } - - List sessions = ActiveSessionsDAO.getInstance().getByLogin(packet.toLogin); - for (ActiveSessionEntry s : sessions) { - selectedSessionIds.add(s.getSessionId()); - deliverToSession(packet, blobB64, messageId, createdAtMs, s.getSessionId(), result); - } - return result; - } - - private void deliverToSession( - SignedDirectMessagePacket packet, - String blobB64, - String messageId, - long createdAtMs, - String sessionId, - DeliveryResult result - ) { - ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId); - boolean wsDelivered = false; - if (targetCtx != null) { - String eventId = NetIdGenerator.eventId("evt"); - CompletableFuture waiter = DeliveryTracker.getInstance().register(eventId); - ObjectNode payload = MAPPER.createObjectNode(); - payload.put("eventId", eventId); - payload.put("messageId", messageId); - payload.put("fromLogin", packet.fromLogin); - payload.put("toLogin", packet.toLogin); - payload.put("blobB64", blobB64); - payload.put("text", new String(packet.messageBytes, StandardCharsets.UTF_8)); - payload.put("timeMs", createdAtMs); - - boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingDirectMessage", eventId, payload); - if (sent) { - try { - wsDelivered = waiter.get(1200, TimeUnit.MILLISECONDS); - } catch (Exception ignored) { - wsDelivered = false; - } - } - DeliveryTracker.getInstance().remove(eventId); - } - - if (wsDelivered) { - result.wsDelivered++; - return; - } - - try { - ActiveSessionEntry targetSession = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); - if (targetSession == null) return; - if (isBlank(targetSession.getPushEndpoint()) || isBlank(targetSession.getPushP256dhKey()) || isBlank(targetSession.getPushAuthKey())) { - return; - } - boolean pushed = WebPushSender.sendBase64Payload( - targetSession.getPushEndpoint(), - targetSession.getPushP256dhKey(), - targetSession.getPushAuthKey(), - blobB64 - ); - if (pushed) result.webPushDelivered++; - } catch (Exception ignored) { - // ignore per-session push errors - } - } - - private boolean isBlank(String s) { - return s == null || s.isBlank(); - } - - private static final class DeliveryResult { - int wsDelivered; - int webPushDelivered; - boolean sessionNotFound; - } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Request.java deleted file mode 100644 index cf52bc97..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Request.java +++ /dev/null @@ -1,10 +0,0 @@ -package server.logic.ws_protocol.JSON.messages.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -public class Net_SendDirectMessage_Request extends Net_Request { - private String blobB64; - - public String getBlobB64() { return blobB64; } - public void setBlobB64(String blobB64) { this.blobB64 = blobB64; } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Response.java deleted file mode 100644 index 88337375..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendDirectMessage_Response.java +++ /dev/null @@ -1,19 +0,0 @@ -package server.logic.ws_protocol.JSON.messages.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -public class Net_SendDirectMessage_Response extends Net_Response { - private String messageId; - private int deliveredWsSessions; - private int deliveredWebPushSessions; - private boolean sessionNotFound; - - public String getMessageId() { return messageId; } - public void setMessageId(String messageId) { this.messageId = messageId; } - public int getDeliveredWsSessions() { return deliveredWsSessions; } - public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; } - public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; } - public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; } - public boolean isSessionNotFound() { return sessionNotFound; } - public void setSessionNotFound(boolean sessionNotFound) { this.sessionNotFound = sessionNotFound; } -} diff --git a/VERSION.properties b/VERSION.properties index 1e462d05..c16c29a1 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ client.version=1.7.0 -server.version=1.6.0 +server.version=1.6.1 diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 95826e81..e7d20fac 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -71,7 +71,6 @@ ## Важные замечания - `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`. -- Legacy-операция `SendDirectMessage` больше не зарегистрирована и не должна использоваться для DM v1. - Отдельных HTTP endpoints для DM-файлов сейчас нет. - Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит. - HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`. diff --git a/docs/API/12_Direct_Messages_Push_Calls_API.md b/docs/API/12_Direct_Messages_Push_Calls_API.md index f576bb2e..6bd5d554 100644 --- a/docs/API/12_Direct_Messages_Push_Calls_API.md +++ b/docs/API/12_Direct_Messages_Push_Calls_API.md @@ -9,7 +9,6 @@ Важно: -- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API; - для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`; - `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI. diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 50fa4ad8..b6843f3f 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -334,8 +334,6 @@ ### 8.2. Новые методы, которые нужны -Отдельный legacy-метод `SendDirectMessage` в DM v1 не используется и должен оставаться отключённым, чтобы не было параллельного старого стека доставки. - ## 9. Правила валидации и применения ### 9.1. Общее правило по ревизиям @@ -653,7 +651,6 @@ UI-следствие для клиента: - межсерверная маршрутизация DM должна идти через `access_servers`; - сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM; - при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает; -- legacy `SendDirectMessage` должен быть отключён; - логика должна быть безопасна для нескольких серверов у каждой стороны. ## 14. Что в v1 пока не входит From 6a5c20a16536482a2d145ea5161d31cd33555d73e5a8f071e6ec313219d79b4f Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 19 Aug 2026 19:45:09 +0400 Subject: [PATCH 2/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20user=5Fsettings=20=D0=B8=20=D1=81=D0=B8=D0=BD=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8E=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/config/ShineSignatureConstants.java | 5 +- .../java/shine/db/DatabaseInitializer.java | 6 + .../java/shine/db/dao/UserSettingsDAO.java | 231 ++++++++++++++++++ .../db/dao/UserSettingsSyncPeerStateDAO.java | 152 ++++++++++++ .../shine/db/entities/UserSettingEntry.java | 68 ++++++ .../UserSettingsSyncPeerStateEntry.java | 40 +++ .../main/resources/postgres/migration_v9.sql | 47 ++++ .../src/main/resources/postgres/schema_v1.sql | 40 ++- .../ws_protocol/JSON/JsonHandlerRegistry.java | 24 ++ .../channels/ChannelsReadSupport.java | 34 +++ .../Net_ListSubscriptionsFeed_Handler.java | 2 +- .../Net_GetUserSetting_Handler.java | 64 +++++ .../Net_ListUserSettings_Handler.java | 68 ++++++ .../Net_UpsertUserSetting_Handler.java | 157 ++++++++++++ .../entyties/Net_GetUserSetting_Request.java | 18 ++ .../entyties/Net_GetUserSetting_Response.java | 42 ++++ .../Net_ListUserSettings_Request.java | 10 + .../Net_ListUserSettings_Response.java | 48 ++++ .../Net_UpsertUserSetting_Request.java | 42 ++++ .../Net_UpsertUserSetting_Response.java | 26 ++ ...t_MarkAllUserSettingsUnsynced_Handler.java | 42 ++++ .../Net_UserSettingsSyncBatch_Handler.java | 129 ++++++++++ .../UserSettingsSyncApplySupport.java | 61 +++++ ...t_MarkAllUserSettingsUnsynced_Request.java | 10 + ..._MarkAllUserSettingsUnsynced_Response.java | 10 + .../Net_UserSettingsSyncBatch_Request.java | 26 ++ .../Net_UserSettingsSyncBatch_Response.java | 62 +++++ .../sync/RemoteUserSettingsSyncClient.java | 209 ++++++++++++++++ .../sync/PeriodicUserSettingsSyncService.java | 206 ++++++++++++++++ .../src/main/java/server/ws/WsServer.java | 2 + ..._дороботать_для_максимальной_надёжности.md | 0 .../межсерверные_звонки.md | 0 docs/API/09_Operations_Index.md | 5 + docs/API/13_User_Settings_API.md | 98 ++++++++ docs/Blockchain/sync-between-servers.md | 11 + shine-UI/js/pages/channel-view.js | 30 +++ shine-UI/js/services/auth-service.js | 57 +++++ 37 files changed, 2079 insertions(+), 3 deletions(-) create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserSettingsDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserSettingsSyncPeerStateDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserSettingEntry.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserSettingsSyncPeerStateEntry.java create mode 100644 SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v9.sql create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_GetUserSetting_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_ListUserSettings_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_GetUserSetting_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_GetUserSetting_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_ListUserSettings_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_ListUserSettings_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_MarkAllUserSettingsUnsynced_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_UserSettingsSyncBatch_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/UserSettingsSyncApplySupport.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteUserSettingsSyncClient.java create mode 100644 SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java rename TODO/{В_отдалённом_Будущем => Вкладка Личные}/ДЦ_личную_переписку_дороботать_для_максимальной_надёжности.md (100%) rename TODO/{ => Вкладка Личные}/межсерверные_звонки.md (100%) create mode 100644 docs/API/13_User_Settings_API.md diff --git a/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java b/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java index c3eb1902..a8cb5d08 100644 --- a/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java +++ b/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java @@ -14,6 +14,9 @@ public final class ShineSignatureConstants { /** Подписываемые данные параметра пользователя: prefix + login + param + time_ms + value */ public static final String USER_PARAMETER_PREFIX = "SHiNe/UserParameter:"; + /** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */ + public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:"; + /** TAG в HeaderBody (genesis). ASCII "SHiNe". */ public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe"; @@ -31,4 +34,4 @@ public final class ShineSignatureConstants { /** Длина публичного ключа Ed25519. */ public static final int ED25519_PUBLIC_KEY32_LEN = 32; -} \ No newline at end of file +} 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 ea9f4932..73975a76 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 @@ -25,6 +25,7 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_6 = 6; public static final int SCHEMA_VERSION_7 = 7; public static final int SCHEMA_VERSION_8 = 8; + public static final int SCHEMA_VERSION_9 = 9; 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"; @@ -33,6 +34,7 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V6_RESOURCE = "postgres/migration_v6.sql"; public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql"; public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql"; + public static final String POSTGRES_MIGRATION_V9_RESOURCE = "postgres/migration_v9.sql"; private DatabaseInitializer() {} @@ -124,6 +126,10 @@ public final class DatabaseInitializer { } if (currentVersion < SCHEMA_VERSION_8) { runSqlScript(conn, POSTGRES_MIGRATION_V8_RESOURCE); + currentVersion = SCHEMA_VERSION_8; + } + if (currentVersion < SCHEMA_VERSION_9) { + runSqlScript(conn, POSTGRES_MIGRATION_V9_RESOURCE); } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserSettingsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserSettingsDAO.java new file mode 100644 index 00000000..82431ef9 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserSettingsDAO.java @@ -0,0 +1,231 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.entities.UserSettingEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; + +/** + * UserSettingsDAO — хранение пользовательских настроек. + * + * Правило: + * - уникальность: login + setting_type + setting_key + * - запись обновляется только если time_ms новее + * - synced=true означает, что значение уже дошло до второго сервера + */ +public final class UserSettingsDAO { + + private static volatile UserSettingsDAO instance; + private final DbController db = DbController.getInstance(); + + private UserSettingsDAO() {} + + public static UserSettingsDAO getInstance() { + if (instance == null) { + synchronized (UserSettingsDAO.class) { + if (instance == null) instance = new UserSettingsDAO(); + } + } + return instance; + } + + public int upsertIfNewer(Connection c, UserSettingEntry e) throws SQLException { + String sql = """ + INSERT INTO user_settings ( + login, setting_type, setting_key, time_ms, + value_text, value_num, client_key, signature, synced + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (login, setting_type, setting_key) + DO UPDATE SET + time_ms = EXCLUDED.time_ms, + value_text = EXCLUDED.value_text, + value_num = EXCLUDED.value_num, + client_key = EXCLUDED.client_key, + signature = EXCLUDED.signature, + synced = EXCLUDED.synced + WHERE user_settings.time_ms < EXCLUDED.time_ms + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, e.getLogin()); + ps.setInt(2, e.getSettingType()); + ps.setString(3, e.getSettingKey()); + ps.setLong(4, e.getTimeMs()); + ps.setString(5, e.getValueText() == null ? "" : e.getValueText()); + ps.setLong(6, e.getValueNum()); + + if (e.getClientKey() == null || e.getClientKey().isBlank()) ps.setNull(7, Types.VARCHAR); + else ps.setString(7, e.getClientKey()); + + if (e.getSignature() == null || e.getSignature().isBlank()) ps.setNull(8, Types.VARCHAR); + else ps.setString(8, e.getSignature()); + + ps.setBoolean(9, e.isSynced()); + return ps.executeUpdate(); + } + } + + public int upsertIfNewer(UserSettingEntry e) throws SQLException { + try (Connection c = db.getConnection()) { + return upsertIfNewer(c, e); + } + } + + public UserSettingEntry getByLoginTypeKey(Connection c, String login, int settingType, String settingKey) throws SQLException { + String sql = """ + SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced + FROM user_settings + WHERE LOWER(login) = LOWER(?) AND setting_type = ? AND setting_key = ? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + ps.setInt(2, settingType); + ps.setString(3, settingKey); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? mapRow(rs) : null; + } + } + } + + public UserSettingEntry getByLoginTypeKey(String login, int settingType, String settingKey) throws SQLException { + try (Connection c = db.getConnection()) { + return getByLoginTypeKey(c, login, settingType, settingKey); + } + } + + public List getByLogin(Connection c, String login) throws SQLException { + String sql = """ + SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced + FROM user_settings + WHERE LOWER(login) = LOWER(?) + ORDER BY time_ms DESC, setting_type ASC, setting_key ASC + """; + List out = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(mapRow(rs)); + } + } + return out; + } + + public List getByLogin(String login) throws SQLException { + try (Connection c = db.getConnection()) { + return getByLogin(c, login); + } + } + + public List listNewerThan(Connection c, String login, long afterTimeMs, String afterSettingKey, int limit) throws SQLException { + String sql = """ + SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced + FROM user_settings + WHERE LOWER(login) = LOWER(?) + AND ( + time_ms > ? + OR (time_ms = ? AND setting_key > ?) + ) + ORDER BY time_ms ASC, setting_key ASC + LIMIT ? + """; + List out = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + ps.setLong(2, Math.max(0L, afterTimeMs)); + ps.setLong(3, Math.max(0L, afterTimeMs)); + ps.setString(4, afterSettingKey == null ? "" : afterSettingKey); + ps.setInt(5, Math.max(1, limit)); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(mapRow(rs)); + } + } + return out; + } + + public List listUnsyncedByLogin(Connection c, String login, int limit) throws SQLException { + String sql = """ + SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced + FROM user_settings + WHERE LOWER(login) = LOWER(?) + AND synced = FALSE + ORDER BY time_ms ASC, setting_key ASC + LIMIT ? + """; + List out = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + ps.setInt(2, Math.max(1, limit)); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(mapRow(rs)); + } + } + return out; + } + + public int markSynced(Connection c, String login, int settingType, String settingKey) throws SQLException { + String sql = """ + UPDATE user_settings + SET synced = TRUE + WHERE LOWER(login) = LOWER(?) + AND setting_type = ? + AND setting_key = ? + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + ps.setInt(2, settingType); + ps.setString(3, settingKey); + return ps.executeUpdate(); + } + } + + public int markAllUnsynced(Connection c, String login) throws SQLException { + String sql = """ + UPDATE user_settings + SET synced = FALSE + WHERE LOWER(login) = LOWER(?) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + return ps.executeUpdate(); + } + } + + public int markAllUnsynced() throws SQLException { + try (Connection c = db.getConnection()) { + return markAllUnsynced(c); + } + } + + public int markAllUnsynced(Connection c) throws SQLException { + try (PreparedStatement ps = c.prepareStatement("UPDATE user_settings SET synced = FALSE")) { + return ps.executeUpdate(); + } + } + + private static UserSettingEntry mapRow(ResultSet rs) throws SQLException { + UserSettingEntry e = new UserSettingEntry(); + e.setLogin(rs.getString("login")); + e.setSettingType(rs.getInt("setting_type")); + e.setSettingKey(rs.getString("setting_key")); + e.setTimeMs(rs.getLong("time_ms")); + e.setValueText(rs.getString("value_text")); + e.setValueNum(rs.getLong("value_num")); + + String clientKey = rs.getString("client_key"); + if (rs.wasNull()) clientKey = null; + e.setClientKey(clientKey); + + String signature = rs.getString("signature"); + if (rs.wasNull()) signature = null; + e.setSignature(signature); + + e.setSynced(rs.getBoolean("synced")); + return e; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserSettingsSyncPeerStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserSettingsSyncPeerStateDAO.java new file mode 100644 index 00000000..3d1e57e2 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserSettingsSyncPeerStateDAO.java @@ -0,0 +1,152 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.entities.UserSettingsSyncPeerStateEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +public final class UserSettingsSyncPeerStateDAO { + + private static volatile UserSettingsSyncPeerStateDAO instance; + private final DbController db = DbController.getInstance(); + + private UserSettingsSyncPeerStateDAO() {} + + public static UserSettingsSyncPeerStateDAO getInstance() { + if (instance == null) { + synchronized (UserSettingsSyncPeerStateDAO.class) { + if (instance == null) instance = new UserSettingsSyncPeerStateDAO(); + } + } + return instance; + } + + public UserSettingsSyncPeerStateEntry getOrCreate(Connection c, String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException { + UserSettingsSyncPeerStateEntry existing = get(c, ownerLogin, remoteServerLogin); + if (existing != null) return existing; + long nowMs = System.currentTimeMillis(); + String sql = """ + INSERT INTO user_settings_sync_peer_state ( + owner_login, remote_server_login, remote_server_url, + cursor_time_ms, cursor_setting_key, bootstrap_completed, + last_sync_at_ms, last_error, updated_at_ms + ) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?) + ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET + remote_server_url = EXCLUDED.remote_server_url, + updated_at_ms = EXCLUDED.updated_at_ms + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerLogin); + ps.setString(2, remoteServerLogin); + ps.setString(3, remoteServerUrl); + ps.setLong(4, nowMs); + ps.executeUpdate(); + } + return get(c, ownerLogin, remoteServerLogin); + } + + public UserSettingsSyncPeerStateEntry get(Connection c, String ownerLogin, String remoteServerLogin) throws SQLException { + String sql = """ + SELECT owner_login, remote_server_login, remote_server_url, cursor_time_ms, cursor_setting_key, + bootstrap_completed, last_sync_at_ms, last_error, updated_at_ms + FROM user_settings_sync_peer_state + WHERE LOWER(owner_login) = LOWER(?) AND LOWER(remote_server_login) = LOWER(?) + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerLogin); + ps.setString(2, remoteServerLogin); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? mapRow(rs) : null; + } + } + } + + public int updateSuccess(String ownerLogin, String remoteServerLogin, String remoteServerUrl, long cursorTimeMs, String cursorSettingKey, boolean bootstrapCompleted) throws SQLException { + try (Connection c = db.getConnection()) { + String sql = """ + INSERT INTO user_settings_sync_peer_state ( + owner_login, remote_server_login, remote_server_url, + cursor_time_ms, cursor_setting_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_time_ms = EXCLUDED.cursor_time_ms, + cursor_setting_key = EXCLUDED.cursor_setting_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 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + long nowMs = System.currentTimeMillis(); + ps.setString(1, ownerLogin); + ps.setString(2, remoteServerLogin); + ps.setString(3, remoteServerUrl); + ps.setLong(4, Math.max(0L, cursorTimeMs)); + ps.setString(5, cursorSettingKey == null ? "" : cursorSettingKey); + ps.setBoolean(6, bootstrapCompleted); + ps.setLong(7, nowMs); + ps.setLong(8, nowMs); + return ps.executeUpdate(); + } + } + } + + public int updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException { + try (Connection c = db.getConnection()) { + String sql = """ + INSERT INTO user_settings_sync_peer_state ( + owner_login, remote_server_login, remote_server_url, + cursor_time_ms, cursor_setting_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 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + long nowMs = System.currentTimeMillis(); + ps.setString(1, ownerLogin); + ps.setString(2, remoteServerLogin); + ps.setString(3, remoteServerUrl); + ps.setString(4, error); + ps.setLong(5, nowMs); + return ps.executeUpdate(); + } + } + } + + public int clearBootstrap(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException { + return updateSuccess(ownerLogin, remoteServerLogin, remoteServerUrl, 0L, "", false); + } + + public int deleteAllForOwner(String ownerLogin) throws SQLException { + try (Connection c = db.getConnection()) { + try (PreparedStatement ps = c.prepareStatement("DELETE FROM user_settings_sync_peer_state WHERE LOWER(owner_login) = LOWER(?)")) { + ps.setString(1, ownerLogin); + return ps.executeUpdate(); + } + } + } + + private static UserSettingsSyncPeerStateEntry mapRow(ResultSet rs) throws SQLException { + UserSettingsSyncPeerStateEntry e = new UserSettingsSyncPeerStateEntry(); + e.setOwnerLogin(rs.getString("owner_login")); + e.setRemoteServerLogin(rs.getString("remote_server_login")); + e.setRemoteServerUrl(rs.getString("remote_server_url")); + e.setCursorTimeMs(rs.getLong("cursor_time_ms")); + e.setCursorSettingKey(rs.getString("cursor_setting_key")); + e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed")); + long lastSyncAtMs = rs.getLong("last_sync_at_ms"); + e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAtMs); + e.setLastError(rs.getString("last_error")); + e.setUpdatedAtMs(rs.getLong("updated_at_ms")); + return e; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserSettingEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserSettingEntry.java new file mode 100644 index 00000000..53fee088 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserSettingEntry.java @@ -0,0 +1,68 @@ +package shine.db.entities; + +/** + * UserSettingEntry — одна пользовательская настройка. + * + * Таблица: user_settings + * - login TEXT NOT NULL + * - setting_type INTEGER NOT NULL + * - setting_key TEXT NOT NULL + * - time_ms BIGINT NOT NULL + * - value_text TEXT NOT NULL + * - value_num BIGINT NOT NULL + * - client_key TEXT NOT NULL + * - signature TEXT NOT NULL + * - synced BOOLEAN NOT NULL + */ +public class UserSettingEntry { + private String login; + private int settingType; + private String settingKey; + private long timeMs; + private String valueText; + private long valueNum; + private String clientKey; + private String signature; + private boolean synced; + + public UserSettingEntry() {} + + public UserSettingEntry(String login, int settingType, String settingKey, long timeMs, String valueText, long valueNum, String clientKey, String signature, boolean synced) { + this.login = login; + this.settingType = settingType; + this.settingKey = settingKey; + this.timeMs = timeMs; + this.valueText = valueText; + this.valueNum = valueNum; + this.clientKey = clientKey; + this.signature = signature; + this.synced = synced; + } + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public int getSettingType() { return settingType; } + public void setSettingType(int settingType) { this.settingType = settingType; } + + public String getSettingKey() { return settingKey; } + public void setSettingKey(String settingKey) { this.settingKey = settingKey; } + + public long getTimeMs() { return timeMs; } + public void setTimeMs(long timeMs) { this.timeMs = timeMs; } + + public String getValueText() { return valueText; } + public void setValueText(String valueText) { this.valueText = valueText; } + + public long getValueNum() { return valueNum; } + public void setValueNum(long valueNum) { this.valueNum = valueNum; } + + public String getClientKey() { return clientKey; } + public void setClientKey(String clientKey) { this.clientKey = clientKey; } + + public String getSignature() { return signature; } + public void setSignature(String signature) { this.signature = signature; } + + public boolean isSynced() { return synced; } + public void setSynced(boolean synced) { this.synced = synced; } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserSettingsSyncPeerStateEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserSettingsSyncPeerStateEntry.java new file mode 100644 index 00000000..715854b4 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserSettingsSyncPeerStateEntry.java @@ -0,0 +1,40 @@ +package shine.db.entities; + +public class UserSettingsSyncPeerStateEntry { + private String ownerLogin; + private String remoteServerLogin; + private String remoteServerUrl; + private long cursorTimeMs; + private String cursorSettingKey; + 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 getCursorTimeMs() { return cursorTimeMs; } + public void setCursorTimeMs(long cursorTimeMs) { this.cursorTimeMs = cursorTimeMs; } + + public String getCursorSettingKey() { return cursorSettingKey; } + public void setCursorSettingKey(String cursorSettingKey) { this.cursorSettingKey = cursorSettingKey; } + + 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_v9.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v9.sql new file mode 100644 index 00000000..84f88d49 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v9.sql @@ -0,0 +1,47 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS user_settings ( + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + setting_type INTEGER NOT NULL, + setting_key TEXT NOT NULL, + time_ms BIGINT NOT NULL, + value_text TEXT NOT NULL DEFAULT '', + value_num BIGINT NOT NULL DEFAULT 0, + client_key TEXT NOT NULL DEFAULT '', + signature TEXT NOT NULL DEFAULT '', + synced BOOLEAN NOT NULL DEFAULT FALSE, + UNIQUE (login, setting_type, setting_key) +); + +CREATE INDEX IF NOT EXISTS idx_user_settings_login + ON user_settings(login); + +CREATE INDEX IF NOT EXISTS idx_user_settings_synced + ON user_settings(login, synced, time_ms); + +CREATE INDEX IF NOT EXISTS idx_user_settings_type_key + ON user_settings(setting_type, setting_key); + +CREATE TABLE IF NOT EXISTS user_settings_sync_peer_state ( + owner_login TEXT NOT NULL, + remote_server_login TEXT NOT NULL, + remote_server_url TEXT NOT NULL, + cursor_time_ms BIGINT NOT NULL DEFAULT 0, + cursor_setting_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_user_settings_sync_peer_state_owner + ON user_settings_sync_peer_state(owner_login); + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 9, 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 b86bd001..ff1c91db 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, 8, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) + VALUES (1, 9, 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; @@ -401,6 +401,44 @@ CREATE TABLE IF NOT EXISTS users_params ( CREATE INDEX IF NOT EXISTS idx_users_params_login ON users_params(login); +CREATE TABLE IF NOT EXISTS user_settings ( + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + setting_type INTEGER NOT NULL, + setting_key TEXT NOT NULL, + time_ms BIGINT NOT NULL, + value_text TEXT NOT NULL DEFAULT '', + value_num BIGINT NOT NULL DEFAULT 0, + client_key TEXT NOT NULL DEFAULT '', + signature TEXT NOT NULL DEFAULT '', + synced BOOLEAN NOT NULL DEFAULT FALSE, + UNIQUE (login, setting_type, setting_key) +); + +CREATE INDEX IF NOT EXISTS idx_user_settings_login + ON user_settings(login); + +CREATE INDEX IF NOT EXISTS idx_user_settings_synced + ON user_settings(login, synced, time_ms); + +CREATE INDEX IF NOT EXISTS idx_user_settings_type_key + ON user_settings(setting_type, setting_key); + +CREATE TABLE IF NOT EXISTS user_settings_sync_peer_state ( + owner_login TEXT NOT NULL, + remote_server_login TEXT NOT NULL, + remote_server_url TEXT NOT NULL, + cursor_time_ms BIGINT NOT NULL DEFAULT 0, + cursor_setting_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_user_settings_sync_peer_state_owner + ON user_settings_sync_peer_state(owner_login); + CREATE TABLE IF NOT EXISTS ip_geo_cache ( ip TEXT PRIMARY KEY, geo TEXT, 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 18bea259..d8aa3e15 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 @@ -60,6 +60,12 @@ import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Han import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request; import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request; import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request; +import server.logic.ws_protocol.JSON.handlers.userSettings.Net_GetUserSetting_Handler; +import server.logic.ws_protocol.JSON.handlers.userSettings.Net_ListUserSettings_Handler; +import server.logic.ws_protocol.JSON.handlers.userSettings.Net_UpsertUserSetting_Handler; +import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Request; +import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Request; +import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request; // --- NEW: connections friends lists --- import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler; @@ -90,8 +96,10 @@ 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_MarkAllUserSettingsUnsynced_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_UserSettingsSyncBatch_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_SendMessagePair_Handler; @@ -102,8 +110,10 @@ 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_MarkAllUserSettingsUnsynced_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_UserSettingsSyncBatch_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_SendMessagePair_Request; @@ -180,6 +190,11 @@ public final class JsonHandlerRegistry { Map.entry("GetUserParam", new Net_GetUserParam_Handler()), Map.entry("ListUserParams", new Net_ListUserParams_Handler()), + // --- userSettings --- + Map.entry("UpsertUserSetting", new Net_UpsertUserSetting_Handler()), + Map.entry("GetUserSetting", new Net_GetUserSetting_Handler()), + Map.entry("ListUserSettings", new Net_ListUserSettings_Handler()), + // --- connections --- Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()), Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()), @@ -202,6 +217,8 @@ public final class JsonHandlerRegistry { Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()), Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()), Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()), + Map.entry("UserSettingsSyncBatch", new Net_UserSettingsSyncBatch_Handler()), + Map.entry("MarkAllUserSettingsUnsynced", new Net_MarkAllUserSettingsUnsynced_Handler()), Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()), Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()), Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()), @@ -262,6 +279,11 @@ public final class JsonHandlerRegistry { Map.entry("GetUserParam", Net_GetUserParam_Request.class), Map.entry("ListUserParams", Net_ListUserParams_Request.class), + // --- userSettings --- + Map.entry("UpsertUserSetting", Net_UpsertUserSetting_Request.class), + Map.entry("GetUserSetting", Net_GetUserSetting_Request.class), + Map.entry("ListUserSettings", Net_ListUserSettings_Request.class), + // --- connections --- Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class), Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class), @@ -284,6 +306,8 @@ public final class JsonHandlerRegistry { Map.entry("DeleteMessage", Net_DeleteMessage_Request.class), Map.entry("DeleteConversation", Net_DeleteConversation_Request.class), Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class), + Map.entry("UserSettingsSyncBatch", Net_UserSettingsSyncBatch_Request.class), + Map.entry("MarkAllUserSettingsUnsynced", Net_MarkAllUserSettingsUnsynced_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/handlers/channels/ChannelsReadSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index 0c59bfd1..01d5e866 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -144,6 +144,40 @@ final class ChannelsReadSupport { } } + static String userSettingsChannelKey(String ownerBch, String channelName) { + String bch = ownerBch == null ? "" : ownerBch.trim(); + String name = channelName == null ? "" : channelName.trim(); + return bch + "/" + name; + } + + static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException { + if (viewerLogin == null || viewerLogin.isBlank()) return 0; + String key = userSettingsChannelKey(ownerBch, channelName); + String sql = """ + SELECT value_num + FROM user_settings + WHERE LOWER(login) = LOWER(?) + AND setting_type = ? + AND setting_key = ? + LIMIT 1 + """; + long lastSeen = messagesCount; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setInt(2, 1); + ps.setString(3, key); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + long value = rs.getLong("value_num"); + if (!rs.wasNull()) lastSeen = value; + } + } + } + if (lastSeen < 0) lastSeen = 0; + if (lastSeen > messagesCount) return 0; + return Math.max(0, messagesCount - (int) lastSeen); + } + static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException { String sql = """ SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java index 2874e982..b203ba94 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java @@ -82,7 +82,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler { row.setChannel(channelRef); row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber)); - row.setUnreadCount(0); + row.setUnreadCount(ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount())); ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber); if (lastPost != null) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_GetUserSetting_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_GetUserSetting_Handler.java new file mode 100644 index 00000000..ea03979b --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_GetUserSetting_Handler.java @@ -0,0 +1,64 @@ +package server.logic.ws_protocol.JSON.handlers.userSettings; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Request; +import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; +import shine.db.dao.CurrentUsersDAO; +import shine.db.dao.UserSettingsDAO; +import shine.db.entities.CurrentUserEntry; +import shine.db.entities.UserSettingEntry; + +import java.sql.Connection; + +public class Net_GetUserSetting_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_GetUserSetting_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetUserSetting_Request req = (Net_GetUserSetting_Request) baseRequest; + if (req.getLogin() == null || req.getLogin().isBlank() + || req.getSetting_type() == null + || req.getSetting_key() == null || req.getSetting_key().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Некорректные поля: login/setting_type/setting_key"); + } + + try (Connection c = DbController.getInstance().getConnection()) { + String login = CurrentUsersDAO.getInstance().getByLogin(c, req.getLogin().trim()) != null + ? req.getLogin().trim() + : null; + if (login == null) { + return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); + } + UserSettingEntry entry = UserSettingsDAO.getInstance().getByLoginTypeKey(c, login, req.getSetting_type(), req.getSetting_key().trim()); + if (entry == null) { + return NetExceptionResponseFactory.error(req, 404, "SETTING_NOT_FOUND", "Настройка не найдена"); + } + + Net_GetUserSetting_Response resp = new Net_GetUserSetting_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setLogin(entry.getLogin()); + resp.setSetting_type(entry.getSettingType()); + resp.setSetting_key(entry.getSettingKey()); + resp.setTime_ms(entry.getTimeMs()); + resp.setValue_text(entry.getValueText()); + resp.setValue_num(entry.getValueNum()); + resp.setClient_key(entry.getClientKey()); + resp.setSignature(entry.getSignature()); + resp.setSynced(entry.isSynced()); + return resp; + } catch (Exception e) { + log.error("GetUserSetting failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера"); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_ListUserSettings_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_ListUserSettings_Handler.java new file mode 100644 index 00000000..7637612f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_ListUserSettings_Handler.java @@ -0,0 +1,68 @@ +package server.logic.ws_protocol.JSON.handlers.userSettings; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Request; +import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; +import shine.db.dao.CurrentUsersDAO; +import shine.db.dao.UserSettingsDAO; +import shine.db.entities.CurrentUserEntry; +import shine.db.entities.UserSettingEntry; + +import java.sql.Connection; +import java.util.ArrayList; +import java.util.List; + +public class Net_ListUserSettings_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_ListUserSettings_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_ListUserSettings_Request req = (Net_ListUserSettings_Request) baseRequest; + if (req.getLogin() == null || req.getLogin().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Некорректные поля: login"); + } + + try (Connection c = DbController.getInstance().getConnection()) { + String login = req.getLogin().trim(); + CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(c, login); + if (user == null) { + return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); + } + + List entries = UserSettingsDAO.getInstance().getByLogin(c, login); + List items = new ArrayList<>(); + for (UserSettingEntry e : entries) { + Net_ListUserSettings_Response.Item item = new Net_ListUserSettings_Response.Item(); + item.setLogin(e.getLogin()); + item.setSetting_type(e.getSettingType()); + item.setSetting_key(e.getSettingKey()); + item.setTime_ms(e.getTimeMs()); + item.setValue_text(e.getValueText()); + item.setValue_num(e.getValueNum()); + item.setClient_key(e.getClientKey()); + item.setSignature(e.getSignature()); + item.setSynced(e.isSynced()); + items.add(item); + } + + Net_ListUserSettings_Response resp = new Net_ListUserSettings_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setLogin(user.getLogin()); + resp.setSettings(items); + return resp; + } catch (Exception e) { + log.error("ListUserSettings failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера"); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java new file mode 100644 index 00000000..b523a2a7 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java @@ -0,0 +1,157 @@ +package server.logic.ws_protocol.JSON.handlers.userSettings; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.Base64Ws; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request; +import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import server.sync.RemoteUserSettingsSyncClient; +import shine.db.DbController; +import shine.db.dao.CurrentUsersDAO; +import shine.db.dao.UserAccessServersCurrentDAO; +import shine.db.dao.UserSettingsDAO; +import shine.db.entities.CurrentUserEntry; +import shine.db.entities.UserAccessServerRouteEntry; +import shine.db.entities.UserSettingEntry; +import utils.config.ShineSignatureConstants; +import utils.config.AppConfig; +import utils.crypto.Ed25519Util; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; + +public class Net_UpsertUserSetting_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_UpsertUserSetting_Handler.class); + private static final RemoteUserSettingsSyncClient REMOTE = new RemoteUserSettingsSyncClient(); + private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login"; + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_UpsertUserSetting_Request req = (Net_UpsertUserSetting_Request) baseRequest; + if (req.getLogin() == null || req.getLogin().isBlank() + || req.getSetting_type() == null + || req.getSetting_key() == null || req.getSetting_key().isBlank() + || req.getTime_ms() == null || req.getTime_ms() <= 0 + || req.getClient_key() == null || req.getClient_key().isBlank() + || req.getSignature() == null || req.getSignature().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", + "Некорректные поля: login/setting_type/setting_key/time_ms/client_key/signature"); + } + + String login = req.getLogin().trim(); + int settingType = req.getSetting_type(); + String settingKey = req.getSetting_key().trim(); + long timeMs = req.getTime_ms(); + String valueText = req.getValue_text() == null ? "" : req.getValue_text(); + long valueNum = req.getValue_num() == null ? 0L : req.getValue_num(); + String clientKeyB64 = req.getClient_key().trim(); + String signatureB64 = req.getSignature().trim(); + boolean syncDelivery = Boolean.TRUE.equals(req.getSync_delivery()); + + try { + byte[] pubKey32; + byte[] sig64; + try { + pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key"); + sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature"); + } catch (IllegalArgumentException e) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_BASE64", "client_key/signature должны быть Base64"); + } + + String signText = ShineSignatureConstants.USER_SETTINGS_PREFIX + + escapePart(login) + '|' + + settingType + '|' + + escapePart(settingKey) + '|' + + timeMs + '|' + + escapePart(valueText) + '|' + + valueNum; + + if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) { + return NetExceptionResponseFactory.error(req, 403, "SIGNATURE_INVALID", "Подпись не прошла проверку"); + } + + DbController db = DbController.getInstance(); + CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance(); + UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance(); + + try (Connection c = db.getConnection()) { + CurrentUserEntry user = usersDAO.getByLogin(c, login); + if (user == null) { + return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); + } + String userClientKey = user.getClientKey(); + if (userClientKey == null || userClientKey.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "USER_DEVICE_KEY_EMPTY", "У пользователя не задан clientKey в БД"); + } + if (!userClientKey.trim().equals(clientKeyB64)) { + return NetExceptionResponseFactory.error(req, 403, "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю"); + } + + UserSettingEntry entry = new UserSettingEntry( + login, + settingType, + settingKey, + timeMs, + valueText, + valueNum, + clientKeyB64, + signatureB64, + syncDelivery + ); + int changed = settingsDAO.upsertIfNewer(c, entry); + + if (!syncDelivery && changed > 0) { + int delivered = 0; + String ownServerLogin = String.valueOf(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG) == null ? "" : AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)).trim(); + List routes = UserAccessServersCurrentDAO.getInstance().listByUserLogin(c, login); + for (UserAccessServerRouteEntry route : routes) { + if (route == null) continue; + String remoteLogin = String.valueOf(route.getServerLogin() == null ? "" : route.getServerLogin()).trim(); + String remoteUrl = String.valueOf(route.getServerUrl() == null ? "" : route.getServerUrl()).trim(); + if (remoteLogin.isBlank() || remoteUrl.isBlank()) continue; + if (!ownServerLogin.isBlank() && remoteLogin.equalsIgnoreCase(ownServerLogin)) continue; + try { + REMOTE.upsertUserSetting(remoteUrl, entry, true); + delivered++; + } catch (Exception e) { + log.warn("user_settings immediate sync failed: login={} remoteServer={} reason={}", login, remoteLogin, String.valueOf(e)); + } + } + if (delivered > 0 || routes.isEmpty()) { + settingsDAO.markSynced(c, login, settingType, settingKey); + } + } + + Net_UpsertUserSetting_Response resp = new Net_UpsertUserSetting_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setLogin(login); + resp.setSetting_type(settingType); + resp.setSetting_key(settingKey); + resp.setTime_ms(timeMs); + resp.setSynced(syncDelivery || changed == 0); + return resp; + } + } catch (SQLException e) { + log.error("UpsertUserSetting DB error", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "DB_ERROR", "Ошибка БД"); + } catch (Exception e) { + log.error("UpsertUserSetting failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при UpsertUserSetting", e)); + } + } + + private static String escapePart(String value) { + String s = String.valueOf(value == null ? "" : value); + return s.replace("\\", "\\\\").replace("|", "\\|"); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_GetUserSetting_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_GetUserSetting_Request.java new file mode 100644 index 00000000..e7bd113f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_GetUserSetting_Request.java @@ -0,0 +1,18 @@ +package server.logic.ws_protocol.JSON.handlers.userSettings.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetUserSetting_Request extends Net_Request { + private String login; + private Integer setting_type; + private String setting_key; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public Integer getSetting_type() { return setting_type; } + public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; } + + public String getSetting_key() { return setting_key; } + public void setSetting_key(String setting_key) { this.setting_key = setting_key; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_GetUserSetting_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_GetUserSetting_Response.java new file mode 100644 index 00000000..aadbbcda --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_GetUserSetting_Response.java @@ -0,0 +1,42 @@ +package server.logic.ws_protocol.JSON.handlers.userSettings.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_GetUserSetting_Response extends Net_Response { + private String login; + private Integer setting_type; + private String setting_key; + private Long time_ms; + private String value_text; + private Long value_num; + private String client_key; + private String signature; + private Boolean synced; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public Integer getSetting_type() { return setting_type; } + public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; } + + public String getSetting_key() { return setting_key; } + public void setSetting_key(String setting_key) { this.setting_key = setting_key; } + + public Long getTime_ms() { return time_ms; } + public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } + + public String getValue_text() { return value_text; } + public void setValue_text(String value_text) { this.value_text = value_text; } + + public Long getValue_num() { return value_num; } + public void setValue_num(Long value_num) { this.value_num = value_num; } + + public String getClient_key() { return client_key; } + public void setClient_key(String client_key) { this.client_key = client_key; } + + public String getSignature() { return signature; } + public void setSignature(String signature) { this.signature = signature; } + + public Boolean getSynced() { return synced; } + public void setSynced(Boolean synced) { this.synced = synced; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_ListUserSettings_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_ListUserSettings_Request.java new file mode 100644 index 00000000..2c442f2c --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_ListUserSettings_Request.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.handlers.userSettings.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_ListUserSettings_Request extends Net_Request { + private String login; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_ListUserSettings_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_ListUserSettings_Response.java new file mode 100644 index 00000000..55a1fab6 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_ListUserSettings_Response.java @@ -0,0 +1,48 @@ +package server.logic.ws_protocol.JSON.handlers.userSettings.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_ListUserSettings_Response extends Net_Response { + private String login; + private List settings = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public List getSettings() { return settings; } + public void setSettings(List settings) { this.settings = settings; } + + public static class Item { + private String login; + private Integer setting_type; + private String setting_key; + private Long time_ms; + private String value_text; + private Long value_num; + private String client_key; + private String signature; + private Boolean synced; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public Integer getSetting_type() { return setting_type; } + public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; } + public String getSetting_key() { return setting_key; } + public void setSetting_key(String setting_key) { this.setting_key = setting_key; } + public Long getTime_ms() { return time_ms; } + public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } + public String getValue_text() { return value_text; } + public void setValue_text(String value_text) { this.value_text = value_text; } + public Long getValue_num() { return value_num; } + public void setValue_num(Long value_num) { this.value_num = value_num; } + public String getClient_key() { return client_key; } + public void setClient_key(String client_key) { this.client_key = client_key; } + public String getSignature() { return signature; } + public void setSignature(String signature) { this.signature = signature; } + public Boolean getSynced() { return synced; } + public void setSynced(Boolean synced) { this.synced = synced; } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Request.java new file mode 100644 index 00000000..5c551156 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Request.java @@ -0,0 +1,42 @@ +package server.logic.ws_protocol.JSON.handlers.userSettings.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_UpsertUserSetting_Request extends Net_Request { + private String login; + private Integer setting_type; + private String setting_key; + private Long time_ms; + private String value_text; + private Long value_num; + private String client_key; + private String signature; + private Boolean sync_delivery; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public Integer getSetting_type() { return setting_type; } + public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; } + + public String getSetting_key() { return setting_key; } + public void setSetting_key(String setting_key) { this.setting_key = setting_key; } + + public Long getTime_ms() { return time_ms; } + public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } + + public String getValue_text() { return value_text; } + public void setValue_text(String value_text) { this.value_text = value_text; } + + public Long getValue_num() { return value_num; } + public void setValue_num(Long value_num) { this.value_num = value_num; } + + public String getClient_key() { return client_key; } + public void setClient_key(String client_key) { this.client_key = client_key; } + + public String getSignature() { return signature; } + public void setSignature(String signature) { this.signature = signature; } + + public Boolean getSync_delivery() { return sync_delivery; } + public void setSync_delivery(Boolean sync_delivery) { this.sync_delivery = sync_delivery; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Response.java new file mode 100644 index 00000000..2d0ad5cc --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/entyties/Net_UpsertUserSetting_Response.java @@ -0,0 +1,26 @@ +package server.logic.ws_protocol.JSON.handlers.userSettings.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_UpsertUserSetting_Response extends Net_Response { + private String login; + private Integer setting_type; + private String setting_key; + private Long time_ms; + private Boolean synced; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public Integer getSetting_type() { return setting_type; } + public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; } + + public String getSetting_key() { return setting_key; } + public void setSetting_key(String setting_key) { this.setting_key = setting_key; } + + public Long getTime_ms() { return time_ms; } + public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } + + public Boolean getSynced() { return synced; } + public void setSynced(Boolean synced) { this.synced = synced; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_MarkAllUserSettingsUnsynced_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_MarkAllUserSettingsUnsynced_Handler.java new file mode 100644 index 00000000..f05cc99a --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_MarkAllUserSettingsUnsynced_Handler.java @@ -0,0 +1,42 @@ +package server.logic.ws_protocol.JSON.messages; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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_MarkAllUserSettingsUnsynced_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_MarkAllUserSettingsUnsynced_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; +import shine.db.dao.UserSettingsDAO; + +import java.sql.Connection; + +public class Net_MarkAllUserSettingsUnsynced_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_MarkAllUserSettingsUnsynced_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_MarkAllUserSettingsUnsynced_Request req = (Net_MarkAllUserSettingsUnsynced_Request) baseRequest; + try (Connection c = DbController.getInstance().getConnection()) { + int updated; + if (req.getLogin() == null || req.getLogin().isBlank()) { + updated = UserSettingsDAO.getInstance().markAllUnsynced(c); + } else { + updated = UserSettingsDAO.getInstance().markAllUnsynced(c, req.getLogin().trim()); + } + Net_MarkAllUserSettingsUnsynced_Response resp = new Net_MarkAllUserSettingsUnsynced_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setUpdated(updated); + return resp; + } catch (Exception e) { + log.error("MarkAllUserSettingsUnsynced failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера"); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_UserSettingsSyncBatch_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_UserSettingsSyncBatch_Handler.java new file mode 100644 index 00000000..2d633fa5 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_UserSettingsSyncBatch_Handler.java @@ -0,0 +1,129 @@ +package server.logic.ws_protocol.JSON.messages; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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_UserSettingsSyncBatch_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_UserSettingsSyncBatch_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; +import shine.db.dao.UserAccessServersCurrentDAO; +import shine.db.dao.UserSettingsDAO; +import shine.db.entities.UserSettingEntry; +import shine.db.entities.UserAccessServerRouteEntry; +import utils.config.AppConfig; + +import java.sql.Connection; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +public class Net_UserSettingsSyncBatch_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_UserSettingsSyncBatch_Handler.class); + private static final int DEFAULT_LIMIT = 500; + private static final int MAX_LIMIT = 1000; + 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_UserSettingsSyncBatch_Request req = (Net_UserSettingsSyncBatch_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 afterTimeMs = Math.max(0L, req.getAfterTimeMs() == null ? 0L : req.getAfterTimeMs()); + String afterSettingKey = req.getAfterSettingKey() == null ? "" : req.getAfterSettingKey().trim(); + + List batch; + try (Connection c = DbController.getInstance().getConnection()) { + batch = UserSettingsDAO.getInstance().listNewerThan( + c, + ownerLogin, + afterTimeMs, + afterSettingKey, + limit + ); + } + + Net_UserSettingsSyncBatch_Response resp = new Net_UserSettingsSyncBatch_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setOwnerLogin(ownerLogin); + resp.setLimit(limit); + + int rawBytes = 0; + List items = new ArrayList<>(); + for (UserSettingEntry entry : batch) { + Net_UserSettingsSyncBatch_Response.Item item = new Net_UserSettingsSyncBatch_Response.Item(); + item.setLogin(entry.getLogin()); + item.setSetting_type(entry.getSettingType()); + item.setSetting_key(entry.getSettingKey()); + item.setTime_ms(entry.getTimeMs()); + item.setValue_text(entry.getValueText()); + item.setValue_num(entry.getValueNum()); + item.setClient_key(entry.getClientKey()); + item.setSignature(entry.getSignature()); + item.setSynced(entry.isSynced()); + items.add(item); + rawBytes += String.valueOf(entry.getLogin()).length() + + String.valueOf(entry.getSettingKey()).length() + + String.valueOf(entry.getValueText()).length() + + String.valueOf(entry.getClientKey() == null ? "" : entry.getClientKey()).length() + + String.valueOf(entry.getSignature() == null ? "" : entry.getSignature()).length() + + 64; + if (rawBytes > maxBytes) break; + resp.setNextTimeMs(entry.getTimeMs()); + resp.setNextSettingKey(entry.getSettingKey()); + } + resp.setRawBytes(rawBytes); + resp.setHasMore(batch.size() > items.size()); + resp.setItems(items); + return resp; + } + + private boolean isLocalAccessServer(String ownerLogin, String ownServerLogin) throws Exception { + try (Connection c = DbController.getInstance().getConnection()) { + for (UserAccessServerRouteEntry route : UserAccessServersCurrentDAO.getInstance().listByUserLogin(c, 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/UserSettingsSyncApplySupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/UserSettingsSyncApplySupport.java new file mode 100644 index 00000000..f591524b --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/UserSettingsSyncApplySupport.java @@ -0,0 +1,61 @@ +package server.logic.ws_protocol.JSON.messages; + +import server.logic.ws_protocol.JSON.messages.entyties.Net_UserSettingsSyncBatch_Response; +import shine.db.dao.UserSettingsDAO; +import shine.db.entities.UserSettingEntry; + +import java.sql.Connection; +import java.util.ArrayList; +import java.util.List; + +public final class UserSettingsSyncApplySupport { + private UserSettingsSyncApplySupport() {} + + public static ApplyResult applySyncedItem(Connection c, String ownerLogin, Net_UserSettingsSyncBatch_Response.Item item) throws Exception { + if (item == null) return new ApplyResult(false, "empty_item"); + String login = normalize(item.getLogin()); + String key = normalize(item.getSetting_key()); + if (login == null || key == null) return new ApplyResult(false, "bad_item"); + + UserSettingEntry entry = new UserSettingEntry( + login, + item.getSetting_type() == null ? 0 : item.getSetting_type(), + key, + item.getTime_ms() == null ? 0L : item.getTime_ms(), + item.getValue_text() == null ? "" : item.getValue_text(), + item.getValue_num() == null ? 0L : item.getValue_num(), + item.getClient_key(), + item.getSignature(), + true + ); + int changed = UserSettingsDAO.getInstance().upsertIfNewer(c, entry); + return new ApplyResult(changed > 0, changed > 0 ? "applied" : "ignored"); + } + + public static List toEntries(List items) { + List out = new ArrayList<>(); + if (items == null) return out; + for (Net_UserSettingsSyncBatch_Response.Item item : items) { + if (item == null) continue; + out.add(new UserSettingEntry( + normalize(item.getLogin()), + item.getSetting_type() == null ? 0 : item.getSetting_type(), + normalize(item.getSetting_key()), + item.getTime_ms() == null ? 0L : item.getTime_ms(), + item.getValue_text() == null ? "" : item.getValue_text(), + item.getValue_num() == null ? 0L : item.getValue_num(), + item.getClient_key(), + item.getSignature(), + true + )); + } + return out; + } + + private static String normalize(String value) { + String s = String.valueOf(value == null ? "" : value).trim(); + return s.isEmpty() ? null : s; + } + + public record ApplyResult(boolean applied, String status) {} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Request.java new file mode 100644 index 00000000..2e1ca6e7 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Request.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_MarkAllUserSettingsUnsynced_Request extends Net_Request { + private String login; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Response.java new file mode 100644 index 00000000..0b5c7f8d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_MarkAllUserSettingsUnsynced_Response.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_MarkAllUserSettingsUnsynced_Response extends Net_Response { + private Integer updated; + + public Integer getUpdated() { return updated; } + public void setUpdated(Integer updated) { this.updated = updated; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Request.java new file mode 100644 index 00000000..c359b46f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Request.java @@ -0,0 +1,26 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_UserSettingsSyncBatch_Request extends Net_Request { + private String ownerLogin; + private Long afterTimeMs; + private String afterSettingKey; + private Integer limit; + private Integer maxBytes; + + public String getOwnerLogin() { return ownerLogin; } + public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; } + + public Long getAfterTimeMs() { return afterTimeMs; } + public void setAfterTimeMs(Long afterTimeMs) { this.afterTimeMs = afterTimeMs; } + + public String getAfterSettingKey() { return afterSettingKey; } + public void setAfterSettingKey(String afterSettingKey) { this.afterSettingKey = afterSettingKey; } + + 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_UserSettingsSyncBatch_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_Response.java new file mode 100644 index 00000000..c1a23f58 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_UserSettingsSyncBatch_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_UserSettingsSyncBatch_Response extends Net_Response { + private String ownerLogin; + private Integer limit; + private Integer rawBytes; + private Boolean hasMore; + private Long nextTimeMs; + private String nextSettingKey; + private List items = new ArrayList<>(); + + public String getOwnerLogin() { return ownerLogin; } + public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; } + public Integer getLimit() { return limit; } + public void setLimit(Integer limit) { this.limit = limit; } + public Integer getRawBytes() { return rawBytes; } + public void setRawBytes(Integer rawBytes) { this.rawBytes = rawBytes; } + public Boolean getHasMore() { return hasMore; } + public void setHasMore(Boolean hasMore) { this.hasMore = hasMore; } + public Long getNextTimeMs() { return nextTimeMs; } + public void setNextTimeMs(Long nextTimeMs) { this.nextTimeMs = nextTimeMs; } + public String getNextSettingKey() { return nextSettingKey; } + public void setNextSettingKey(String nextSettingKey) { this.nextSettingKey = nextSettingKey; } + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } + + public static class Item { + private String login; + private Integer setting_type; + private String setting_key; + private Long time_ms; + private String value_text; + private Long value_num; + private String client_key; + private String signature; + private Boolean synced; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public Integer getSetting_type() { return setting_type; } + public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; } + public String getSetting_key() { return setting_key; } + public void setSetting_key(String setting_key) { this.setting_key = setting_key; } + public Long getTime_ms() { return time_ms; } + public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } + public String getValue_text() { return value_text; } + public void setValue_text(String value_text) { this.value_text = value_text; } + public Long getValue_num() { return value_num; } + public void setValue_num(Long value_num) { this.value_num = value_num; } + public String getClient_key() { return client_key; } + public void setClient_key(String client_key) { this.client_key = client_key; } + public String getSignature() { return signature; } + public void setSignature(String signature) { this.signature = signature; } + public Boolean getSynced() { return synced; } + public void setSynced(Boolean synced) { this.synced = synced; } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteUserSettingsSyncClient.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteUserSettingsSyncClient.java new file mode 100644 index 00000000..f2e545b2 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteUserSettingsSyncClient.java @@ -0,0 +1,209 @@ +package server.sync; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import shine.db.entities.UserSettingEntry; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +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; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public final class RemoteUserSettingsSyncClient { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .build(); + + public void upsertUserSetting(String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery) throws Exception { + JsonNode response = send(serverAddressRaw, """ + { + "op":"UpsertUserSetting", + "requestId":%s, + "payload":{ + "login":%s, + "setting_type":%d, + "setting_key":%s, + "time_ms":%d, + "value_text":%s, + "value_num":%d, + "client_key":%s, + "signature":%s, + "sync_delivery":%s + } + } + """.formatted( + "%s", + MAPPER.writeValueAsString(entry.getLogin()), + entry.getSettingType(), + MAPPER.writeValueAsString(entry.getSettingKey()), + entry.getTimeMs(), + MAPPER.writeValueAsString(entry.getValueText() == null ? "" : entry.getValueText()), + entry.getValueNum(), + MAPPER.writeValueAsString(entry.getClientKey() == null ? "" : entry.getClientKey()), + MAPPER.writeValueAsString(entry.getSignature() == null ? "" : entry.getSignature()), + syncDelivery ? "true" : "false" + )); + ensureOk("UpsertUserSetting", response); + } + + public RemoteUserSettingsBatch userSettingsSyncBatch( + String serverAddressRaw, + String ownerLogin, + long afterTimeMs, + String afterSettingKey, + int limit, + int maxBytes + ) throws Exception { + JsonNode response = send(serverAddressRaw, """ + { + "op":"UserSettingsSyncBatch", + "requestId":%s, + "payload":{ + "ownerLogin":%s, + "afterTimeMs":%d, + "afterSettingKey":%s, + "limit":%d, + "maxBytes":%d + } + } + """.formatted( + "%s", + MAPPER.writeValueAsString(ownerLogin), + Math.max(0L, afterTimeMs), + MAPPER.writeValueAsString(afterSettingKey == null ? "" : afterSettingKey), + limit, + maxBytes + )); + ensureOk("UserSettingsSyncBatch", 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 RemoteUserSettingsItem( + item.path("login").asText(""), + item.path("setting_type").asInt(0), + item.path("setting_key").asText(""), + item.path("time_ms").asLong(0L), + item.path("value_text").asText(""), + item.path("value_num").asLong(0L), + item.path("client_key").asText(""), + item.path("signature").asText("") + )); + } + } + return new RemoteUserSettingsBatch( + payload.path("nextTimeMs").asLong(afterTimeMs), + payload.path("nextSettingKey").asText(afterSettingKey == null ? "" : afterSettingKey), + payload.path("hasMore").asBoolean(false), + items + ); + } + + private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception { + String requestId = MAPPER.writeValueAsString("user-settings-sync-" + UUID.randomUUID()); + String json = jsonTemplate.formatted(requestId); + String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw); + if (wsUrl == null) { + throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw); + } + + CompletableFuture responseFuture = new CompletableFuture<>(); + CountDownLatch openLatch = new CountDownLatch(1); + SyncWsListener listener = new SyncWsListener(responseFuture, openLatch); + + WebSocket webSocket = HTTP.newWebSocketBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .buildAsync(URI.create(wsUrl), listener) + .get(8, TimeUnit.SECONDS); + + if (!openLatch.await(8, TimeUnit.SECONDS)) { + tryAbort(webSocket); + throw new TimeoutException("WS open timeout"); + } + + webSocket.sendText(json, true).get(8, TimeUnit.SECONDS); + String responseJson = responseFuture.get(12, TimeUnit.SECONDS); + tryAbort(webSocket); + return MAPPER.readTree(responseJson); + } + + private void ensureOk(String op, JsonNode response) { + int status = response.path("status").asInt(500); + if (status >= 200 && status < 300) return; + String code = response.path("code").asText(""); + if (code.isBlank()) code = response.path("error").asText(""); + throw new IllegalStateException(op + " failed: status=" + status + " code=" + code); + } + + private static void tryAbort(WebSocket webSocket) { + try { + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); + } catch (Exception ignored) { + } + try { + webSocket.abort(); + } catch (Exception ignored) { + } + } + + public record RemoteUserSettingsBatch( + long nextTimeMs, + String nextSettingKey, + boolean hasMore, + List items + ) {} + + public record RemoteUserSettingsItem( + String login, + int settingType, + String settingKey, + long timeMs, + String valueText, + long valueNum, + String clientKey, + String signature + ) {} + + private static final class SyncWsListener implements WebSocket.Listener { + private final CompletableFuture responseFuture; + private final CountDownLatch openLatch; + private final StringBuilder textBuffer = new StringBuilder(); + + private SyncWsListener(CompletableFuture responseFuture, CountDownLatch openLatch) { + this.responseFuture = responseFuture; + this.openLatch = openLatch; + } + + @Override + public void onOpen(WebSocket webSocket) { + openLatch.countDown(); + webSocket.request(1); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + textBuffer.append(data); + if (last && !responseFuture.isDone()) { + responseFuture.complete(textBuffer.toString()); + } + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + responseFuture.completeExceptionally(error); + } + } +} diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java new file mode 100644 index 00000000..b982a910 --- /dev/null +++ b/SHiNE-server/src/main/java/server/sync/PeriodicUserSettingsSyncService.java @@ -0,0 +1,206 @@ +package server.sync; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import shine.db.dao.UserAccessServersCurrentDAO; +import shine.db.dao.UserSettingsDAO; +import shine.db.dao.UserSettingsSyncPeerStateDAO; +import shine.db.entities.UserAccessServerRouteEntry; +import shine.db.entities.UserSettingEntry; +import shine.db.entities.UserSettingsSyncPeerStateEntry; +import utils.config.AppConfig; + +import java.sql.Connection; +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; + +public final class PeriodicUserSettingsSyncService { + private static final Logger log = LoggerFactory.getLogger(PeriodicUserSettingsSyncService.class); + private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login"; + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static final RemoteUserSettingsSyncClient REMOTE = new RemoteUserSettingsSyncClient(); + private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance(); + private static final UserSettingsDAO SETTINGS_DAO = UserSettingsDAO.getInstance(); + private static final UserSettingsSyncPeerStateDAO STATE_DAO = UserSettingsSyncPeerStateDAO.getInstance(); + + private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "periodic-user-settings-sync"); + t.setDaemon(true); + return t; + } + }); + + private PeriodicUserSettingsSyncService() {} + + public static void startOrLog() { + if (!isEnabled()) { + log.info("Periodic user settings sync disabled by user.settings.sync.enabled=false"); + return; + } + if (!STARTED.compareAndSet(false, true)) return; + long initialDelaySec = configLong("user.settings.sync.initialDelaySeconds", 90L, 0L, 3600L); + long periodHours = configLong("user.settings.sync.periodHours", 6L, 1L, 168L); + EXECUTOR.scheduleWithFixedDelay( + PeriodicUserSettingsSyncService::runCycleSafe, + initialDelaySec, + TimeUnit.HOURS.toSeconds(periodHours), + TimeUnit.SECONDS + ); + log.info("Periodic user settings sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours); + } + + private static void runCycleSafe() { + try { + runCycle(); + } catch (Exception e) { + log.error("Periodic user settings 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 user settings 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 user settings sync skipped: no local access-server users for {}", ownServerLogin); + return; + } + + int syncedPeers = 0; + int appliedItems = 0; + int pushedItems = 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 { + SyncStats stats = syncOwnerWithRemote(ownerLogin, route); + appliedItems += stats.applied(); + pushedItems += stats.pushed(); + syncedPeers++; + } catch (Exception e) { + STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e)); + log.warn("Periodic user settings sync peer failed: owner={} remoteServer={} reason={}", + ownerLogin, route.getServerLogin(), String.valueOf(e)); + } + } + } + + log.info("Periodic user settings sync cycle finished: owners={} syncedPeers={} appliedItems={} pushedItems={}", + owners.size(), syncedPeers, appliedItems, pushedItems); + } + + private static SyncStats syncOwnerWithRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception { + int limit = (int) configLong("user.settings.sync.batchLimit", 500L, 1L, 1000L); + int maxBytes = (int) configLong("user.settings.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L); + int maxPages = (int) configLong("user.settings.sync.maxPagesPerPeer", 50L, 1L, 500L); + + UserSettingsSyncPeerStateEntry state; + try (Connection c = getDbConnection()) { + state = STATE_DAO.getOrCreate(c, ownerLogin, route.getServerLogin(), route.getServerUrl()); + } + long cursorTimeMs = state.getCursorTimeMs(); + String cursorSettingKey = state.getCursorSettingKey() == null ? "" : state.getCursorSettingKey(); + int applied = 0; + int pushed = 0; + boolean bootstrapCompleted = false; + + for (int page = 0; page < maxPages; page++) { + RemoteUserSettingsSyncClient.RemoteUserSettingsBatch batch = REMOTE.userSettingsSyncBatch( + route.getServerUrl(), + ownerLogin, + cursorTimeMs, + cursorSettingKey, + limit, + maxBytes + ); + + try (Connection c = getDbConnection()) { + for (RemoteUserSettingsSyncClient.RemoteUserSettingsItem item : batch.items()) { + UserSettingEntry entry = new UserSettingEntry( + item.login(), + item.settingType(), + item.settingKey(), + item.timeMs(), + item.valueText(), + item.valueNum(), + item.clientKey(), + item.signature(), + true + ); + int changed = SETTINGS_DAO.upsertIfNewer(c, entry); + if (changed > 0) applied++; + } + } + + cursorTimeMs = Math.max(cursorTimeMs, batch.nextTimeMs()); + cursorSettingKey = batch.nextSettingKey() == null ? "" : batch.nextSettingKey(); + bootstrapCompleted = !batch.hasMore(); + STATE_DAO.updateSuccess(ownerLogin, route.getServerLogin(), route.getServerUrl(), cursorTimeMs, cursorSettingKey, bootstrapCompleted); + + if (!batch.hasMore() || batch.items().isEmpty()) break; + } + + try (Connection c = getDbConnection()) { + List unsynced = SETTINGS_DAO.listUnsyncedByLogin(c, ownerLogin, limit); + for (UserSettingEntry entry : unsynced) { + REMOTE.upsertUserSetting(route.getServerUrl(), entry, true); + SETTINGS_DAO.markSynced(c, entry.getLogin(), entry.getSettingType(), entry.getSettingKey()); + pushed++; + } + } + + if (!bootstrapCompleted) { + log.info("Periodic user settings sync peer paused by page limit: owner={} remoteServer={} maxPages={}", + ownerLogin, route.getServerLogin(), maxPages); + } + return new SyncStats(applied, pushed); + } + + private static java.sql.Connection getDbConnection() throws Exception { + return shine.db.DbController.getInstance().getConnection(); + } + + private static boolean isEnabled() { + String raw = AppConfig.getInstance().getParam("user.settings.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; + } + + private record SyncStats(int applied, int pushed) {} +} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 42a9aff2..b504009a 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -9,6 +9,7 @@ import server.debug.DebugApiConfigurator; import server.sync.BlockchainResyncRecoveryOnStartup; import server.sync.PeriodicBlockchainSyncService; import server.sync.PeriodicDmSyncService; +import server.sync.PeriodicUserSettingsSyncService; import server.sync.SolanaUsersSyncStartupService; import server.sync.SyncServersBootstrapService; import utils.config.AppConfig; @@ -104,6 +105,7 @@ public final class WsServer { server.start(); log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); PeriodicDmSyncService.startOrLog(); + PeriodicUserSettingsSyncService.startOrLog(); server.join(); } } diff --git a/TODO/В_отдалённом_Будущем/ДЦ_личную_переписку_дороботать_для_максимальной_надёжности.md b/TODO/Вкладка Личные/ДЦ_личную_переписку_дороботать_для_максимальной_надёжности.md similarity index 100% rename from TODO/В_отдалённом_Будущем/ДЦ_личную_переписку_дороботать_для_максимальной_надёжности.md rename to TODO/Вкладка Личные/ДЦ_личную_переписку_дороботать_для_максимальной_надёжности.md diff --git a/TODO/межсерверные_звонки.md b/TODO/Вкладка Личные/межсерверные_звонки.md similarity index 100% rename from TODO/межсерверные_звонки.md rename to TODO/Вкладка Личные/межсерверные_звонки.md diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index e7d20fac..8d922090 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -51,6 +51,9 @@ | `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя | | `GetUserParam` | `10_User_Params_API.md` | чтение одного параметра пользователя | | `ListUserParams` | `10_User_Params_API.md` | список параметров пользователя | +| `UpsertUserSetting` | `13_User_Settings_API.md` | запись пользовательской настройки | +| `GetUserSetting` | `13_User_Settings_API.md` | чтение одной пользовательской настройки | +| `ListUserSettings` | `13_User_Settings_API.md` | список пользовательских настроек | | `GetFriendsLists` | `11_Connections_API.md` | входящие/исходящие друзья | | `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя | | `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя | @@ -63,6 +66,8 @@ | `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 по курсору | +| `UserSettingsSyncBatch` | `13_User_Settings_API.md` | межсерверная догоняющая синхронизация пользовательских настроек по курсору | +| `MarkAllUserSettingsUnsynced` | `13_User_Settings_API.md` | служебная пометка всех настроек как несинхронизированных | | `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/13_User_Settings_API.md b/docs/API/13_User_Settings_API.md new file mode 100644 index 00000000..dc51a1f9 --- /dev/null +++ b/docs/API/13_User_Settings_API.md @@ -0,0 +1,98 @@ +# API пользовательских настроек + +Этот раздел описывает `user_settings` - отдельное хранилище пользовательских настроек, не связанное с `users_params` и не связанное с legacy DM-таблицами. + +## 1. Назначение + +`user_settings` хранит технические пользовательские настройки, которые должны синхронизироваться между максимум двумя access/sync-серверами пользователя. + +Основной текущий кейс: + +- `setting_type = 1` - курсор прочитанности канала; +- `setting_key = ownerBlockchainName/channelName`; +- `value_num = number of messages already seen in channel`; +- `value_text = ''`. + +Если настройки нет, канал считается просмотренным до конца, то есть unread = `0`. + +## 2. Структура записи + +- `login` - логин владельца настройки; +- `setting_type` - числовой код типа настройки; +- `setting_key` - строковый ключ настройки; +- `time_ms` - время установки значения в миллисекундах; +- `value_text` - строковое значение; +- `value_num` - числовое значение; +- `client_key` - публичный Ed25519 ключ клиента в Base64; +- `signature` - Ed25519 подпись preimage в Base64; +- `synced` - была ли настройка успешно доставлена на второй сервер. + +Уникальность: `(login, setting_type, setting_key)`. +Обновление: только если `time_ms` новее текущего значения. + +## 3. Формат подписи + +Подписывается строка: + +`SHiNe/UserSettings:|login|setting_type|setting_key|time_ms|value_text|value_num` + +Где внутри полей используется экранирование `\` и `|`. + +Подпись создаётся клиентским `client_key`. + +## 4. Операции + +### `UpsertUserSetting` + +Записывает или обновляет настройку пользователя. + +Если запрос пришёл от клиента, сервер: + +- сохраняет запись локально; +- пытается сразу отправить её на доступный sync-сервер; +- если отправка успешна, помечает запись как `synced=true`; +- если нет, оставляет `synced=false`. + +Если запрос пришёл по синхронизации между серверами, используется `sync_delivery=true`, и повторной пересылки дальше не делается. + +### `GetUserSetting` + +Чтение одной настройки по `(login, setting_type, setting_key)`. + +### `ListUserSettings` + +Список всех настроек пользователя. + +### `UserSettingsSyncBatch` + +Внутренний межсерверный batch-эндпоинт. + +- отдаёт настройки, новые относительно курсора; +- используется для bootstrap и догрузки после восстановления; +- применяется только для пользователей, чей сервер есть в `access_servers`. + +### `MarkAllUserSettingsUnsynced` + +Внутренний служебный запрос. + +- помечает все настройки пользователя или все настройки сразу как `synced=false`; +- нужен после добавления нового sync-сервера или при потере локальной БД. + +## 5. Синхронизация + +Синхронизация настроек работает отдельно от DM. + +- локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер; +- если запись пришла с другого сервера, она сохраняется сразу как `synced=true`; +- периодический sync раз в 6 часов проверяет несинхронизированные записи и догружает новые записи по курсору; +- если появляется новый sync-сервер или локальная БД была потеряна, нужно пометить все настройки несинхронизированными и заново догрузить batch с нуля. + +## 6. Текущий UI-кейс + +UI при открытии канала отправляет `UpsertUserSetting` с: + +- `setting_type = 1`; +- `setting_key = ownerBlockchainName/channelName`; +- `value_num = количество уже просмотренных сообщений в канале`. + +Это значение используется сервером для расчёта unread в списке каналов и в канале. diff --git a/docs/Blockchain/sync-between-servers.md b/docs/Blockchain/sync-between-servers.md index 07d831b5..6c08aac4 100644 --- a/docs/Blockchain/sync-between-servers.md +++ b/docs/Blockchain/sync-between-servers.md @@ -19,6 +19,10 @@ Каждый сервер регистрирует в своей Solana PDA список `sync_servers` — логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется. +Важно: в текущей архитектуре у пользователя одновременно может быть не более +двух sync/access-серверов. Это ограничение считается обязательным для runtime-логики +`synced` и пользовательских курсоров. + - Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера. - Адрес каждого партнёрского сервера читается из его PDA на Solana. - Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`. @@ -39,6 +43,13 @@ - Порядок блоков сохраняется (по глобальному номеру блока и хэшу). - Дедупликация по глобальному номеру блока и хэшу. +### 3.3 Пользовательские настройки + +- Отдельная таблица `user_settings`. +- Синхронизируются технические настройки пользователя, включая курсор прочитанности каналов. +- Для текущего UI-кейса хранится `setting_type = 1` и `setting_key = ownerBlockchainName/channelName`. +- Синхронизация идёт с учётом `time_ms` и флага `synced`. + ## 4. Текущая реализованная схема На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов. diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 9344210d..d665a9a0 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -234,6 +234,12 @@ function buildThreadRoute(messageRef, selector) { }); } +function buildChannelSettingsKey(selector, channelName) { + const ownerBch = String(selector?.ownerBlockchainName || '').trim(); + const name = String(channelName || '').trim(); + return `${ownerBch}/${name}`; +} + function firstNonEmptyText(...candidates) { for (const candidate of candidates) { if (typeof candidate !== 'string') continue; @@ -1374,6 +1380,8 @@ async function loadFromApi(route, channelId) { const messages = Array.isArray(payload.messages) ? payload.messages : []; let reverseChannelMissingWarning = ''; let mergedMessages = [...messages]; + const unreadCount = Number(channel?.unreadCount || 0); + const messagesCount = Number(channel?.messagesCount || mergedMessages.length || 0); const currentLogin = currentSessionLogin; const ownerLogin = String(payload.channel?.ownerLogin || '').trim(); @@ -1445,6 +1453,8 @@ async function loadFromApi(route, channelId) { posts, metaEvents: Array.isArray(payload?.metaEvents) ? payload.metaEvents : [], reverseChannelMissingWarning, + unreadCount, + messagesCount, isOwnChannel, isSubscribed, selector, @@ -1905,6 +1915,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { screen.append(reverseWarning); } + if (Number(channelData.unreadCount || 0) > 0) { + const unreadLine = document.createElement('div'); + unreadLine.className = 'card channel-unread-line'; + unreadLine.textContent = `Не прочитано: ${channelData.unreadCount}`; + screen.append(unreadLine); + } + const actionButton = document.createElement('button'); actionButton.className = 'destructive-btn channel-main-action'; actionButton.textContent = 'Подписаться на канал'; @@ -2294,6 +2311,19 @@ export function render({ navigate, route, chrome }) { try { const apiData = await loadFromApi(route, channelId); activeSelector = apiData?.selector || null; + const lastSeenCount = Number(apiData?.messagesCount || (Array.isArray(apiData?.posts) ? apiData.posts.length : 0) || 0); + const settingKey = buildChannelSettingsKey(apiData?.selector, apiData?.channel?.name); + if (settingKey && state.session.login && state.session.storagePwdInMemory) { + void authService.upsertUserSetting({ + login: state.session.login, + settingType: 1, + settingKey, + timeMs: Date.now(), + valueText: '', + valueNum: lastSeenCount, + storagePwd: state.session.storagePwdInMemory, + }).catch(() => {}); + } const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel'; const entrypointPosts = getEntrypointPosts(apiData?.posts); const openEntrypointHistory = () => { diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index c23c4433..ba3dbe0b 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -153,6 +153,10 @@ function makeClientPlatform() { return 'Web'; } +function escapeUserSettingPart(value = '') { + return String(value ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); +} + function hexToBytes(hex) { const clean = String(hex || '').trim().toLowerCase(); if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex'); @@ -2898,6 +2902,59 @@ export class AuthService { return response.payload || {}; } + async upsertUserSetting({ + login, + settingType, + settingKey, + timeMs, + valueText = '', + valueNum = 0, + storagePwd, + syncDelivery = false, + }) { + const cleanLogin = String(login || '').trim(); + const cleanSettingKey = String(settingKey || '').trim(); + const cleanValueText = String(valueText ?? ''); + const cleanTimeMs = Number(timeMs); + const cleanSettingType = Number(settingType); + const cleanValueNum = Number(valueNum ?? 0); + if (!cleanLogin || !cleanSettingKey) throw new Error('Не переданы login/settingKey'); + if (!Number.isFinite(cleanTimeMs) || cleanTimeMs <= 0) throw new Error('Не передан корректный timeMs'); + if (!Number.isFinite(cleanSettingType)) throw new Error('Не передан корректный settingType'); + if (!storagePwd) throw new Error('Не передан storagePwd для подписи UpsertUserSetting.'); + + const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd); + const clientPrivPkcs8 = String(secrets?.clientKey || '').trim(); + if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey'); + const privateKey = await importPkcs8Ed25519(clientPrivPkcs8); + const clientKey = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8); + + const preimage = [ + 'SHiNe/UserSettings:', + escapeUserSettingPart(cleanLogin), + String(cleanSettingType), + escapeUserSettingPart(cleanSettingKey), + String(Math.trunc(cleanTimeMs)), + escapeUserSettingPart(cleanValueText), + String(Math.trunc(cleanValueNum)), + ].join('|'); + const signature = await signBase64(privateKey, preimage); + + const response = await this.ws.request('UpsertUserSetting', { + login: cleanLogin, + setting_type: Math.trunc(cleanSettingType), + setting_key: cleanSettingKey, + time_ms: Math.trunc(cleanTimeMs), + value_text: cleanValueText, + value_num: Math.trunc(cleanValueNum), + client_key: clientKey, + signature, + sync_delivery: !!syncDelivery, + }); + if (response.status !== 200) throw opError('UpsertUserSetting', response); + return response.payload || {}; + } + async getTestFreeAvatarQuota() { const response = await this.ws.request('TestGetFreeAvatarQuota', {}); if (response.status !== 200) throw opError('TestGetFreeAvatarQuota', response); From 5763eb828ee07c946147f7e8cdf2318205f1fc1c9eef55dfd25ef7b4aacd4aa6 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 19 Aug 2026 20:34:41 +0400 Subject: [PATCH 3/5] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=B0=20=D0=B8=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shine-UI/js/pages/channel-view.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index d665a9a0..71c33b29 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -234,8 +234,8 @@ function buildThreadRoute(messageRef, selector) { }); } -function buildChannelSettingsKey(selector, channelName) { - const ownerBch = String(selector?.ownerBlockchainName || '').trim(); +function buildChannelSettingsKey(ownerBlockchainName, channelName) { + const ownerBch = String(ownerBlockchainName || '').trim(); const name = String(channelName || '').trim(); return `${ownerBch}/${name}`; } @@ -1364,6 +1364,8 @@ async function loadFromApi(route, channelId) { if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) { throw new Error('Канал не найден.'); } + unreadCount = Number(channel?.unreadCount || 0); + messagesCount = Number(channel?.messagesCount || mergedMessages.length || 0); selector = { ownerBlockchainName: String(channel.channel.ownerBlockchainName), channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber), @@ -1380,8 +1382,8 @@ async function loadFromApi(route, channelId) { const messages = Array.isArray(payload.messages) ? payload.messages : []; let reverseChannelMissingWarning = ''; let mergedMessages = [...messages]; - const unreadCount = Number(channel?.unreadCount || 0); - const messagesCount = Number(channel?.messagesCount || mergedMessages.length || 0); + let unreadCount = 0; + let messagesCount = mergedMessages.length; const currentLogin = currentSessionLogin; const ownerLogin = String(payload.channel?.ownerLogin || '').trim(); @@ -2312,7 +2314,7 @@ export function render({ navigate, route, chrome }) { const apiData = await loadFromApi(route, channelId); activeSelector = apiData?.selector || null; const lastSeenCount = Number(apiData?.messagesCount || (Array.isArray(apiData?.posts) ? apiData.posts.length : 0) || 0); - const settingKey = buildChannelSettingsKey(apiData?.selector, apiData?.channel?.name); + const settingKey = buildChannelSettingsKey(apiData?.channel?.ownerBlockchainName, apiData?.channel?.name); if (settingKey && state.session.login && state.session.storagePwdInMemory) { void authService.upsertUserSetting({ login: state.session.login, From 4656a03ea999cce2b8c6131a1c41e9d9aceea6234ed8ebf440a41c5d36974e69 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 19 Aug 2026 20:38:03 +0400 Subject: [PATCH 4/5] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B0=D0=B2=D1=82=D0=BE=D1=81=D0=BA=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB=20=D0=B8=20TDZ=20=D0=B2=20=D0=BA=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shine-UI/js/pages/channel-view.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 71c33b29..b30b07dc 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -1255,6 +1255,8 @@ function mapApiMessageToPost(message, selector, localNumber) { async function loadFromApi(route, channelId) { const currentSessionLogin = String(state.session.login || '').trim(); const isAuthorized = !!currentSessionLogin; + let unreadCount = 0; + let messagesCount = 0; let cachedFeed = null; const ensureFeed = async () => { if (cachedFeed) return cachedFeed; @@ -1315,6 +1317,9 @@ async function loadFromApi(route, channelId) { } if (selector?.ownerBlockchainName && selector?.channelName) { + let unreadCount = 0; + let messagesCount = 0; + const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim(); const routeOwnerNormalized = routeOwnerRaw.toLowerCase(); const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw); @@ -1382,8 +1387,7 @@ async function loadFromApi(route, channelId) { const messages = Array.isArray(payload.messages) ? payload.messages : []; let reverseChannelMissingWarning = ''; let mergedMessages = [...messages]; - let unreadCount = 0; - let messagesCount = mergedMessages.length; + if (!messagesCount) messagesCount = mergedMessages.length; const currentLogin = currentSessionLogin; const ownerLogin = String(payload.channel?.ownerLogin || '').trim(); @@ -2014,7 +2018,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { screen.append(feed, backButton); } - applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary); + applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || Number(channelData.unreadCount || 0) === 0); return () => { // noop }; From 8741be6cba70a75a63316ae2438108f4dec23d079092d48d36b028b0dca65ed1 Mon Sep 17 00:00:00 2001 From: peradas Date: Thu, 20 Aug 2026 07:11:55 +0300 Subject: [PATCH 5/5] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D1=83?= =?UTF-8?q?=20=D1=87=D0=B0=D1=82=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=B0=D1=82=D1=83=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shine-UI/index.html | 4 +- shine-UI/js/app.js | 107 ++++++++++++++++- shine-UI/js/pages/chat-view.js | 143 +++++++++++++--------- shine-UI/js/pages/messages-list.js | 118 +++++++++++++++++- shine-UI/styles/components.css | 186 +++++++++++++++++++++++++---- shine-UI/styles/layout.css | 75 ++++++++---- 6 files changed, 529 insertions(+), 104 deletions(-) diff --git a/shine-UI/index.html b/shine-UI/index.html index fea3b7e3..db4c53ad 100644 --- a/shine-UI/index.html +++ b/shine-UI/index.html @@ -4,7 +4,7 @@ @@ -12,7 +12,7 @@ СИЯНИЕ