diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java index eb780c99..3a0ae6e0 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java @@ -98,14 +98,9 @@ public final class MsgSubType { /* ===================== CONNECTION (msg_type=3) ===================== */ /** Добавить в близкие друзья (close friend). */ - public static final short CONNECTION_FRIEND = 10; + public static final short CONNECTION_CLOSE_FRIEND = 10; /** Удалить из близких друзей (close friend). */ - public static final short CONNECTION_UNFRIEND = 11; - - /** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */ - public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND; - /** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */ - public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND; + public static final short CONNECTION_UNCLOSE_FRIEND = 11; /** Добавить в контакты. */ public static final short CONNECTION_CONTACT = 20; diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java index bb7ef404..53445aa8 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java @@ -66,7 +66,7 @@ import java.util.Objects; * toBlockHash32=hash32(CREATE_CHANNEL) * * 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД): - * - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено). + * - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено). * - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала: * разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL). * @@ -183,8 +183,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL private static boolean isValidSubType(short st) { int v = st & 0xFFFF; - return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF) - || v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF) + return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF) + || v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF) || v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF) || v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF) 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 73975a76..05ddfd75 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 @@ -26,6 +26,7 @@ public final class DatabaseInitializer { 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 int SCHEMA_VERSION_10 = 10; 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"; @@ -35,6 +36,7 @@ public final class DatabaseInitializer { 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"; + public static final String POSTGRES_MIGRATION_V10_RESOURCE = "postgres/migration_v10.sql"; private DatabaseInitializer() {} @@ -53,10 +55,8 @@ public final class DatabaseInitializer { public static final short REACTION_LIKE = 1; public static final short REACTION_UNLIKE = 2; - public static final short CONNECTION_FRIEND = 10; - public static final short CONNECTION_UNFRIEND = 11; - public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND; - public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND; + public static final short CONNECTION_CLOSE_FRIEND = 10; + public static final short CONNECTION_UNCLOSE_FRIEND = 11; public static final short CONNECTION_CONTACT = 20; public static final short CONNECTION_UNCONTACT = 21; @@ -130,6 +130,10 @@ public final class DatabaseInitializer { } if (currentVersion < SCHEMA_VERSION_9) { runSqlScript(conn, POSTGRES_MIGRATION_V9_RESOURCE); + currentVersion = SCHEMA_VERSION_9; + } + if (currentVersion < SCHEMA_VERSION_10) { + runSqlScript(conn, POSTGRES_MIGRATION_V10_RESOURCE); } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java index 3cd7456c..3c8a1366 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java @@ -58,23 +58,17 @@ public final class MsgSubType { /* ===================== CONNECTION (msg_type=3) ===================== */ /** * Совпадает с ConnectionBody: - * SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54, + * SET: CLOSE_FRIEND=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54, * KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74 - * UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55, + * UNSET: UNCLOSE_FRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55, * UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75 */ /** Добавить в близкие друзья (close friend). */ - public static final short CONNECTION_FRIEND = 10; + public static final short CONNECTION_CLOSE_FRIEND = 10; /** Удалить из близких друзей (close friend). */ - public static final short CONNECTION_UNFRIEND = 11; - - /** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */ - public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND; - - /** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */ - public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND; + public static final short CONNECTION_UNCLOSE_FRIEND = 11; /** Добавить в контакты. */ public static final short CONNECTION_CONTACT = 20; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java index e11a5fff..2771a743 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java @@ -185,10 +185,10 @@ public final class BlockchainResyncCleanupDAO { private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException { String sql = """ UPDATE message_stats - SET likes_count = MAX( + SET likes_count = GREATEST( 0, - likes_count - ( - SELECT COUNT(*) + likes_count - COALESCE(( + SELECT COUNT(*)::int FROM reactions_state rs WHERE rs.from_bch_name = ? AND rs.reaction_type = ? @@ -198,7 +198,7 @@ public final class BlockchainResyncCleanupDAO { AND rs.to_block_number = message_stats.to_block_number AND rs.to_block_hash = message_stats.to_block_hash AND rs.to_bch_name <> ? - ) + ), 0) ) WHERE EXISTS ( SELECT 1 @@ -237,10 +237,10 @@ public final class BlockchainResyncCleanupDAO { private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException { String sql = """ UPDATE message_stats - SET replies_count = MAX( + SET replies_count = GREATEST( 0, replies_count - COALESCE(( - SELECT COUNT(*) + SELECT COUNT(*)::int FROM blocks b WHERE b.bch_name = ? AND b.msg_type = 1 diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationsStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationsStateDAO.java new file mode 100644 index 00000000..21c800d5 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationsStateDAO.java @@ -0,0 +1,105 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.entities.UserNotificationEntry; + +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; + +public final class UserNotificationsStateDAO { + private static volatile UserNotificationsStateDAO instance; + private final DbController db = DbController.getInstance(); + + private UserNotificationsStateDAO() {} + + public static UserNotificationsStateDAO getInstance() { + if (instance == null) { + synchronized (UserNotificationsStateDAO.class) { + if (instance == null) instance = new UserNotificationsStateDAO(); + } + } + return instance; + } + + public int upsert(Connection c, UserNotificationEntry e) throws SQLException { + String sql = """ + INSERT INTO user_notifications_state ( + owner_login, notification_kind, created_at_ms, source_login, source_bch_name, + source_block_number, source_block_hash, target_login, target_bch_name, + target_block_number, target_block_hash, source_msg_sub_type, source_text + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) DO UPDATE SET + created_at_ms = EXCLUDED.created_at_ms, + source_login = EXCLUDED.source_login, + target_login = EXCLUDED.target_login, + target_bch_name = EXCLUDED.target_bch_name, + target_block_number = EXCLUDED.target_block_number, + target_block_hash = EXCLUDED.target_block_hash, + source_msg_sub_type = EXCLUDED.source_msg_sub_type, + source_text = EXCLUDED.source_text + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + int i = 1; + ps.setString(i++, e.getOwnerLogin()); + ps.setString(i++, e.getNotificationKind()); + ps.setLong(i++, e.getCreatedAtMs()); + ps.setString(i++, e.getSourceLogin()); + ps.setString(i++, e.getSourceBchName()); + ps.setInt(i++, e.getSourceBlockNumber()); + ps.setBytes(i++, e.getSourceBlockHash()); + if (e.getTargetLogin() != null) ps.setString(i++, e.getTargetLogin()); else ps.setNull(i++, Types.VARCHAR); + if (e.getTargetBchName() != null) ps.setString(i++, e.getTargetBchName()); else ps.setNull(i++, Types.VARCHAR); + if (e.getTargetBlockNumber() != null) ps.setInt(i++, e.getTargetBlockNumber()); else ps.setNull(i++, Types.INTEGER); + if (e.getTargetBlockHash() != null) ps.setBytes(i++, e.getTargetBlockHash()); else ps.setNull(i++, Types.BINARY); + ps.setInt(i++, e.getSourceMsgSubType()); + ps.setString(i++, e.getSourceText() == null ? "" : e.getSourceText()); + return ps.executeUpdate(); + } + } + + public List listByOwnerAndKind(Connection c, String ownerLogin, String kind, int limit) throws SQLException { + String sql = """ + SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name, + source_block_number, source_block_hash, target_login, target_bch_name, + target_block_number, target_block_hash, source_msg_sub_type, source_text + FROM user_notifications_state + WHERE owner_login = ? AND notification_kind = ? + ORDER BY created_at_ms DESC, source_block_number DESC + LIMIT ? + """; + List out = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerLogin); + ps.setString(2, kind); + ps.setInt(3, Math.max(1, limit)); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(mapRow(rs)); + } + } + return out; + } + + private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException { + UserNotificationEntry e = new UserNotificationEntry(); + e.setOwnerLogin(rs.getString("owner_login")); + e.setNotificationKind(rs.getString("notification_kind")); + e.setCreatedAtMs(rs.getLong("created_at_ms")); + e.setSourceLogin(rs.getString("source_login")); + e.setSourceBchName(rs.getString("source_bch_name")); + e.setSourceBlockNumber(rs.getInt("source_block_number")); + e.setSourceBlockHash(rs.getBytes("source_block_hash")); + e.setTargetLogin(rs.getString("target_login")); + e.setTargetBchName(rs.getString("target_bch_name")); + Object targetBlockNumber = rs.getObject("target_block_number"); + e.setTargetBlockNumber(targetBlockNumber == null ? null : ((Number) targetBlockNumber).intValue()); + e.setTargetBlockHash(rs.getBytes("target_block_hash")); + e.setSourceMsgSubType(rs.getInt("source_msg_sub_type")); + e.setSourceText(rs.getString("source_text")); + return e; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserNotificationEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserNotificationEntry.java new file mode 100644 index 00000000..2a150d90 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserNotificationEntry.java @@ -0,0 +1,44 @@ +package shine.db.entities; + +public class UserNotificationEntry { + private String ownerLogin; + private String notificationKind; + private long createdAtMs; + private String sourceLogin; + private String sourceBchName; + private int sourceBlockNumber; + private byte[] sourceBlockHash; + private String targetLogin; + private String targetBchName; + private Integer targetBlockNumber; + private byte[] targetBlockHash; + private int sourceMsgSubType; + private String sourceText; + + public String getOwnerLogin() { return ownerLogin; } + public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; } + public String getNotificationKind() { return notificationKind; } + public void setNotificationKind(String notificationKind) { this.notificationKind = notificationKind; } + public long getCreatedAtMs() { return createdAtMs; } + public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } + public String getSourceLogin() { return sourceLogin; } + public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; } + public String getSourceBchName() { return sourceBchName; } + public void setSourceBchName(String sourceBchName) { this.sourceBchName = sourceBchName; } + public int getSourceBlockNumber() { return sourceBlockNumber; } + public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; } + public byte[] getSourceBlockHash() { return sourceBlockHash; } + public void setSourceBlockHash(byte[] sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; } + public String getTargetLogin() { return targetLogin; } + public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; } + public String getTargetBchName() { return targetBchName; } + public void setTargetBchName(String targetBchName) { this.targetBchName = targetBchName; } + public Integer getTargetBlockNumber() { return targetBlockNumber; } + public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; } + public byte[] getTargetBlockHash() { return targetBlockHash; } + public void setTargetBlockHash(byte[] targetBlockHash) { this.targetBlockHash = targetBlockHash; } + public int getSourceMsgSubType() { return sourceMsgSubType; } + public void setSourceMsgSubType(int sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; } + public String getSourceText() { return sourceText; } + public void setSourceText(String sourceText) { this.sourceText = sourceText; } +} diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v10.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v10.sql new file mode 100644 index 00000000..b3c35a17 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v10.sql @@ -0,0 +1,32 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS user_notifications_state ( + owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login), + notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')), + created_at_ms BIGINT NOT NULL, + source_login TEXT NOT NULL, + source_bch_name TEXT NOT NULL, + source_block_number INTEGER NOT NULL CHECK (source_block_number >= 0), + source_block_hash BYTEA NOT NULL, + target_login TEXT, + target_bch_name TEXT, + target_block_number INTEGER, + target_block_hash BYTEA, + source_msg_sub_type INTEGER NOT NULL, + source_text TEXT NOT NULL DEFAULT '', + UNIQUE (owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) +); + +CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_kind_time + ON user_notifications_state(owner_login, notification_kind, created_at_ms DESC, source_block_number DESC); + +CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_time + ON user_notifications_state(owner_login, created_at_ms DESC); + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 10, 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-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 d8aa3e15..3f182a42 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -88,9 +88,11 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscrip import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler; +import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request; import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request; +import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request; import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler; @@ -207,6 +209,7 @@ public final class JsonHandlerRegistry { Map.entry("ListContacts", new Net_ListContacts_Handler()), Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()), Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()), + Map.entry("GetNotifications", new Net_GetNotifications_Handler()), // --- direct messages / push --- Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()), @@ -296,6 +299,7 @@ public final class JsonHandlerRegistry { Map.entry("ListContacts", Net_ListContacts_Request.class), Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class), Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class), + Map.entry("GetNotifications", Net_GetNotifications_Request.class), // --- direct messages / push --- Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 6843ef11..b434eb5f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -5,8 +5,10 @@ import blockchain.BchCryptoVerifier; import blockchain.MsgSubType; import blockchain.body.BodyHasLine; import blockchain.body.BodyHasTarget; +import blockchain.body.ConnectionBody; import blockchain.body.CreateChannelBody; import blockchain.body.StatusActionBody; +import blockchain.body.TextReplyBody; import blockchain.body.TextLineBody; import blockchain.body.UserParamBody; import org.slf4j.Logger; @@ -30,10 +32,12 @@ import shine.db.channels.ChannelNameRules; import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlocksDAO; import shine.db.dao.ChannelNameStateDAO; +import shine.db.dao.UserNotificationsStateDAO; import shine.db.dao.UserParamsDAO; import shine.db.entities.BlockchainStateEntry; import shine.db.entities.BlockEntry; import shine.db.entities.ChannelNameStateEntry; +import shine.db.entities.UserNotificationEntry; import shine.db.entities.UserParamEntry; import utils.blockchain.BlockchainNameUtil; @@ -61,7 +65,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance(); private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService(); - private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO); + private final BlockchainWriter dbWriter = new BlockchainWriter( + blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO, UserNotificationsStateDAO.getInstance()); public Net_AddBlock_Handler() { ChannelNamesStateBootstrapper.bootstrapOrFailFast(); @@ -573,7 +578,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { ); } - dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry); + UserNotificationEntry notificationEntry = buildNotificationEntry(block, be); + dbWriter.appendBlockAndState( + blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry); if (chat200CreateSeed != null) { upsertChat200StateFromCreate(chat200CreateSeed); @@ -855,6 +862,66 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return b; } + private UserNotificationEntry buildNotificationEntry(BchBlockEntry block, BlockEntry storedEntry) { + if (block == null || storedEntry == null) return null; + if (storedEntry.getToLogin() == null || storedEntry.getToLogin().isBlank()) return null; + + int msgType = block.type & 0xFFFF; + int msgSubType = block.subType & 0xFFFF; + long createdAtMs = block.timestamp * 1000L; + String ownerLogin = storedEntry.getToLogin(); + String sourceLogin = storedEntry.getLogin(); + + // A user must never receive a notification for replying to their own message/thread. + if (ownerLogin.equalsIgnoreCase(sourceLogin)) return null; + + if (msgType == 1 + && msgSubType == (MsgSubType.TEXT_REPLY & 0xFFFF) + && block.body instanceof TextReplyBody replyBody) { + UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType); + entry.setNotificationKind("reply"); + entry.setSourceText(replyBody.message == null ? "" : replyBody.message); + return entry; + } + + // Connection notifications are intentionally modeled as a generic kind. + // Today only CONNECTION_CLOSE_FRIEND is indexed; future incoming connection types + // can reuse the same notification kind and expose their code via sourceMsgSubType. + if (msgType == 3 + && msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF) + && block.body instanceof ConnectionBody) { + UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType); + entry.setNotificationKind("connection"); + entry.setSourceText(""); + return entry; + } + + return null; + } + + private UserNotificationEntry baseNotificationEntry(BlockEntry storedEntry, + String ownerLogin, + long createdAtMs, + int msgSubType) { + UserNotificationEntry entry = new UserNotificationEntry(); + entry.setOwnerLogin(ownerLogin); + entry.setCreatedAtMs(createdAtMs); + entry.setSourceLogin(storedEntry.getLogin()); + entry.setSourceBchName(blockchainNameFromEntry(storedEntry)); + entry.setSourceBlockNumber(storedEntry.getBlockNumber()); + entry.setSourceBlockHash(storedEntry.getBlockHash()); + entry.setTargetLogin(storedEntry.getToLogin()); + entry.setTargetBchName(storedEntry.getToBchName()); + entry.setTargetBlockNumber(storedEntry.getToBlockNumber()); + entry.setTargetBlockHash(storedEntry.getToBlockHash()); + entry.setSourceMsgSubType(msgSubType); + return entry; + } + + private String blockchainNameFromEntry(BlockEntry entry) { + return entry == null ? null : entry.getBchName(); + } + private static String toHex(byte[] bytes) { if (bytes == null) return "null"; char[] HEX = "0123456789abcdef".toCharArray(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java index 7c2171b9..72a730d1 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java @@ -5,10 +5,12 @@ import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlocksDAO; import shine.db.dao.ChannelNameStateDAO; import shine.db.dao.UserParamsDAO; +import shine.db.dao.UserNotificationsStateDAO; import shine.db.entities.BlockchainStateEntry; import shine.db.entities.BlockEntry; import shine.db.entities.ChannelNameStateEntry; import shine.db.entities.UserParamEntry; +import shine.db.entities.UserNotificationEntry; import utils.files.FileStoreUtil; import java.io.IOException; @@ -41,16 +43,19 @@ public final class BlockchainWriter { private final BlockchainStateDAO stateDAO; private final ChannelNameStateDAO channelNameStateDAO; private final UserParamsDAO userParamsDAO; + private final UserNotificationsStateDAO userNotificationsStateDAO; private final FileStoreUtil fs = FileStoreUtil.getInstance(); public BlockchainWriter(BlocksDAO blocksDAO, BlockchainStateDAO stateDAO, UserParamsDAO userParamsDAO, - ChannelNameStateDAO channelNameStateDAO) { + ChannelNameStateDAO channelNameStateDAO, + UserNotificationsStateDAO userNotificationsStateDAO) { this.blocksDAO = blocksDAO; this.stateDAO = stateDAO; this.userParamsDAO = userParamsDAO; this.channelNameStateDAO = channelNameStateDAO; + this.userNotificationsStateDAO = userNotificationsStateDAO; } public void appendBlockAndState(String blockchainName, @@ -59,7 +64,8 @@ public final class BlockchainWriter { BlockEntry be, UserParamEntry userParamEntry, ChannelNameStateEntry channelNameStateEntry, - ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException { + ChannelNameStateEntry channelMetaUpdateEntry, + UserNotificationEntry notificationEntry) throws SQLException { long nowMs = System.currentTimeMillis(); byte[] blockBytes = block.toBytes(); @@ -96,6 +102,12 @@ public final class BlockchainWriter { channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry); } + // Notification projection is part of the same SQL transaction as the block. + // If notification indexing fails, the block/state write is rolled back as well. + if (notificationEntry != null) { + userNotificationsStateDAO.upsert(c, notificationEntry); + } + c.commit(); committed = true; } catch (Exception e) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_GetNotifications_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_GetNotifications_Handler.java new file mode 100644 index 00000000..5daaaba8 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_GetNotifications_Handler.java @@ -0,0 +1,94 @@ +package server.logic.ws_protocol.JSON.handlers.notifications; + +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.notifications.entyties.Net_GetNotifications_Request; +import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; +import shine.db.dao.UserNotificationsStateDAO; +import shine.db.entities.UserNotificationEntry; + +import java.sql.Connection; +import java.util.ArrayList; +import java.util.List; + +public final class Net_GetNotifications_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.UNVERIFIED, + "NOT_AUTHENTICATED", + "Операция доступна только для авторизованных пользователей" + ); + } + + String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim(); + if (login.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя"); + } + + int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit())); + + try (Connection c = DbController.getInstance().getConnection()) { + List replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit); + List eventRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "connection", limit); + + Net_GetNotifications_Response resp = new Net_GetNotifications_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setLogin(login); + resp.setReplies(mapRows(replyRows)); + resp.setEvents(mapRows(eventRows)); + return resp; + } catch (Exception e) { + log.error("GetNotifications failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера"); + } + } + + private List mapRows(List rows) { + List out = new ArrayList<>(); + for (UserNotificationEntry row : rows) { + Net_GetNotifications_Response.NotificationItem item = new Net_GetNotifications_Response.NotificationItem(); + item.setKind(row.getNotificationKind()); + item.setCreatedAtMs(row.getCreatedAtMs()); + item.setSourceLogin(row.getSourceLogin()); + item.setSourceBlockchainName(row.getSourceBchName()); + item.setSourceBlockNumber(row.getSourceBlockNumber()); + item.setSourceBlockHash(bytesToHex(row.getSourceBlockHash())); + item.setSourceMsgSubType(row.getSourceMsgSubType()); + item.setConnectionTypeCode("connection".equals(row.getNotificationKind()) ? row.getSourceMsgSubType() : null); + item.setSourceText(row.getSourceText()); + item.setTargetLogin(row.getTargetLogin()); + item.setTargetBlockchainName(row.getTargetBchName()); + item.setTargetBlockNumber(row.getTargetBlockNumber()); + item.setTargetBlockHash(bytesToHex(row.getTargetBlockHash())); + out.add(item); + } + return out; + } + + private static String bytesToHex(byte[] bytes) { + if (bytes == null) return null; + char[] HEX = "0123456789abcdef".toCharArray(); + char[] out = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + int v = bytes[i] & 0xff; + out[i * 2] = HEX[v >>> 4]; + out[i * 2 + 1] = HEX[v & 0x0f]; + } + return new String(out); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Request.java new file mode 100644 index 00000000..4642f08f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Request.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.handlers.notifications.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetNotifications_Request extends Net_Request { + private Integer limit; + + public Integer getLimit() { return limit; } + public void setLimit(Integer limit) { this.limit = limit; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Response.java new file mode 100644 index 00000000..bb0581a2 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Response.java @@ -0,0 +1,62 @@ +package server.logic.ws_protocol.JSON.handlers.notifications.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_GetNotifications_Response extends Net_Response { + private String login; + private List replies = new ArrayList<>(); + private List events = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public List getReplies() { return replies; } + public void setReplies(List replies) { this.replies = replies; } + public List getEvents() { return events; } + public void setEvents(List events) { this.events = events; } + + public static class NotificationItem { + private String kind; + private long createdAtMs; + private String sourceLogin; + private String sourceBlockchainName; + private int sourceBlockNumber; + private String sourceBlockHash; + private Integer sourceMsgSubType; + private Integer connectionTypeCode; + private String sourceText; + private String targetLogin; + private String targetBlockchainName; + private Integer targetBlockNumber; + private String targetBlockHash; + + public String getKind() { return kind; } + public void setKind(String kind) { this.kind = kind; } + public long getCreatedAtMs() { return createdAtMs; } + public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } + public String getSourceLogin() { return sourceLogin; } + public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; } + public String getSourceBlockchainName() { return sourceBlockchainName; } + public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; } + public int getSourceBlockNumber() { return sourceBlockNumber; } + public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; } + public String getSourceBlockHash() { return sourceBlockHash; } + public void setSourceBlockHash(String sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; } + public Integer getSourceMsgSubType() { return sourceMsgSubType; } + public void setSourceMsgSubType(Integer sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; } + public Integer getConnectionTypeCode() { return connectionTypeCode; } + public void setConnectionTypeCode(Integer connectionTypeCode) { this.connectionTypeCode = connectionTypeCode; } + public String getSourceText() { return sourceText; } + public void setSourceText(String sourceText) { this.sourceText = sourceText; } + public String getTargetLogin() { return targetLogin; } + public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; } + public String getTargetBlockchainName() { return targetBlockchainName; } + public void setTargetBlockchainName(String targetBlockchainName) { this.targetBlockchainName = targetBlockchainName; } + public Integer getTargetBlockNumber() { return targetBlockNumber; } + public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; } + public String getTargetBlockHash() { return targetBlockHash; } + public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; } + } +} diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java index b767688c..68d61890 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java @@ -189,11 +189,11 @@ public class IT_03_AddBlock_NoAuth { "U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t); // 3) FRIEND взаимно (на HEADER) - sendConnection(sender1, st1, MsgSubType.CONNECTION_FRIEND, + sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND, bch2, u2HeaderBlock, u2HeaderHash, "U1 -> U2: FRIEND", t); - sendConnection(sender2, st2, MsgSubType.CONNECTION_FRIEND, + sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND, bch1, u1HeaderBlock, u1HeaderHash, "U2 -> U1: FRIEND", t); diff --git a/TODO/Arweave/notifications_projection_full_resync.md b/TODO/Arweave/notifications_projection_full_resync.md new file mode 100644 index 00000000..f24db8c7 --- /dev/null +++ b/TODO/Arweave/notifications_projection_full_resync.md @@ -0,0 +1,21 @@ +# TODO: уведомления и full-resync после фиксации блоков в Arweave + +## Контекст + +`user_notifications_state` является производным индексом blockchain-блоков. Сейчас full-resync цепочки очищает и перестраивает основные derived-state таблицы, но уведомления специально не включены в этот cleanup. + +На текущем этапе это **не исправляем**, потому что логика синхронизации ещё будет дорабатываться, а надёжность/финальность блоков планируется усилить записью в Arweave. + +## Что проверить после внедрения Arweave + +Когда схема Arweave и правила восстановления цепочки стабилизируются: + +- определить окончательный source of truth для accepted/finalized blocks; +- проверить поведение `user_notifications_state` при rollback/full-resync; +- если цепочка может реально заменить ранее принятый блок, удалять/перестраивать уведомления по `source_bch_name` и фактическому набору финальных блоков; +- не допускать stale-уведомлений от блоков, которые больше не входят в подтверждённую цепочку; +- добавить интеграционный тест на divergence + full-resync + notification projection. + +## Важно + +До появления финальной Arweave/recovery-модели не добавлять временную сложную cleanup-логику только ради этого редкого сценария. diff --git a/bundle.sh b/bundle.sh new file mode 100644 index 00000000..16adab75 --- /dev/null +++ b/bundle.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Build a source bundle ZIP while excluding credentials, private keys, +# local state, generated artifacts and other likely secrets. +# +# Usage: +# ./bundle.sh +# ./bundle.sh path/to/output.zip +# +# Run from anywhere inside the project; the script resolves its own directory. + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +OUT="${1:-SHiNE-bundle-$(date +%Y%m%d-%H%M%S).zip}" +case "$OUT" in + /*) ;; + *) OUT="$ROOT/$OUT" ;; +esac + +if ! command -v zip >/dev/null 2>&1; then + echo "ERROR: 'zip' is required." >&2 + exit 1 +fi + +TMP="$(mktemp -d)" +LIST="$TMP/files.txt" +SAFE_LIST="$TMP/safe-files.txt" +trap 'rm -rf "$TMP"' EXIT + +# Paths / filenames that must never be bundled. +is_denied_path() { + local p="/$1" + + case "$p" in + */.git/*|*/.git|\ + */.gradle/*|*/.gradle|\ + */.gradle-home/*|*/.gradle-home|\ + */.idea/*|*/.idea|\ + */.vscode/*|*/.vscode|\ + */node_modules/*|*/node_modules|\ + */target/*|*/target|\ + */build/*|*/build|\ + */out/*|*/out|\ + */bin/*|*/bin|\ + */logs/*|*/logs|\ + */data/*|*/data|\ + */test-ledger/*|*/test-ledger|\ + */.anchor/*|*/.anchor|\ + */.yarn/*|*/.yarn|\ + */.vendor/*|*/.vendor|\ + */.agents/*|*/.agents|\ + */.codex/*|*/.codex|\ + */.claude/*|*/.claude|\ + */deploy/backup/archive/*|\ + */scripts/*/runs/*|\ + */scripts/*/keypairs/*|\ + */keys/*|\ + */.git-local-backup/*) + return 0 + ;; + esac + + local base="${p##*/}" + local lower + lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')" + + case "$lower" in + .env|.env.*|\ + .debug-token|\ + .npmrc|.pypirc|.netrc|\ + credentials|credentials.*|\ + secrets|secrets.*|\ + secret|secret.*|\ + id_rsa|id_dsa|id_ecdsa|id_ed25519|\ + *.pem|*.key|*.p12|*.pfx|*.jks|*.keystore|\ + *keypair*.json|\ + service-account*.json|\ + firebase-adminsdk*.json|\ + google-services.json|\ + validator.log) + return 0 + ;; + esac + + case "$lower" in + *.class|*.jar|*.war|*.ear|*.o|*.a|*.so|*.dll|*.dylib|\ + *.elf|*.map|*.uf2|*.bin|*.merged.bin|\ + *.log|*.bak|*.bak.png|*.tmp|*.swp|*.swo|\ + .ds_store) + return 0 + ;; + esac + + return 1 +} + +# Collect files. Prefer Git because it naturally avoids most ignored local files. +if command -v git >/dev/null 2>&1 && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$ROOT" ls-files -co --exclude-standard -z > "$TMP/files.z" +else + find "$ROOT" -type f -print0 > "$TMP/files.z" +fi + +# Convert to project-relative paths and enforce hard deny rules. +: > "$LIST" +while IFS= read -r -d '' f; do + if [[ "$f" = /* ]]; then + rel="${f#"$ROOT"/}" + else + rel="$f" + fi + + [[ "$rel" == "$OUT" ]] && continue + [[ -z "$rel" ]] && continue + + if is_denied_path "$rel"; then + continue + fi + + printf '%s\n' "$rel" >> "$LIST" +done < "$TMP/files.z" + +sort -u "$LIST" -o "$LIST" + +# Content scan: fail closed on common credential/private-key patterns. +# We scan only text-ish files; grep -I skips binary data. +SECRET_RE='-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|(^|[^A-Za-z0-9])(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key)[[:space:]]*[:=][[:space:]]*["'\'']?[^${[:space:]]{][^[:space:]]{7,}' + +: > "$SAFE_LIST" +found_secret=0 + +while IFS= read -r rel; do + [[ -f "$ROOT/$rel" ]] || continue + + # Files that contain examples/templates can legitimately mention secret keys + # with placeholders. They are scanned too, but placeholder-looking values + # are less likely to match the regex above. + if LC_ALL=C grep -IEnq "$SECRET_RE" "$ROOT/$rel" 2>/dev/null; then + echo "BLOCKED: possible secret in $rel" >&2 + LC_ALL=C grep -IEn "$SECRET_RE" "$ROOT/$rel" 2>/dev/null \ + | sed -E 's/(:[[:space:]]*).*/\1[REDACTED]/' \ + | head -n 3 >&2 || true + found_secret=1 + continue + fi + + printf '%s\n' "$rel" >> "$SAFE_LIST" +done < "$LIST" + +if (( found_secret != 0 )); then + echo >&2 + echo "Bundle NOT created because possible secrets were detected." >&2 + echo "Move secrets to ignored/local files or adjust the scanner only after review." >&2 + exit 2 +fi + +if [[ ! -s "$SAFE_LIST" ]]; then + echo "ERROR: no files left to bundle." >&2 + exit 3 +fi + +rm -f -- "$OUT" + +( + cd "$ROOT" + zip -q -9 "$OUT" -@ < "$SAFE_LIST" +) + +echo "Created: $OUT" +echo "Files: $(wc -l < "$SAFE_LIST" | tr -d ' ')" +echo "Size: $(du -h "$OUT" | awk '{print $1}')" diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 8d922090..2716626c 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -58,6 +58,7 @@ | `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя | | `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя | | `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга | +| `GetNotifications` | `15_Notifications_API.md` | ответы и события уведомлений | | `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена | | `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка | | `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM | diff --git a/docs/API/15_Notifications_API.md b/docs/API/15_Notifications_API.md new file mode 100644 index 00000000..c0c40c8a --- /dev/null +++ b/docs/API/15_Notifications_API.md @@ -0,0 +1,81 @@ +# API для разработчиков: уведомления + +Документ описывает чтение пользовательских уведомлений, которые сервер индексирует при `AddBlock`. + +Текущая операция: + +- `GetNotifications` + +## 1. `GetNotifications` + +Метод читает уведомления текущего пользователя. В `payload.login` можно передать логин явно, но обычно клиент использует авторизованную сессию. + +Возвращаются две отдельные ленты: + +- `replies` — ответы на сообщения пользователя в каналах и тредах; +- `events` — события добавления в `close_friend`. + +### Запрос + +```json +{ + "op": "GetNotifications", + "requestId": "notif-001", + "payload": { + "login": "alice", + "limit": 50 + } +} +``` + +### Успешный ответ + +```json +{ + "op": "GetNotifications", + "requestId": "notif-001", + "status": 200, + "ok": true, + "payload": { + "login": "Alice", + "replies": [ + { + "kind": "reply", + "createdAtMs": 1755673200000, + "sourceLogin": "Bob", + "sourceBlockchainName": "bob-001", + "sourceBlockNumber": 42, + "sourceBlockHash": "ab12...", + "sourceMsgSubType": 20, + "sourceText": "Спасибо!", + "targetLogin": "Alice", + "targetBlockchainName": "alice-001", + "targetBlockNumber": 18, + "targetBlockHash": "cd34..." + } + ], + "events": [ + { + "kind": "close_friend", + "createdAtMs": 1755673300000, + "sourceLogin": "Kate", + "sourceBlockchainName": "kate-001", + "sourceBlockNumber": 7, + "sourceBlockHash": "ef56...", + "sourceMsgSubType": 10, + "sourceText": "close_friend", + "targetLogin": "Alice", + "targetBlockchainName": "alice-001", + "targetBlockNumber": 0, + "targetBlockHash": "0000..." + } + ] + } +} +``` + +### Примечание + +- `replies` заполняется только для `TEXT_REPLY`. +- `events` заполняется только для входящего `CONNECTION_FRIEND` / `close_friend`. +- Другие типы связей в эту ленту не попадают. diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 32fa3ae1..d2122b16 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -1371,7 +1371,7 @@ async function loadFromApi(route, channelId) { throw new Error('Канал не найден.'); } unreadCount = Number(channel?.unreadCount || 0); - messagesCount = Number(channel?.messagesCount || mergedMessages.length || 0); + messagesCount = Number(channel?.messagesCount || 0); selector = { ownerBlockchainName: String(channel.channel.ownerBlockchainName), channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber), diff --git a/shine-UI/js/pages/notifications-view.js b/shine-UI/js/pages/notifications-view.js index 578bfe70..d9418ef9 100644 --- a/shine-UI/js/pages/notifications-view.js +++ b/shine-UI/js/pages/notifications-view.js @@ -1,5 +1,4 @@ import { renderHeader } from '../components/header.js'; -import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { authService, state } from '../state.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; @@ -302,8 +301,6 @@ function renderItem(item, activeTab, navigate) { export function render({ navigate, chrome } = {}) { const screen = document.createElement('section'); screen.className = 'stack notifications-screen'; - const appScreen = document.getElementById('app-screen'); - const scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen }); chrome?.setTopbar(renderHeader({ title: 'Уведомления' })); const tabs = document.createElement('div'); @@ -336,7 +333,6 @@ export function render({ navigate, chrome } = {}) { const items = await Promise.all(baseItems.map((item) => enrichItem(item, activeTab))); if (seq !== requestSeq) return; list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate))); - scrollToBottomControl.refresh(); } catch (error) { if (seq !== requestSeq) return; const card = document.createElement('article'); @@ -364,8 +360,5 @@ export function render({ navigate, chrome } = {}) { screen.append(tabs, list); void load(); - screen.cleanup = () => { - scrollToBottomControl.cleanup(); - }; return screen; } diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index ba3dbe0b..8863a616 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -2862,6 +2862,14 @@ export class AuthService { return response.payload || {}; } + async getNotifications(limit = 50) { + const payload = {}; + if (Number.isFinite(Number(limit))) payload.limit = Math.max(1, Math.min(200, Number(limit))); + const response = await this.ws.request('GetNotifications', payload); + if (response.status !== 200) throw opError('GetNotifications', response); + return response.payload || {}; + } + async getUserConnectionsGraph(login) { const response = await this.ws.request('GetUserConnectionsGraph', { login }); if (response.status !== 200) throw opError('GetUserConnectionsGraph', response); diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 9af9512b..286cc78d 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -7453,19 +7453,6 @@ html, body { overflow-x: hidden; } border-color: rgba(230, 236, 245, 0.28); } -.profile-head-menu-wrap { - margin-left: auto; -} - -.profile-head-menu-item img { - width: 24px; - height: 24px; - flex: 0 0 auto; - display: block; - opacity: 0.94; - filter: drop-shadow(0 0 5px rgba(240, 184, 46, 0.18)); -} - .profile-info-modal { padding: 16px; background: rgba(2, 5, 11, 0.7); @@ -8154,62 +8141,3 @@ html, body { overflow-x: hidden; } .notifications-screen .notification-card--clickable:active { transform: scale(0.99); } - -/* ===== Shared scroll-to-bottom control ===== */ -.scroll-to-bottom-btn { - position: absolute; - right: 16px; - bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px) + 14px + env(safe-area-inset-bottom)); - z-index: 28; - width: 44px; - height: 44px; - padding: 0; - display: grid; - place-items: center; - border-radius: 50%; - border: 1px solid rgba(212, 175, 55, 0.38); - background: rgba(15, 20, 31, 0.82); - color: rgba(255, 226, 143, 0.96); - box-shadow: - 0 8px 26px rgba(0, 0, 0, 0.34), - 0 0 18px rgba(212, 175, 55, 0.14), - inset 0 1px 0 rgba(255, 255, 255, 0.08); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - opacity: 0; - pointer-events: none; - transform: translateY(10px) scale(0.92); - transition: opacity 160ms ease, transform 160ms ease, border-color 160ms ease, background 160ms ease; - cursor: pointer; - -webkit-tap-highlight-color: transparent; -} - -.scroll-to-bottom-btn.is-visible { - opacity: 1; - pointer-events: auto; - transform: translateY(0) scale(1); -} - -.scroll-to-bottom-btn:hover, -.scroll-to-bottom-btn:focus-visible { - border-color: rgba(255, 208, 82, 0.64); - background: rgba(24, 29, 43, 0.92); - outline: none; -} - -.scroll-to-bottom-btn:active { - transform: translateY(1px) scale(0.96); -} - -.scroll-to-bottom-btn__icon { - display: block; - margin-top: -2px; - font-size: 27px; - line-height: 1; - font-weight: 400; - text-shadow: 0 0 10px rgba(255, 201, 69, 0.34); -} - -.app-shell.keyboard-open .scroll-to-bottom-btn { - bottom: calc(var(--keyboard-offset, 0px) + var(--composer-height, 0px) + 14px + env(safe-area-inset-bottom)); -}