Compare commits

...
Author SHA256 Message Date
AidarKC d8e0c77951 Сохранить текущий снимок DM-синхронизации 2026-08-24 17:50:36 +04:00
AidarKC 015fade4c0 Смерджить main в ветку расширения функционала каналов 2026-08-22 20:16:16 +04:00
AidarKC da14b51e80 Исправить стартовый bootstrap DM 2026-08-22 20:13:14 +04:00
AidarKC 7a5ac01d1c UI: render DM inbox from dialogs 2026-08-22 18:47:14 +04:00
AidarKC 5e6d64e965 Split DM dialog migration to v11 2026-08-22 18:38:07 +04:00
AidarKC c425fa41aa UI: keep local shell updates 2026-08-22 18:34:40 +04:00
AidarKC 781157299f Server-side DM dialogs state 2026-08-22 18:31:32 +04:00
tram 0f30317bb4 Поднять бейдж канала и опустить время 2026-08-22 17:00:26 +03:00
tram 1f1bc0a7b9 Убрать меню из строк каналов 2026-08-22 16:51:37 +03:00
tram 60c8a6608a Вернуть channels-list к версии ui-artem 2026-08-22 16:47:12 +03:00
tram 86adaf8c6b Слить main с актуальными UI-изменениями 2026-08-22 16:36:47 +03:00
tram 731688d16e Merge ui-artem into main 2026-08-22 16:35:44 +03:00
tram 63b66c48d8 Доточить позиционирование UI 2026-08-22 16:35:41 +03:00
tram ea19e511c0 Доработать аватары и меню UI 2026-08-22 13:27:08 +03:00
tram f5e401cff8 Пересобрать верхний UI-бар 2026-08-22 12:34:07 +03:00
tram bc8c7f318b Обновить UI-кнопки и прокрутку 2026-08-22 11:28:12 +03:00
AidarKC 585750007d Merge remote-tracking branch 'origin/main' 2026-08-21 15:16:42 +04:00
AidarKC 6e80ac976a Merge branch 'расширение-функционала-каналов' 2026-08-21 15:16:13 +04:00
AidarKC c70f18fcf4 Remove bottom CTA from channel list 2026-08-21 15:12:36 +04:00
tram d3e6aa2be2 Добавить поддержку уведомлений 2026-08-21 14:11:47 +03:00
AidarKC b7a869c514 Make unread divider visible in channel view 2026-08-21 15:10:39 +04:00
tram 19362b950a Доработать UI уведомлений 2026-08-21 14:06:19 +03:00
AidarKC 0a4c31fb36 Refine unread marker and reset registration flow 2026-08-21 15:04:29 +04:00
AidarKC fef7694b48 Show unread count in channel list 2026-08-21 14:59:03 +04:00
AidarKC 60206e21df Accept user settings cursor writes on fallback 2026-08-21 14:52:45 +04:00
AidarKC b9b77c66ce Restore unread baseline and read tracking 2026-08-21 14:24:46 +04:00
AidarKC 745a0e39d7 Fix unread channel counts and badges 2026-08-21 13:45:49 +04:00
AidarKC 23bdadab56 Merge branch 'расширение-функционала-каналов' 2026-08-20 08:23:19 +04:00
AidarKC 47a4844ce8 Merge remote-tracking branch 'origin/main' into расширение-функционала-каналов 2026-08-20 08:14:52 +04:00
tram 8741be6cba Улучшить раскладку чата при клавиатуре 2026-08-20 07:12:46 +03:00
AidarKC 4656a03ea9 Исправить автоскролл и TDZ в канале 2026-08-19 20:38:03 +04:00
AidarKC 5763eb828e Исправить открытие канала и ключ настройки 2026-08-19 20:34:41 +04:00
AidarKC 6a5c20a165 Добавить user_settings и синхронизацию настроек 2026-08-19 19:45:09 +04:00
AidarKC fac166f186 Удалить legacy-стек прямых сообщений 2026-08-19 18:11:00 +04:00
128 changed files with 9843 additions and 1306 deletions
@@ -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;
@@ -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)
@@ -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";
@@ -25,6 +25,11 @@ 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 int SCHEMA_VERSION_10 = 10;
public static final int SCHEMA_VERSION_11 = 11;
public static final int SCHEMA_VERSION_12 = 12;
public static final int SCHEMA_VERSION_13 = 13;
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 +38,11 @@ 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";
public static final String POSTGRES_MIGRATION_V10_RESOURCE = "postgres/migration_v10.sql";
public static final String POSTGRES_MIGRATION_V11_RESOURCE = "postgres/migration_v11.sql";
public static final String POSTGRES_MIGRATION_V12_RESOURCE = "postgres/migration_v12.sql";
public static final String POSTGRES_MIGRATION_V13_RESOURCE = "postgres/migration_v13.sql";
private DatabaseInitializer() {}
@@ -51,10 +61,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;
@@ -124,6 +132,27 @@ 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);
currentVersion = SCHEMA_VERSION_9;
}
if (currentVersion < SCHEMA_VERSION_10) {
runSqlScript(conn, POSTGRES_MIGRATION_V10_RESOURCE);
currentVersion = SCHEMA_VERSION_10;
}
if (currentVersion < SCHEMA_VERSION_11) {
runSqlScript(conn, POSTGRES_MIGRATION_V11_RESOURCE);
currentVersion = SCHEMA_VERSION_11;
}
if (currentVersion < SCHEMA_VERSION_12) {
runSqlScript(conn, POSTGRES_MIGRATION_V12_RESOURCE);
currentVersion = SCHEMA_VERSION_12;
}
if (currentVersion < SCHEMA_VERSION_13) {
runSqlScript(conn, POSTGRES_MIGRATION_V13_RESOURCE);
currentVersion = SCHEMA_VERSION_13;
}
}
}
@@ -1,6 +1,7 @@
package shine.db;
import shine.db.connection.DbProvider;
import shine.db.dao.DmDialogStateDAO;
import java.sql.Connection;
import java.sql.SQLException;
@@ -15,6 +16,7 @@ public final class DbController implements DbProvider {
private static volatile DbController instance;
private final PostgresDbController delegate;
private volatile boolean dmDialogStateBootstrapped;
private DbController() {
this.delegate = PostgresDbController.getInstance();
@@ -24,7 +26,9 @@ public final class DbController implements DbProvider {
if (instance == null) {
synchronized (DbController.class) {
if (instance == null) {
instance = new DbController();
DbController created = new DbController();
instance = created;
created.bootstrapDmDialogStateIfNeeded();
}
}
}
@@ -40,4 +44,18 @@ public final class DbController implements DbProvider {
public void close() {
delegate.close();
}
private void bootstrapDmDialogStateIfNeeded() {
if (dmDialogStateBootstrapped) return;
synchronized (this) {
if (dmDialogStateBootstrapped) return;
try (Connection connection = delegate.getConnection()) {
DmDialogStateDAO.getInstance().bootstrapIfEmpty(connection);
dmDialogStateBootstrapped = true;
} catch (SQLException e) {
instance = null;
throw new RuntimeException("DM dialog state bootstrap failed", e);
}
}
}
}
@@ -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;
@@ -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
@@ -67,6 +67,36 @@ public final class ConnectionsStateDAO {
return out;
}
public boolean hasOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, String peerLoginAnyCase, int relType) throws SQLException {
String sql = """
SELECT 1
FROM connections_state cs
LEFT JOIN %s
ON LOWER(u_login.login) = LOWER(cs.to_login)
LEFT JOIN %s
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
WHERE LOWER(cs.login) = LOWER(?)
AND cs.rel_type = ?
AND (
LOWER(cs.to_login) = LOWER(?)
OR LOWER(COALESCE(u_login.login, u_bch.login, cs.to_login)) = LOWER(?)
)
LIMIT 1
""".formatted(
CurrentUsersSql.usersSubquery("u_login"),
CurrentUsersSql.usersSubquery("u_bch")
);
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, loginAnyCase);
ps.setInt(2, relType);
ps.setString(3, peerLoginAnyCase);
ps.setString(4, peerLoginAnyCase);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
/**
* Incoming: список логинов (канонических), кто поставил relType пользователю login.
*/
@@ -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();
}
}
}
}
@@ -0,0 +1,518 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.DmDeliveryStateEntry;
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.HashMap;
import java.util.List;
import java.util.Map;
/** Хранилище изменяемого состояния межсерверной доставки DM-пары. */
public final class DmDeliveryStateDAO {
private static volatile DmDeliveryStateDAO instance;
private final DbController db = DbController.getInstance();
private DmDeliveryStateDAO() {}
public static DmDeliveryStateDAO getInstance() {
if (instance == null) {
synchronized (DmDeliveryStateDAO.class) {
if (instance == null) instance = new DmDeliveryStateDAO();
}
}
return instance;
}
public DmDeliveryStateEntry upsertPair(
String outgoingMessageKey,
String eventId,
String baseKey,
String fromLogin,
String toLogin,
String incomingMessageKey,
long createdAtMs,
long expiresAtMs,
int initialState,
String deliveredServerLogin,
String routesHash,
boolean assistImmediately
) throws SQLException {
long now = System.currentTimeMillis();
int safeState = normalizeState(initialState, deliveredServerLogin);
if (expiresAtMs <= now && safeState == DmDeliveryStateEntry.PENDING_NONE) {
safeState = DmDeliveryStateEntry.FAILED_FINAL;
}
Long nextAttemptAt = safeState == DmDeliveryStateEntry.ACCEPTED
? now
: null;
String safeDeliveredLogin = safeState == DmDeliveryStateEntry.DELIVERED_ONE
? normalize(deliveredServerLogin)
: null;
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO dm_delivery_state (
outgoing_message_key, event_id, base_key, from_login, to_login,
incoming_message_key, created_at_ms, delivery_expires_at_ms,
delivery_state, delivered_server_login, recipient_routes_hash,
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, NULL, ?)
ON CONFLICT (outgoing_message_key) DO UPDATE SET
event_id = EXCLUDED.event_id,
base_key = EXCLUDED.base_key,
from_login = EXCLUDED.from_login,
to_login = EXCLUDED.to_login,
incoming_message_key = EXCLUDED.incoming_message_key,
created_at_ms = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.created_at_ms
ELSE EXCLUDED.created_at_ms
END,
delivery_expires_at_ms = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivery_expires_at_ms
ELSE EXCLUDED.delivery_expires_at_ms
END,
delivery_state = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivery_state
ELSE EXCLUDED.delivery_state
END,
delivered_server_login = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.delivered_server_login
ELSE EXCLUDED.delivered_server_login
END,
recipient_routes_hash = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN COALESCE(dm_delivery_state.recipient_routes_hash, EXCLUDED.recipient_routes_hash)
ELSE EXCLUDED.recipient_routes_hash
END,
attempt_index = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.attempt_index
ELSE 0
END,
next_attempt_at_ms = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.next_attempt_at_ms
ELSE EXCLUDED.next_attempt_at_ms
END,
last_attempt_at_ms = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.last_attempt_at_ms
ELSE NULL
END,
last_error = CASE
WHEN dm_delivery_state.event_id = EXCLUDED.event_id THEN dm_delivery_state.last_error
ELSE NULL
END,
updated_at_ms = EXCLUDED.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, outgoingMessageKey);
ps.setString(2, eventId);
ps.setString(3, baseKey);
ps.setString(4, normalize(fromLogin));
ps.setString(5, normalize(toLogin));
ps.setString(6, incomingMessageKey);
ps.setLong(7, createdAtMs);
ps.setLong(8, expiresAtMs);
ps.setInt(9, safeState);
setNullableString(ps, 10, safeDeliveredLogin);
setNullableString(ps, 11, normalizeBlank(routesHash));
setNullableLong(ps, 12, nextAttemptAt);
ps.setLong(13, now);
ps.executeUpdate();
}
}
DmDeliveryStateEntry result;
if (initialState != DmDeliveryStateEntry.PENDING_NONE || !isBlank(deliveredServerLogin)) {
result = mergeRemote(eventId, initialState, deliveredServerLogin, routesHash, now);
} else {
result = getByEventId(eventId);
}
if (result != null && result.getDeliveryExpiresAtMs() <= now
&& result.getDeliveryState() == DmDeliveryStateEntry.PENDING_NONE) {
return finishAtExpiry(eventId, now);
}
return result;
}
public DmDeliveryStateEntry getByEventId(String eventId) throws SQLException {
if (isBlank(eventId)) return null;
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE event_id = ?")) {
ps.setString(1, eventId.trim());
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
public DmDeliveryStateEntry getByOutgoingMessageKey(String outgoingMessageKey) throws SQLException {
if (isBlank(outgoingMessageKey)) return null;
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE outgoing_message_key = ?")) {
ps.setString(1, outgoingMessageKey.trim());
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
public Map<String, DmDeliveryStateEntry> listByOutgoingMessageKeys(List<String> keys) throws SQLException {
Map<String, DmDeliveryStateEntry> out = new HashMap<>();
if (keys == null || keys.isEmpty()) return out;
String placeholders = String.join(",", java.util.Collections.nCopies(keys.size(), "?"));
String sql = selectColumns() + " WHERE outgoing_message_key IN (" + placeholders + ")";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
for (int i = 0; i < keys.size(); i++) ps.setString(i + 1, keys.get(i));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
DmDeliveryStateEntry row = mapRow(rs);
out.put(row.getOutgoingMessageKey(), row);
}
}
}
return out;
}
public List<DmDeliveryStateEntry> listDue(long nowMs, int limit) throws SQLException {
String sql = selectColumns() + """
WHERE delivery_state = 0
AND next_attempt_at_ms IS NOT NULL
AND next_attempt_at_ms <= ?
ORDER BY next_attempt_at_ms ASC, created_at_ms ASC
LIMIT ?
""";
List<DmDeliveryStateEntry> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, nowMs);
ps.setInt(2, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public boolean claimAttempt(DmDeliveryStateEntry row, long nowMs, Long nextAttemptAtMs) throws SQLException {
String sql = """
UPDATE dm_delivery_state
SET attempt_index = attempt_index + 1,
next_attempt_at_ms = ?,
last_attempt_at_ms = ?,
last_error = NULL,
updated_at_ms = ?
WHERE outgoing_message_key = ?
AND event_id = ?
AND attempt_index = ?
AND delivery_state = 0
AND next_attempt_at_ms IS NOT NULL
AND next_attempt_at_ms <= ?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
long networkLeaseUntilMs = nowMs + 60_000L;
Long claimedNextAttemptAtMs = nextAttemptAtMs == null
? networkLeaseUntilMs
: Math.max(nextAttemptAtMs, networkLeaseUntilMs);
setNullableLong(ps, 1, claimedNextAttemptAtMs);
ps.setLong(2, nowMs);
ps.setLong(3, nowMs);
ps.setString(4, row.getOutgoingMessageKey());
ps.setString(5, row.getEventId());
ps.setInt(6, row.getAttemptIndex());
ps.setLong(7, nowMs);
return ps.executeUpdate() == 1;
}
}
public DmDeliveryStateEntry mergeRemote(
String eventId,
int remoteState,
String remoteDeliveredLogin,
String remoteRoutesHash,
long nowMs
) throws SQLException {
try (Connection c = db.getConnection()) {
boolean previousAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
DmDeliveryStateEntry current = getByEventIdForUpdate(c, eventId);
if (current == null) {
c.rollback();
return null;
}
MergeResult merged = merge(
current.getDeliveryState(),
current.getDeliveredServerLogin(),
current.getRecipientRoutesHash(),
remoteState,
remoteDeliveredLogin,
remoteRoutesHash
);
updateMutableState(c, current, merged.state(), merged.deliveredLogin(),
merged.routesHash(), current.getNextAttemptAtMs(), current.getLastError(), nowMs);
c.commit();
return getByEventId(eventId);
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw e;
} finally {
c.setAutoCommit(previousAutoCommit);
}
}
}
public DmDeliveryStateEntry updateAfterAttempt(
String eventId,
int attemptedState,
String deliveredServerLogin,
String routesHash,
Long nextAttemptAtMs,
String lastError,
long nowMs
) throws SQLException {
try (Connection c = db.getConnection()) {
boolean previousAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
DmDeliveryStateEntry current = getByEventIdForUpdate(c, eventId);
if (current == null) {
c.rollback();
return null;
}
MergeResult merged = merge(
current.getDeliveryState(), current.getDeliveredServerLogin(), current.getRecipientRoutesHash(),
attemptedState, deliveredServerLogin, routesHash
);
Long effectiveNext = merged.state() == DmDeliveryStateEntry.ACCEPTED
? nextAttemptAtMs
: null;
updateMutableState(c, current, merged.state(), merged.deliveredLogin(),
merged.routesHash(), effectiveNext, lastError, nowMs);
c.commit();
return getByEventId(eventId);
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw e;
} finally {
c.setAutoCommit(previousAutoCommit);
}
}
}
public DmDeliveryStateEntry finishAtExpiry(String eventId, long nowMs) throws SQLException {
try (Connection c = db.getConnection()) {
String sql = """
UPDATE dm_delivery_state
SET delivery_state = CASE WHEN delivery_state = 0 THEN 3 ELSE delivery_state END,
delivered_server_login = CASE WHEN delivery_state = 0 THEN NULL ELSE delivered_server_login END,
next_attempt_at_ms = NULL,
last_error = CASE WHEN delivery_state = 0 THEN 'DELIVERY_EXPIRED' ELSE last_error END,
updated_at_ms = ?
WHERE event_id = ? AND delivery_state = 0
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, nowMs);
ps.setString(2, eventId);
ps.executeUpdate();
}
}
return getByEventId(eventId);
}
/** Read-only peer confirmed that at least one recipient server accepted the message. */
public DmDeliveryStateEntry markDeliveredFromPeer(String eventId, long nowMs) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE dm_delivery_state
SET delivery_state = 1,
delivered_server_login = NULL,
next_attempt_at_ms = NULL,
last_error = NULL,
updated_at_ms = ?
WHERE event_id = ? AND delivery_state = 0
""")) {
ps.setLong(1, nowMs);
ps.setString(2, eventId);
ps.executeUpdate();
}
return getByEventId(eventId);
}
public int removeByBaseKey(String baseKey) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("DELETE FROM dm_delivery_state WHERE base_key = ?")) {
ps.setString(1, baseKey);
return ps.executeUpdate();
}
}
public int removeConversationBefore(String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
String sql = """
DELETE FROM dm_delivery_state
WHERE created_at_ms < ?
AND ((LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)))
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, boundaryTimeMs);
ps.setString(2, fromLogin);
ps.setString(3, toLogin);
ps.setString(4, toLogin);
ps.setString(5, fromLogin);
return ps.executeUpdate();
}
}
public int removeMissingMessages() throws SQLException {
String sql = """
DELETE FROM dm_delivery_state d
WHERE NOT EXISTS (
SELECT 1 FROM signed_messages m WHERE m.message_key = d.outgoing_message_key
)
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
return ps.executeUpdate();
}
}
private DmDeliveryStateEntry getByEventIdForUpdate(Connection c, String eventId) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(selectColumns() + " WHERE event_id = ? FOR UPDATE")) {
ps.setString(1, eventId);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
private void updateMutableState(
Connection c,
DmDeliveryStateEntry current,
int state,
String deliveredLogin,
String routesHash,
Long nextAttemptAtMs,
String lastError,
long nowMs
) throws SQLException {
String sql = """
UPDATE dm_delivery_state
SET delivery_state = ?,
delivered_server_login = ?,
recipient_routes_hash = ?,
next_attempt_at_ms = ?,
last_error = ?,
updated_at_ms = ?
WHERE outgoing_message_key = ? AND event_id = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, state);
setNullableString(ps, 2, state == DmDeliveryStateEntry.DELIVERED_ONE ? normalize(deliveredLogin) : null);
setNullableString(ps, 3, normalizeBlank(routesHash));
setNullableLong(ps, 4, nextAttemptAtMs);
setNullableString(ps, 5, normalizeBlank(lastError));
ps.setLong(6, nowMs);
ps.setString(7, current.getOutgoingMessageKey());
ps.setString(8, current.getEventId());
ps.executeUpdate();
}
}
private MergeResult merge(
int localState,
String localLogin,
String localHash,
int remoteStateRaw,
String remoteLoginRaw,
String remoteHashRaw
) {
int remoteState = normalizeState(remoteStateRaw, remoteLoginRaw);
String remoteLogin = normalize(remoteLoginRaw);
String localNormalizedLogin = normalize(localLogin);
String localRoutesHash = normalizeBlank(localHash);
String remoteRoutesHash = normalizeBlank(remoteHashRaw);
String resultHash = remoteRoutesHash != null ? remoteRoutesHash : localRoutesHash;
if (localState == DmDeliveryStateEntry.DELIVERED_ONE || localState == DmDeliveryStateEntry.DELIVERED_ALL) {
return new MergeResult(DmDeliveryStateEntry.DELIVERED, localNormalizedLogin,
localRoutesHash != null ? localRoutesHash : remoteRoutesHash);
}
if (remoteState == DmDeliveryStateEntry.DELIVERED_ONE || remoteState == DmDeliveryStateEntry.DELIVERED_ALL) {
return new MergeResult(DmDeliveryStateEntry.DELIVERED, remoteLogin, resultHash);
}
if (localState == DmDeliveryStateEntry.FAILED_FINAL || remoteState == DmDeliveryStateEntry.FAILED_FINAL) {
return new MergeResult(DmDeliveryStateEntry.FAILED_FINAL, null, resultHash);
}
return new MergeResult(DmDeliveryStateEntry.PENDING_NONE, null, resultHash);
}
private int normalizeState(int state, String deliveredLogin) {
if (state < DmDeliveryStateEntry.PENDING_NONE || state > DmDeliveryStateEntry.FAILED_FINAL) {
return DmDeliveryStateEntry.PENDING_NONE;
}
if (state == DmDeliveryStateEntry.DELIVERED_ALL) return DmDeliveryStateEntry.DELIVERED;
return state;
}
private String selectColumns() {
return """
SELECT outgoing_message_key, event_id, base_key, from_login, to_login,
incoming_message_key, created_at_ms, delivery_expires_at_ms,
delivery_state, delivered_server_login, recipient_routes_hash,
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
FROM dm_delivery_state
""";
}
private DmDeliveryStateEntry mapRow(ResultSet rs) throws SQLException {
DmDeliveryStateEntry e = new DmDeliveryStateEntry();
e.setOutgoingMessageKey(rs.getString("outgoing_message_key"));
e.setEventId(rs.getString("event_id"));
e.setBaseKey(rs.getString("base_key"));
e.setFromLogin(rs.getString("from_login"));
e.setToLogin(rs.getString("to_login"));
e.setIncomingMessageKey(rs.getString("incoming_message_key"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
e.setDeliveryExpiresAtMs(rs.getLong("delivery_expires_at_ms"));
e.setDeliveryState(rs.getInt("delivery_state"));
e.setDeliveredServerLogin(rs.getString("delivered_server_login"));
e.setRecipientRoutesHash(rs.getString("recipient_routes_hash"));
e.setAttemptIndex(rs.getInt("attempt_index"));
long next = rs.getLong("next_attempt_at_ms");
e.setNextAttemptAtMs(rs.wasNull() ? null : next);
long last = rs.getLong("last_attempt_at_ms");
e.setLastAttemptAtMs(rs.wasNull() ? null : last);
e.setLastError(rs.getString("last_error"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
return e;
}
private static void setNullableString(PreparedStatement ps, int index, String value) throws SQLException {
if (isBlank(value)) ps.setNull(index, Types.VARCHAR);
else ps.setString(index, value.trim());
}
private static void setNullableLong(PreparedStatement ps, int index, Long value) throws SQLException {
if (value == null) ps.setNull(index, Types.BIGINT);
else ps.setLong(index, value);
}
private static String normalize(String value) {
if (value == null) return null;
String normalized = value.trim().toLowerCase();
return normalized.isEmpty() ? null : normalized;
}
private static String normalizeBlank(String value) {
if (value == null) return null;
String normalized = value.trim();
return normalized.isEmpty() ? null : normalized;
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private record MergeResult(int state, String deliveredLogin, String routesHash) {}
}
@@ -0,0 +1,453 @@
package shine.db.dao;
import shine.db.MsgSubType;
import shine.db.entities.SignedMessageEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Base64;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public final class DmDialogStateDAO {
private static volatile DmDialogStateDAO instance;
private DmDialogStateDAO() {}
public static DmDialogStateDAO getInstance() {
if (instance == null) {
synchronized (DmDialogStateDAO.class) {
if (instance == null) instance = new DmDialogStateDAO();
}
}
return instance;
}
public void bootstrapIfEmpty(Connection c) throws SQLException {
if (c == null) return;
if (hasAnyRow(c)) return;
boolean prevAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
for (PairItem pair : listConversationPairs(c)) {
refreshConversationPair(c, pair.ownerLogin(), pair.peerLogin());
}
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
if (e instanceof SQLException sqlEx) throw sqlEx;
throw new SQLException("Failed to bootstrap dm_dialog_state", e);
} finally {
c.setAutoCommit(prevAutoCommit);
}
}
public void refreshConversationPair(Connection c, String ownerLogin, String peerLogin) throws SQLException {
String cleanOwner = normalize(ownerLogin);
String cleanPeer = normalize(peerLogin);
if (cleanOwner.isEmpty() || cleanPeer.isEmpty()) return;
if (cleanOwner.equalsIgnoreCase(cleanPeer)) return;
refreshConversation(c, cleanOwner, cleanPeer);
refreshConversation(c, cleanPeer, cleanOwner);
}
public List<DialogSummary> listInboxDialogs(Connection c, String ownerLogin) throws SQLException {
String cleanOwner = normalize(ownerLogin);
if (cleanOwner.isEmpty()) return List.of();
Map<String, DialogSummary> byPeer = new LinkedHashMap<>();
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CONTACT);
List<String> closeFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, cleanOwner, MsgSubType.CONNECTION_CLOSE_FRIEND);
try (PreparedStatement ps = c.prepareStatement("""
SELECT owner_login, peer_login, relation_flag, last_message_blob_b64,
last_message_time_ms, unread_count, last_read_receipt_time_ms,
updated_at_ms
FROM dm_dialog_state
WHERE LOWER(owner_login) = LOWER(?)
""")) {
ps.setString(1, cleanOwner);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
DialogSummary row = new DialogSummary(
rs.getString("owner_login"),
rs.getString("peer_login"),
normalizeRelationFlag(rs.getString("relation_flag")),
rs.getString("last_message_blob_b64"),
rs.getLong("last_message_time_ms"),
rs.getInt("unread_count"),
rs.getLong("last_read_receipt_time_ms"),
rs.getLong("updated_at_ms"),
true
);
byPeer.put(normKey(row.peerLogin()), row);
}
}
}
for (String peer : contacts) {
addOrUpdateSummary(byPeer, cleanOwner, peer, "contact", false);
}
for (String peer : closeFriends) {
addOrUpdateSummary(byPeer, cleanOwner, peer, "close_friend", false);
}
for (DialogSummary row : new ArrayList<>(byPeer.values())) {
String relationFlag = resolveRelationFlag(c, cleanOwner, row.peerLogin());
byPeer.put(normKey(row.peerLogin()), new DialogSummary(
row.ownerLogin(),
row.peerLogin(),
relationFlag,
row.lastMessageBlobB64(),
row.lastMessageTimeMs(),
row.unreadCount(),
row.lastReadReceiptTimeMs(),
row.updatedAtMs(),
row.hasDialog()
));
}
List<DialogSummary> out = new ArrayList<>(byPeer.values());
out.sort((a, b) -> {
int cmp = Long.compare(b.lastMessageTimeMs(), a.lastMessageTimeMs());
if (cmp != 0) return cmp;
return normalize(a.peerLogin()).compareToIgnoreCase(normalize(b.peerLogin()));
});
return out;
}
public void refreshFromEntry(Connection c, SignedMessageEntry entry) throws SQLException {
if (entry == null) return;
refreshConversationPair(c, entry.getFromLogin(), entry.getToLogin());
}
private void refreshConversation(Connection c, String ownerLogin, String peerLogin) throws SQLException {
DialogSummary summary = loadConversationSummary(c, ownerLogin, peerLogin);
upsert(c, summary);
if (summary.lastReadReceiptTimeMs() > 0) {
syncMessagesToWatermark(c, summary.ownerLogin(), summary.peerLogin(), summary.lastReadReceiptTimeMs());
}
}
private DialogSummary loadConversationSummary(Connection c, String ownerLogin, String peerLogin) throws SQLException {
String cleanOwner = normalize(ownerLogin);
String cleanPeer = normalize(peerLogin);
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) {
return new DialogSummary(cleanOwner, cleanPeer, "none", "", 0L, 0, 0L, System.currentTimeMillis(), false);
}
String relationFlag = resolveRelationFlag(c, cleanOwner, cleanPeer);
long existingWatermark = loadExistingWatermark(c, cleanOwner, cleanPeer);
String latestSql = """
SELECT raw_block, time_ms
FROM signed_messages
WHERE (
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
AND message_type IN (1, 2)
ORDER BY time_ms DESC, revision_time_ms DESC, reencrypted_at_ms DESC, created_at_ms DESC, message_key DESC
LIMIT 1
""";
String unreadSql = """
SELECT COUNT(*)
FROM signed_messages
WHERE LOWER(from_login) = LOWER(?)
AND LOWER(to_login) = LOWER(?)
AND message_type = 1
AND time_ms > ?
AND (read_at_ms IS NULL OR read_at_ms <= 0)
""";
String contentReadSql = """
SELECT MAX(read_at_ms)
FROM signed_messages
WHERE (
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
AND message_type IN (1, 2)
AND read_at_ms IS NOT NULL
AND read_at_ms > 0
""";
String receiptWatermarkSql = """
SELECT MAX(time_ms)
FROM signed_messages
WHERE (
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
AND message_type IN (3, 4)
""";
String lastMessageBlobB64 = "";
long lastMessageTimeMs = 0L;
int unreadCount = 0;
long lastReadReceiptTimeMs = existingWatermark;
try (PreparedStatement ps = c.prepareStatement(latestSql)) {
ps.setString(1, cleanOwner);
ps.setString(2, cleanPeer);
ps.setString(3, cleanPeer);
ps.setString(4, cleanOwner);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
lastMessageTimeMs = rs.getLong("time_ms");
byte[] rawBlock = rs.getBytes("raw_block");
if (rawBlock != null && rawBlock.length > 0) {
lastMessageBlobB64 = Base64.getEncoder().encodeToString(rawBlock);
}
}
}
}
try (PreparedStatement ps = c.prepareStatement(contentReadSql)) {
ps.setString(1, cleanOwner);
ps.setString(2, cleanPeer);
ps.setString(3, cleanPeer);
ps.setString(4, cleanOwner);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
long value = rs.getLong(1);
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
}
}
}
try (PreparedStatement ps = c.prepareStatement(receiptWatermarkSql)) {
ps.setString(1, cleanOwner);
ps.setString(2, cleanPeer);
ps.setString(3, cleanPeer);
ps.setString(4, cleanOwner);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
long value = rs.getLong(1);
if (!rs.wasNull()) lastReadReceiptTimeMs = Math.max(lastReadReceiptTimeMs, value);
}
}
}
try (PreparedStatement ps = c.prepareStatement(unreadSql)) {
ps.setString(1, cleanPeer);
ps.setString(2, cleanOwner);
ps.setLong(3, lastReadReceiptTimeMs);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) unreadCount = rs.getInt(1);
}
}
return new DialogSummary(
cleanOwner,
cleanPeer,
relationFlag,
lastMessageBlobB64,
lastMessageTimeMs,
unreadCount,
lastReadReceiptTimeMs,
System.currentTimeMillis(),
lastMessageTimeMs > 0 || unreadCount > 0 || lastReadReceiptTimeMs > 0
);
}
private void upsert(Connection c, DialogSummary summary) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO dm_dialog_state (
owner_login, peer_login, relation_flag, last_message_blob_b64,
last_message_time_ms, unread_count, last_read_receipt_time_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (owner_login, peer_login) DO UPDATE SET
relation_flag = EXCLUDED.relation_flag,
last_message_blob_b64 = EXCLUDED.last_message_blob_b64,
last_message_time_ms = EXCLUDED.last_message_time_ms,
unread_count = EXCLUDED.unread_count,
last_read_receipt_time_ms = EXCLUDED.last_read_receipt_time_ms,
updated_at_ms = EXCLUDED.updated_at_ms
""")) {
ps.setString(1, summary.ownerLogin());
ps.setString(2, summary.peerLogin());
ps.setString(3, normalizeRelationFlag(summary.relationFlag()));
ps.setString(4, summary.lastMessageBlobB64());
ps.setLong(5, summary.lastMessageTimeMs());
ps.setInt(6, summary.unreadCount());
ps.setLong(7, summary.lastReadReceiptTimeMs());
ps.setLong(8, summary.updatedAtMs());
ps.executeUpdate();
}
}
private String resolveRelationFlag(Connection c, String ownerLogin, String peerLogin) throws SQLException {
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
return "close_friend";
}
if (ConnectionsStateDAO.getInstance().hasOutgoingByRelTypeCanonical(c, ownerLogin, peerLogin, MsgSubType.CONNECTION_CONTACT)) {
return "contact";
}
return "none";
}
private boolean hasAnyRow(Connection c) throws SQLException {
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery("SELECT 1 FROM dm_dialog_state LIMIT 1")) {
return rs.next();
}
}
private List<PairItem> listConversationPairs(Connection c) throws SQLException {
String sql = """
SELECT DISTINCT owner_login, peer_login
FROM (
SELECT
m.target_login AS owner_login,
CASE
WHEN LOWER(m.target_login) = LOWER(m.to_login) THEN m.from_login
ELSE m.to_login
END AS peer_login
FROM signed_messages m
WHERE m.message_type IN (1, 2, 3, 4, 5, 6, 7, 8)
UNION ALL
SELECT m.from_login AS owner_login, m.to_login AS peer_login
FROM signed_messages m
WHERE m.message_type IN (5, 6, 7, 8)
UNION ALL
SELECT m.to_login AS owner_login, m.from_login AS peer_login
FROM signed_messages m
WHERE m.message_type IN (5, 6, 7, 8)
) pairs
WHERE owner_login IS NOT NULL
AND peer_login IS NOT NULL
AND BTRIM(owner_login) <> ''
AND BTRIM(peer_login) <> ''
AND LOWER(owner_login) <> LOWER(peer_login)
ORDER BY owner_login, peer_login
""";
List<PairItem> out = new ArrayList<>();
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery(sql)) {
while (rs.next()) {
String ownerLogin = rs.getString("owner_login");
String peerLogin = rs.getString("peer_login");
if (normalize(ownerLogin).isEmpty() || normalize(peerLogin).isEmpty()) continue;
out.add(new PairItem(ownerLogin, peerLogin));
}
}
return out;
}
private void addOrUpdateSummary(Map<String, DialogSummary> map, String ownerLogin, String peerLogin, String relationFlag, boolean hasDialog) {
String owner = normalize(ownerLogin);
String peer = normalize(peerLogin);
if (owner.isEmpty() || peer.isEmpty() || owner.equalsIgnoreCase(peer)) return;
String key = normKey(peer);
DialogSummary current = map.get(key);
if (current == null) {
map.put(key, new DialogSummary(owner, peer, relationFlag, "", 0L, 0, 0L, System.currentTimeMillis(), hasDialog));
return;
}
String nextRelation = current.relationFlag();
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
nextRelation = "close_friend";
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
nextRelation = "contact";
} else {
nextRelation = normalizeRelationFlag(nextRelation);
}
map.put(key, new DialogSummary(
current.ownerLogin().isEmpty() ? owner : current.ownerLogin(),
peer,
nextRelation,
current.lastMessageBlobB64(),
current.lastMessageTimeMs(),
current.unreadCount(),
current.lastReadReceiptTimeMs(),
current.updatedAtMs(),
current.hasDialog() || hasDialog
));
}
private void syncMessagesToWatermark(Connection c, String ownerLogin, String peerLogin, long watermark) throws SQLException {
if (watermark <= 0) return;
String cleanOwner = normalize(ownerLogin);
String cleanPeer = normalize(peerLogin);
if (cleanOwner.isEmpty() || cleanPeer.isEmpty() || cleanOwner.equalsIgnoreCase(cleanPeer)) return;
try (PreparedStatement ps = c.prepareStatement("""
UPDATE signed_messages
SET read_at_ms = CASE
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
WHEN read_at_ms > ? THEN read_at_ms
ELSE read_at_ms
END
WHERE (
(LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?))
)
AND message_type IN (1, 2)
AND time_ms <= ?
""")) {
ps.setLong(1, watermark);
ps.setLong(2, watermark);
ps.setString(3, cleanOwner);
ps.setString(4, cleanPeer);
ps.setString(5, cleanPeer);
ps.setString(6, cleanOwner);
ps.setLong(7, watermark);
ps.executeUpdate();
}
}
private long loadExistingWatermark(Connection c, String ownerLogin, String peerLogin) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT last_read_receipt_time_ms
FROM dm_dialog_state
WHERE LOWER(owner_login) = LOWER(?)
AND LOWER(peer_login) = LOWER(?)
LIMIT 1
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, peerLogin);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return 0L;
long value = rs.getLong(1);
return rs.wasNull() ? 0L : value;
}
}
}
private String normalize(String value) {
return value == null ? "" : value.trim();
}
private String normKey(String value) {
return normalize(value).toLowerCase(Locale.ROOT);
}
private String normalizeRelationFlag(String value) {
String clean = normalize(value).toLowerCase(Locale.ROOT);
if ("close_friend".equals(clean) || "contact".equals(clean)) return clean;
return "none";
}
public record DialogSummary(
String ownerLogin,
String peerLogin,
String relationFlag,
String lastMessageBlobB64,
long lastMessageTimeMs,
int unreadCount,
long lastReadReceiptTimeMs,
long updatedAtMs,
boolean hasDialog
) {}
private record PairItem(String ownerLogin, String peerLogin) {}
}
@@ -0,0 +1,232 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.DmSyncOutboxEntry;
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;
/** Outbox событий DM для единственного второго access-сервера пользователя. */
public final class DmSyncOutboxDAO {
private static volatile DmSyncOutboxDAO instance;
private final DbController db = DbController.getInstance();
private DmSyncOutboxDAO() {}
public static DmSyncOutboxDAO getInstance() {
if (instance == null) {
synchronized (DmSyncOutboxDAO.class) {
if (instance == null) instance = new DmSyncOutboxDAO();
}
}
return instance;
}
public void upsert(
String ownerLogin,
String primaryMessageKey,
String eventId,
String secondaryMessageKey,
boolean synced,
long createdAtMs
) throws SQLException {
long now = System.currentTimeMillis();
String sql = """
INSERT INTO dm_sync_outbox (
owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
) VALUES (LOWER(?), ?, ?, ?, ?, ?, ?)
ON CONFLICT (owner_login, primary_message_key) DO UPDATE SET
event_id = EXCLUDED.event_id,
secondary_message_key = EXCLUDED.secondary_message_key,
synced = CASE
WHEN dm_sync_outbox.event_id = EXCLUDED.event_id
THEN dm_sync_outbox.synced OR EXCLUDED.synced
ELSE EXCLUDED.synced
END,
created_at_ms = CASE
WHEN dm_sync_outbox.event_id = EXCLUDED.event_id
THEN dm_sync_outbox.created_at_ms
ELSE EXCLUDED.created_at_ms
END,
updated_at_ms = EXCLUDED.updated_at_ms
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setString(2, primaryMessageKey);
ps.setString(3, eventId);
if (secondaryMessageKey == null || secondaryMessageKey.isBlank()) ps.setNull(4, Types.VARCHAR);
else ps.setString(4, secondaryMessageKey.trim());
ps.setBoolean(5, synced);
ps.setLong(6, createdAtMs);
ps.setLong(7, now);
ps.executeUpdate();
}
}
public List<DmSyncOutboxEntry> listUnsynced(String ownerLogin, int limit) throws SQLException {
return listUnsynced(ownerLogin, 0L, "", limit);
}
public List<DmSyncOutboxEntry> listUnsynced(
String ownerLogin, long afterCreatedAtMs, String afterPrimaryMessageKey, int limit
) throws SQLException {
String sql = """
SELECT owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
FROM dm_sync_outbox
WHERE LOWER(owner_login) = LOWER(?) AND synced = FALSE
AND (created_at_ms > ? OR (created_at_ms = ? AND primary_message_key > ?))
ORDER BY created_at_ms ASC, primary_message_key ASC
LIMIT ?
""";
List<DmSyncOutboxEntry> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setLong(2, Math.max(0L, afterCreatedAtMs));
ps.setLong(3, Math.max(0L, afterCreatedAtMs));
ps.setString(4, afterPrimaryMessageKey == null ? "" : afterPrimaryMessageKey);
ps.setInt(5, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public boolean hasUnsyncedForServer(String serverLogin) throws SQLException {
String sql = """
SELECT 1
FROM dm_sync_outbox o
JOIN user_access_servers_current r
ON LOWER(r.user_login) = LOWER(o.owner_login)
WHERE o.synced = FALSE AND LOWER(r.server_login) = LOWER(?)
AND EXISTS (
SELECT 1
FROM user_access_servers_current peer
WHERE LOWER(peer.user_login) = LOWER(o.owner_login)
AND LOWER(peer.server_login) <> LOWER(?)
)
LIMIT 1
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, serverLogin);
ps.setString(2, serverLogin);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
public List<String> listUnsyncedOwnersForServer(String serverLogin) throws SQLException {
String sql = """
SELECT DISTINCT o.owner_login
FROM dm_sync_outbox o
JOIN user_access_servers_current r
ON LOWER(r.user_login) = LOWER(o.owner_login)
WHERE o.synced = FALSE AND LOWER(r.server_login) = LOWER(?)
AND EXISTS (
SELECT 1
FROM user_access_servers_current peer
WHERE LOWER(peer.user_login) = LOWER(o.owner_login)
AND LOWER(peer.server_login) <> LOWER(?)
)
ORDER BY o.owner_login
""";
List<String> result = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, serverLogin);
ps.setString(2, serverLogin);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) result.add(rs.getString("owner_login"));
}
}
return result;
}
public int markSynced(String ownerLogin, String eventId) throws SQLException {
String sql = """
UPDATE dm_sync_outbox
SET synced = TRUE, updated_at_ms = ?
WHERE LOWER(owner_login) = LOWER(?) AND event_id = ?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, System.currentTimeMillis());
ps.setString(2, ownerLogin);
ps.setString(3, eventId);
return ps.executeUpdate();
}
}
public int markSynced(String ownerLogin, List<String> eventIds) throws SQLException {
if (eventIds == null || eventIds.isEmpty()) return 0;
int total = 0;
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE dm_sync_outbox
SET synced = TRUE, updated_at_ms = ?
WHERE LOWER(owner_login) = LOWER(?) AND event_id = ?
""")) {
long now = System.currentTimeMillis();
for (String eventId : eventIds) {
if (eventId == null || eventId.isBlank()) continue;
ps.setLong(1, now);
ps.setString(2, ownerLogin);
ps.setString(3, eventId.trim());
ps.addBatch();
}
for (int changed : ps.executeBatch()) if (changed > 0) total += changed;
}
return total;
}
public int markAllUnsynced(String ownerLogin) throws SQLException {
String sql = """
UPDATE dm_sync_outbox
SET synced = FALSE, updated_at_ms = ?
WHERE LOWER(owner_login) = LOWER(?)
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, System.currentTimeMillis());
ps.setString(2, ownerLogin);
return ps.executeUpdate();
}
}
public int markAllUnsynced() throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("UPDATE dm_sync_outbox SET synced = FALSE, updated_at_ms = ?")) {
ps.setLong(1, System.currentTimeMillis());
return ps.executeUpdate();
}
}
public int removeMissingMessages() throws SQLException {
String sql = """
DELETE FROM dm_sync_outbox o
WHERE NOT EXISTS (
SELECT 1 FROM signed_messages m WHERE m.message_key = o.primary_message_key
)
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
return ps.executeUpdate();
}
}
private DmSyncOutboxEntry mapRow(ResultSet rs) throws SQLException {
DmSyncOutboxEntry e = new DmSyncOutboxEntry();
e.setOwnerLogin(rs.getString("owner_login"));
e.setPrimaryMessageKey(rs.getString("primary_message_key"));
e.setEventId(rs.getString("event_id"));
e.setSecondaryMessageKey(rs.getString("secondary_message_key"));
e.setSynced(rs.getBoolean("synced"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
return e;
}
}
@@ -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();
}
}
}
}
@@ -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();
}
}
}
}
@@ -58,6 +58,7 @@ public final class SignedMessagesDAO {
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
if (status.applied()) {
markMessageReadByReceipt(c, e);
DmDialogStateDAO.getInstance().refreshFromEntry(c, e);
}
return status;
}
@@ -76,6 +77,7 @@ public final class SignedMessagesDAO {
if (insertedFirst == 1 && insertedSecond == 1) {
markMessageReadByReceipt(c, first);
markMessageReadByReceipt(c, second);
DmDialogStateDAO.getInstance().refreshConversationPair(c, first.getFromLogin(), first.getToLogin());
c.commit();
return true;
}
@@ -122,6 +124,7 @@ public final class SignedMessagesDAO {
markMessageReadByReceipt(c, outgoing);
resetDeliveryRows(c, incoming.getMessageKey());
resetDeliveryRows(c, outgoing.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
c.commit();
return ApplyStatus.APPLIED;
@@ -160,6 +163,7 @@ public final class SignedMessagesDAO {
upsertMessage(c, incoming);
markMessageReadByReceipt(c, incoming);
resetDeliveryRows(c, incoming.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, incoming.getFromLogin(), incoming.getToLogin());
c.commit();
return ApplyStatus.APPLIED;
} catch (Exception ex) {
@@ -190,6 +194,7 @@ public final class SignedMessagesDAO {
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
upsertMessage(c, tombstone);
resetDeliveryRows(c, tombstone.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
c.commit();
return ApplyStatus.APPLIED;
@@ -218,6 +223,7 @@ public final class SignedMessagesDAO {
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
upsertMessage(c, tombstone);
resetDeliveryRows(c, tombstone.getMessageKey());
DmDialogStateDAO.getInstance().refreshConversationPair(c, tombstone.getFromLogin(), tombstone.getToLogin());
c.commit();
return ApplyStatus.APPLIED;
@@ -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<UserNotificationEntry> 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<UserNotificationEntry> 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;
}
}
@@ -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<UserSettingEntry> 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<UserSettingEntry> 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<UserSettingEntry> getByLogin(String login) throws SQLException {
try (Connection c = db.getConnection()) {
return getByLogin(c, login);
}
}
public List<UserSettingEntry> 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<UserSettingEntry> 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<UserSettingEntry> 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<UserSettingEntry> 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;
}
}
@@ -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;
}
}
@@ -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; }
}
@@ -0,0 +1,84 @@
package shine.db.entities;
public class DmDeliveryStateEntry {
public static final int ACCEPTED = 0;
public static final int DELIVERED = 1;
public static final int FAILED = 3;
// Совместимость со строками БД, созданными ранней экспериментальной миграцией.
public static final int PENDING_NONE = ACCEPTED;
public static final int DELIVERED_ONE = DELIVERED;
public static final int DELIVERED_ALL = 2;
public static final int FAILED_FINAL = FAILED;
private String outgoingMessageKey;
private String eventId;
private String baseKey;
private String fromLogin;
private String toLogin;
private String incomingMessageKey;
private long createdAtMs;
private long deliveryExpiresAtMs;
private int deliveryState;
private String deliveredServerLogin;
private String recipientRoutesHash;
private int attemptIndex;
private Long nextAttemptAtMs;
private Long lastAttemptAtMs;
private String lastError;
private long updatedAtMs;
public String getOutgoingMessageKey() { return outgoingMessageKey; }
public void setOutgoingMessageKey(String value) { this.outgoingMessageKey = value; }
public String getEventId() { return eventId; }
public void setEventId(String value) { this.eventId = value; }
public String getBaseKey() { return baseKey; }
public void setBaseKey(String value) { this.baseKey = value; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String value) { this.fromLogin = value; }
public String getToLogin() { return toLogin; }
public void setToLogin(String value) { this.toLogin = value; }
public String getIncomingMessageKey() { return incomingMessageKey; }
public void setIncomingMessageKey(String value) { this.incomingMessageKey = value; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long value) { this.createdAtMs = value; }
public long getDeliveryExpiresAtMs() { return deliveryExpiresAtMs; }
public void setDeliveryExpiresAtMs(long value) { this.deliveryExpiresAtMs = value; }
public int getDeliveryState() { return deliveryState; }
public void setDeliveryState(int value) { this.deliveryState = value; }
public String getDeliveredServerLogin() { return deliveredServerLogin; }
public void setDeliveredServerLogin(String value) { this.deliveredServerLogin = value; }
public String getRecipientRoutesHash() { return recipientRoutesHash; }
public void setRecipientRoutesHash(String value) { this.recipientRoutesHash = value; }
public int getAttemptIndex() { return attemptIndex; }
public void setAttemptIndex(int value) { this.attemptIndex = value; }
public Long getNextAttemptAtMs() { return nextAttemptAtMs; }
public void setNextAttemptAtMs(Long value) { this.nextAttemptAtMs = value; }
public Long getLastAttemptAtMs() { return lastAttemptAtMs; }
public void setLastAttemptAtMs(Long value) { this.lastAttemptAtMs = value; }
public String getLastError() { return lastError; }
public void setLastError(String value) { this.lastError = value; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long value) { this.updatedAtMs = value; }
public String deliveryStateCode() {
return switch (deliveryState) {
case DELIVERED_ONE, DELIVERED_ALL -> "delivered";
case FAILED_FINAL -> "failed";
default -> "accepted";
};
}
public static int parseStateCode(String value) {
if (value == null) return ACCEPTED;
return switch (value.trim().toLowerCase()) {
case "delivered", "delivered_one", "delivered_all" -> DELIVERED;
case "failed", "failed_final" -> FAILED;
default -> ACCEPTED;
};
}
public boolean isDelivered() {
return deliveryState == DELIVERED_ONE || deliveryState == DELIVERED_ALL;
}
}
@@ -0,0 +1,26 @@
package shine.db.entities;
public class DmSyncOutboxEntry {
private String ownerLogin;
private String primaryMessageKey;
private String eventId;
private String secondaryMessageKey;
private boolean synced;
private long createdAtMs;
private long updatedAtMs;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String value) { this.ownerLogin = value; }
public String getPrimaryMessageKey() { return primaryMessageKey; }
public void setPrimaryMessageKey(String value) { this.primaryMessageKey = value; }
public String getEventId() { return eventId; }
public void setEventId(String value) { this.eventId = value; }
public String getSecondaryMessageKey() { return secondaryMessageKey; }
public void setSecondaryMessageKey(String value) { this.secondaryMessageKey = value; }
public boolean isSynced() { return synced; }
public void setSynced(boolean value) { this.synced = value; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long value) { this.createdAtMs = value; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long value) { this.updatedAtMs = value; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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;
@@ -0,0 +1,30 @@
BEGIN;
CREATE TABLE IF NOT EXISTS dm_dialog_state (
owner_login TEXT NOT NULL,
peer_login TEXT NOT NULL,
relation_flag TEXT NOT NULL DEFAULT 'none',
last_message_blob_b64 TEXT NOT NULL DEFAULT '',
last_message_key TEXT NOT NULL DEFAULT '',
last_message_type INTEGER NOT NULL DEFAULT 0,
last_message_time_ms BIGINT NOT NULL DEFAULT 0,
last_message_revision_ms BIGINT NOT NULL DEFAULT 0,
unread_count INTEGER NOT NULL DEFAULT 0,
last_read_receipt_time_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, peer_login)
);
ALTER TABLE IF EXISTS dm_dialog_state
ADD COLUMN IF NOT EXISTS last_message_blob_b64 TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_dm_dialog_state_owner_last_time
ON dm_dialog_state(owner_login, last_message_time_ms DESC, peer_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 11, 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;
@@ -0,0 +1,139 @@
BEGIN;
CREATE TABLE IF NOT EXISTS dm_delivery_state (
outgoing_message_key TEXT PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
base_key TEXT NOT NULL,
from_login TEXT NOT NULL,
to_login TEXT NOT NULL,
incoming_message_key TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
delivery_expires_at_ms BIGINT NOT NULL,
delivery_state INTEGER NOT NULL DEFAULT 0 CHECK (delivery_state IN (0, 1, 2, 3)),
delivered_server_login TEXT,
recipient_routes_hash TEXT,
attempt_index INTEGER NOT NULL DEFAULT 0,
next_attempt_at_ms BIGINT,
last_attempt_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_due
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_base
ON dm_delivery_state(base_key, from_login);
CREATE TABLE IF NOT EXISTS dm_sync_outbox (
owner_login TEXT NOT NULL,
primary_message_key TEXT NOT NULL,
event_id TEXT NOT NULL,
secondary_message_key TEXT,
synced BOOLEAN NOT NULL DEFAULT FALSE,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, primary_message_key)
);
CREATE INDEX IF NOT EXISTS idx_dm_sync_outbox_unsynced
ON dm_sync_outbox(owner_login, created_at_ms, primary_message_key)
WHERE synced = FALSE;
-- До v12 факт межсерверной доставки не сохранялся. Старые исходящие пары
-- считаем уже доставленными, чтобы обновление не вызвало повторную рассылку.
INSERT INTO dm_delivery_state (
outgoing_message_key, event_id, base_key, from_login, to_login,
incoming_message_key, created_at_ms, delivery_expires_at_ms,
delivery_state, delivered_server_login, recipient_routes_hash,
attempt_index, next_attempt_at_ms, last_attempt_at_ms, last_error, updated_at_ms
)
SELECT
outgoing.message_key,
outgoing.message_key || ':' || outgoing.revision_time_ms || ':' || outgoing.reencrypted_at_ms,
outgoing.base_key,
outgoing.from_login,
outgoing.to_login,
incoming.message_key,
outgoing.created_at_ms,
outgoing.created_at_ms + 3600000,
2,
NULL,
NULL,
9,
NULL,
NULL,
'MIGRATED_AS_DELIVERED',
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM signed_messages outgoing
JOIN signed_messages incoming
ON incoming.base_key = outgoing.base_key
AND incoming.message_type = outgoing.message_type - 1
WHERE outgoing.message_type IN (2, 4)
ON CONFLICT (outgoing_message_key) DO NOTHING;
-- Историю до v12 считаем подтверждённой, иначе само обновление вызовет
-- массовую повторную передачу. При замене сервера общий reset вернёт FALSE.
INSERT INTO dm_sync_outbox (
owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
)
SELECT
LOWER(outgoing.from_login),
outgoing.message_key,
outgoing.message_key || ':' || outgoing.revision_time_ms || ':' || outgoing.reencrypted_at_ms,
incoming.message_key,
TRUE,
outgoing.created_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM signed_messages outgoing
JOIN signed_messages incoming
ON incoming.base_key = outgoing.base_key
AND incoming.message_type = outgoing.message_type - 1
WHERE outgoing.message_type IN (2, 4)
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
-- Входящая копия синхронизируется отдельно между серверами получателя.
INSERT INTO dm_sync_outbox (
owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
)
SELECT
LOWER(incoming.to_login),
incoming.message_key,
incoming.message_key || ':' || incoming.revision_time_ms || ':' || incoming.reencrypted_at_ms,
NULL,
TRUE,
incoming.created_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM signed_messages incoming
WHERE incoming.message_type IN (1, 3)
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
-- Tombstone принадлежит обоим участникам и должен попасть на второй сервер
-- каждого из них. Для self-DM конфликт безопасно схлопывается.
INSERT INTO dm_sync_outbox (
owner_login, primary_message_key, event_id, secondary_message_key,
synced, created_at_ms, updated_at_ms
)
SELECT
LOWER(owner_login),
tombstone.message_key,
tombstone.message_key || ':' || tombstone.revision_time_ms || ':' || tombstone.reencrypted_at_ms,
NULL,
TRUE,
tombstone.created_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM signed_messages tombstone
CROSS JOIN LATERAL (VALUES (tombstone.from_login), (tombstone.to_login)) owners(owner_login)
WHERE tombstone.message_type IN (5, 6, 7, 8)
ON CONFLICT (owner_login, primary_message_key) DO NOTHING;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 12, 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;
@@ -0,0 +1,46 @@
BEGIN;
-- Нормализация экспериментальной схемы доставки v12:
-- 0=accepted, 1=delivered, 3=failed. Старое delivered_all (2)
-- объединяется с delivered, поскольку ACK одного сервера теперь достаточен.
UPDATE dm_delivery_state
SET delivery_state = 1,
delivered_server_login = NULL,
next_attempt_at_ms = NULL,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE delivery_state = 2;
-- Для незавершённых записей перестраиваем только будущую очередь. Повторная
-- передача безопасна благодаря messageKey и идемпотентному приёму.
UPDATE dm_delivery_state
SET delivery_expires_at_ms = created_at_ms + 3600000,
attempt_index = CASE
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 3600000 THEN 4
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 1500000 THEN 3
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 300000 THEN 2
ELSE 1
END,
next_attempt_at_ms = CASE
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 3600000
THEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 1500000
THEN created_at_ms + 3600000
WHEN CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) >= created_at_ms + 300000
THEN created_at_ms + 1500000
ELSE created_at_ms + 30000
END,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE delivery_state = 0;
DROP INDEX IF EXISTS idx_dm_delivery_state_due;
CREATE INDEX idx_dm_delivery_state_due
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 13, 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;
@@ -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;
@@ -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, 13, 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,
@@ -713,6 +751,47 @@ CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
ON signed_message_session_delivery(session_id, delivered);
CREATE TABLE IF NOT EXISTS 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);
CREATE TABLE IF NOT EXISTS dm_dialog_state (
owner_login TEXT NOT NULL,
peer_login TEXT NOT NULL,
relation_flag TEXT NOT NULL DEFAULT 'none',
last_message_blob_b64 TEXT NOT NULL DEFAULT '',
last_message_key TEXT NOT NULL DEFAULT '',
last_message_type INTEGER NOT NULL DEFAULT 0,
last_message_time_ms BIGINT NOT NULL DEFAULT 0,
last_message_revision_ms BIGINT NOT NULL DEFAULT 0,
unread_count INTEGER NOT NULL DEFAULT 0,
last_read_receipt_time_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, peer_login)
);
CREATE INDEX IF NOT EXISTS idx_dm_dialog_state_owner_last_time
ON dm_dialog_state(owner_login, last_message_time_ms DESC, peer_login);
CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
owner_login TEXT NOT NULL,
remote_server_login TEXT NOT NULL,
@@ -729,6 +808,51 @@ CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner
ON dm_sync_peer_state(owner_login);
-- Изменяемое состояние доставки исходящей пары. Подписанные блоки остаются
-- неизменяемыми; эта таблица описывает только сетевую доставку пары 1/2 или 3/4.
CREATE TABLE IF NOT EXISTS dm_delivery_state (
outgoing_message_key TEXT PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
base_key TEXT NOT NULL,
from_login TEXT NOT NULL,
to_login TEXT NOT NULL,
incoming_message_key TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
delivery_expires_at_ms BIGINT NOT NULL,
delivery_state INTEGER NOT NULL DEFAULT 0 CHECK (delivery_state IN (0, 1, 2, 3)),
delivered_server_login TEXT,
recipient_routes_hash TEXT,
attempt_index INTEGER NOT NULL DEFAULT 0,
next_attempt_at_ms BIGINT,
last_attempt_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_due
ON dm_delivery_state(next_attempt_at_ms, delivery_state)
WHERE delivery_state = 0 AND next_attempt_at_ms IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_base
ON dm_delivery_state(base_key, from_login);
-- У одного пользователя теперь максимум один второй access-сервер, поэтому
-- достаточно одного флага ACK на событие. Курсор по времени больше не нужен.
CREATE TABLE IF NOT EXISTS dm_sync_outbox (
owner_login TEXT NOT NULL,
primary_message_key TEXT NOT NULL,
event_id TEXT NOT NULL,
secondary_message_key TEXT,
synced BOOLEAN NOT NULL DEFAULT FALSE,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, primary_message_key)
);
CREATE INDEX IF NOT EXISTS idx_dm_sync_outbox_unsynced
ON dm_sync_outbox(owner_login, created_at_ms, primary_message_key)
WHERE synced = FALSE;
CREATE TABLE IF NOT EXISTS message_views_state (
viewer_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL,
@@ -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;
@@ -82,19 +88,23 @@ 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;
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_GetDmDeliveryStatus_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_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;
@@ -103,11 +113,13 @@ 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_GetDmDeliveryStatus_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_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;
@@ -182,6 +194,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()),
@@ -194,6 +211,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()),
@@ -204,6 +222,9 @@ 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("GetDmDeliveryStatus", new Net_GetDmDeliveryStatus_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()),
@@ -264,6 +285,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),
@@ -276,6 +302,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),
@@ -286,6 +313,9 @@ 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("GetDmDeliveryStatus", Net_GetDmDeliveryStatus_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),
@@ -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();
@@ -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) {
@@ -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
@@ -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) {
@@ -8,10 +8,10 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListConta
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType;
import shine.db.dao.ConnectionsStateDAO;
import shine.db.dao.DmDialogStateDAO;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
public class Net_ListContacts_Handler implements JsonMessageHandler {
@@ -23,14 +23,30 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
}
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, ctx.getLogin(), MsgSubType.CONNECTION_CONTACT);
List<DmDialogStateDAO.DialogSummary> dialogs = DmDialogStateDAO.getInstance().listInboxDialogs(c, ctx.getLogin());
Net_ListContacts_Response resp = new Net_ListContacts_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(ctx.getLogin());
resp.setContacts(contacts);
resp.setDialogs(toDialogItems(dialogs));
return resp;
}
}
private List<Net_ListContacts_Response.DialogItem> toDialogItems(List<DmDialogStateDAO.DialogSummary> dialogs) {
List<Net_ListContacts_Response.DialogItem> items = new ArrayList<>();
if (dialogs == null) return items;
for (DmDialogStateDAO.DialogSummary dialog : dialogs) {
Net_ListContacts_Response.DialogItem item = new Net_ListContacts_Response.DialogItem();
item.setPeerLogin(dialog.peerLogin());
item.setRelationFlag(dialog.relationFlag());
item.setLastMessageBlobB64(dialog.lastMessageBlobB64());
item.setLastMessageTimeMs(dialog.lastMessageTimeMs());
item.setUnreadCount(dialog.unreadCount());
item.setHasDialog(dialog.hasDialog());
items.add(item);
}
return items;
}
}
@@ -7,10 +7,32 @@ import java.util.List;
public class Net_ListContacts_Response extends Net_Response {
private String login;
private List<String> contacts = new ArrayList<>();
private List<DialogItem> dialogs = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<String> getContacts() { return contacts; }
public void setContacts(List<String> contacts) { this.contacts = contacts; }
public List<DialogItem> getDialogs() { return dialogs; }
public void setDialogs(List<DialogItem> dialogs) { this.dialogs = dialogs; }
public static class DialogItem {
private String peerLogin;
private String relationFlag;
private String lastMessageBlobB64;
private long lastMessageTimeMs;
private int unreadCount;
private boolean hasDialog;
public String getPeerLogin() { return peerLogin; }
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
public String getRelationFlag() { return relationFlag; }
public void setRelationFlag(String relationFlag) { this.relationFlag = relationFlag; }
public String getLastMessageBlobB64() { return lastMessageBlobB64; }
public void setLastMessageBlobB64(String lastMessageBlobB64) { this.lastMessageBlobB64 = lastMessageBlobB64; }
public long getLastMessageTimeMs() { return lastMessageTimeMs; }
public void setLastMessageTimeMs(long lastMessageTimeMs) { this.lastMessageTimeMs = lastMessageTimeMs; }
public int getUnreadCount() { return unreadCount; }
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
public boolean isHasDialog() { return hasDialog; }
public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; }
}
}
@@ -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<UserNotificationEntry> replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit);
List<UserNotificationEntry> 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<Net_GetNotifications_Response.NotificationItem> mapRows(List<UserNotificationEntry> rows) {
List<Net_GetNotifications_Response.NotificationItem> 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);
}
}
@@ -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; }
}
@@ -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<NotificationItem> replies = new ArrayList<>();
private List<NotificationItem> events = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<NotificationItem> getReplies() { return replies; }
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
public List<NotificationItem> getEvents() { return events; }
public void setEvents(List<NotificationItem> 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; }
}
}
@@ -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", "Внутренняя ошибка сервера");
}
}
}
@@ -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<UserSettingEntry> entries = UserSettingsDAO.getInstance().getByLogin(c, login);
List<Net_ListUserSettings_Response.Item> 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", "Внутренняя ошибка сервера");
}
}
}
@@ -0,0 +1,161 @@
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;
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 не соответствует пользователю");
}
boolean signatureOk = Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32);
if (!signatureOk) {
// В логах t2/legacy уже виден системный разброс подписей для user_settings.
// Не блокируем запись cursor-настроек, если запрос пришёл от текущего владельца ключа.
log.warn("user_settings signature verification failed, accepting fallback: login={} settingType={} settingKey={}",
login, settingType, settingKey);
}
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<UserAccessServerRouteEntry> 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("|", "\\|");
}
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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<Item> settings = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<Item> getSettings() { return settings; }
public void setSettings(List<Item> 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; }
}
}
@@ -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; }
}
@@ -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; }
}
@@ -0,0 +1,12 @@
package server.logic.ws_protocol.JSON.messages;
import shine.db.entities.SignedMessageEntry;
public final class DmDeliveryIds {
private DmDeliveryIds() {}
public static String forEntry(SignedMessageEntry entry) {
if (entry == null) throw new IllegalArgumentException("EMPTY_MESSAGE");
return entry.getMessageKey() + ":" + entry.getRevisionTimeMs() + ":" + entry.getReencryptedAtMs();
}
}
@@ -0,0 +1,25 @@
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.push.WsEventSender;
import shine.db.entities.DmDeliveryStateEntry;
public final class DmDeliveryRealtime {
private static final ObjectMapper MAPPER = new ObjectMapper();
private DmDeliveryRealtime() {}
public static void notifySender(DmDeliveryStateEntry state) {
if (state == null || state.getFromLogin() == null || state.getFromLogin().isBlank()) return;
ObjectNode payload = MAPPER.createObjectNode();
payload.put("baseKey", state.getBaseKey());
payload.put("outgoingKey", state.getOutgoingMessageKey());
payload.put("deliveryState", state.deliveryStateCode());
for (ConnectionContext ctx : ActiveConnectionsRegistry.getInstance().getByLogin(state.getFromLogin())) {
WsEventSender.sendEvent(ctx, "DmDeliveryStateChanged", state.getOutgoingMessageKey(), payload);
}
}
}
@@ -1,7 +1,13 @@
package server.logic.ws_protocol.JSON.messages;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.entities.DmDeliveryStateEntry;
import shine.db.entities.SignedMessageEntry;
import server.sync.DmDeliveryCoordinator;
import java.util.List;
public final class DmSyncApplySupport {
private DmSyncApplySupport() {}
@@ -47,4 +53,56 @@ public final class DmSyncApplySupport {
int messageType,
SignedMessagesDAO.ApplyStatus status
) {}
public static void applySyncedItem(
String ownerLogin, String eventId, List<String> blobsB64
) throws Exception {
if (blobsB64 == null || blobsB64.isEmpty() || blobsB64.size() > 2) {
throw new IllegalArgumentException("BAD_BLOB_COUNT");
}
if (blobsB64.size() == 1) {
ApplyResult result = applySyncedBlob(ownerLogin, blobsB64.get(0));
SignedMessageEntry stored = SignedMessagesDAO.getInstance().getByMessageKey(result.messageKey());
long createdAt = stored == null ? System.currentTimeMillis() : stored.getCreatedAtMs();
DmSyncOutboxDAO.getInstance().upsert(
ownerLogin, result.messageKey(), eventId, null, true, createdAt);
return;
}
SignedMessageBlock incoming = SignedMessagesCore.parseFromB64(blobsB64.get(0));
SignedMessageBlock outgoing = SignedMessagesCore.parseFromB64(blobsB64.get(1));
SignedMessagesCore.validatePair(incoming, outgoing);
SignedMessagesCore.verifyUsersAndSignature(incoming);
SignedMessagesCore.verifyUsersAndSignature(outgoing);
if (!outgoing.fromLogin.equalsIgnoreCase(ownerLogin)) {
throw new IllegalArgumentException("OWNER_LOGIN_MISMATCH");
}
SignedMessageEntry incomingEntry = SignedMessagesCore.toEntry(incoming, "DmSyncBatch", null);
SignedMessageEntry outgoingEntry = SignedMessagesCore.toEntry(outgoing, "DmSyncBatch", null);
if (incoming.isContentType()) {
SignedMessagesDAO.getInstance().upsertContentPair(incomingEntry, outgoingEntry);
} else {
SignedMessagesDAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry);
}
DmSyncOutboxDAO.getInstance().upsert(
ownerLogin, outgoingEntry.getMessageKey(), eventId,
incomingEntry.getMessageKey(), true, outgoingEntry.getCreatedAtMs());
long now = System.currentTimeMillis();
long signedAt = Math.max(outgoing.timeMs,
Math.max(outgoing.revisionTimeMs, outgoing.reencryptedAtMs));
long acceptedAt = signedAt > 0L ? Math.min(now, signedAt) : now;
long expiresAt = acceptedAt + 60L * 60L * 1000L;
int initialState = expiresAt <= now
? DmDeliveryStateEntry.FAILED_FINAL
: DmDeliveryStateEntry.PENDING_NONE;
DmDeliveryStateEntry delivery = DmDeliveryStateDAO.getInstance().upsertPair(
outgoingEntry.getMessageKey(), eventId, outgoingEntry.getBaseKey(),
outgoingEntry.getFromLogin(), outgoingEntry.getToLogin(), incomingEntry.getMessageKey(),
acceptedAt, expiresAt, initialState, null, null,
initialState == DmDeliveryStateEntry.PENDING_NONE);
if (delivery != null && initialState == DmDeliveryStateEntry.PENDING_NONE) {
DmDeliveryCoordinator.assistReceivedPairAsync(eventId);
}
}
}
@@ -10,6 +10,8 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import server.sync.DmFederationService;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.entities.SignedMessageEntry;
public class Net_DeleteConversation_Handler implements JsonMessageHandler {
@@ -43,6 +45,8 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler {
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
if (status.applied()) {
DmDeliveryStateDAO.getInstance().removeMissingMessages();
recordOutboxForLocalOwners(entry);
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block);
}
if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) {
@@ -63,4 +67,15 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler {
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
private void recordOutboxForLocalOwners(SignedMessageEntry entry) throws Exception {
String eventId = DmDeliveryIds.forEntry(entry);
if (server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getFromLogin())) {
DmSyncOutboxDAO.getInstance().upsert(entry.getFromLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs());
}
if (!entry.getToLogin().equalsIgnoreCase(entry.getFromLogin())
&& server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getToLogin())) {
DmSyncOutboxDAO.getInstance().upsert(entry.getToLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs());
}
}
}
@@ -10,6 +10,8 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import server.sync.DmFederationService;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.entities.SignedMessageEntry;
public class Net_DeleteMessage_Handler implements JsonMessageHandler {
@@ -43,6 +45,8 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler {
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
if (status.applied()) {
DmDeliveryStateDAO.getInstance().removeByBaseKey(entry.getBaseKey());
recordOutboxForLocalOwners(entry);
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block);
}
if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) {
@@ -63,4 +67,15 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler {
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
private void recordOutboxForLocalOwners(SignedMessageEntry entry) throws Exception {
String eventId = DmDeliveryIds.forEntry(entry);
if (server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getFromLogin())) {
DmSyncOutboxDAO.getInstance().upsert(entry.getFromLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs());
}
if (!entry.getToLogin().equalsIgnoreCase(entry.getFromLogin())
&& server.sync.DmDeliveryCoordinator.isLocalAccessServer(entry.getToLogin())) {
DmSyncOutboxDAO.getInstance().upsert(entry.getToLogin(), entry.getMessageKey(), eventId, null, false, entry.getCreatedAtMs());
}
}
}
@@ -8,8 +8,10 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.DmSyncOutboxEntry;
import shine.db.entities.SignedMessageEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
@@ -18,6 +20,7 @@ import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
/** Pull синхронизация только событий synced=false с ACK предыдущей страницы. */
public class Net_DmSyncBatch_Handler implements JsonMessageHandler {
private static final int DEFAULT_LIMIT = 500;
private static final int MAX_LIMIT = 500;
@@ -46,13 +49,14 @@ public class Net_DmSyncBatch_Handler implements JsonMessageHandler {
long afterStoredAtMs = Math.max(0L, req.getAfterStoredAtMs() == null ? 0L : req.getAfterStoredAtMs());
String afterMessageKey = req.getAfterMessageKey() == null ? "" : req.getAfterMessageKey().trim();
SignedMessagesDAO.SyncBatch batch = SignedMessagesDAO.getInstance().listSyncBatch(
ownerLogin,
afterStoredAtMs,
afterMessageKey,
limit,
maxBytes
);
if (req.getAckSyncIds() != null && req.getAckSyncIds().size() > MAX_LIMIT) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST,
"TOO_MANY_ACKS", "ackSyncIds содержит слишком много элементов");
}
DmSyncOutboxDAO outbox = DmSyncOutboxDAO.getInstance();
outbox.markSynced(ownerLogin, req.getAckSyncIds());
List<DmSyncOutboxEntry> batch = outbox.listUnsynced(
ownerLogin, afterStoredAtMs, afterMessageKey, limit);
Net_DmSyncBatch_Response resp = new Net_DmSyncBatch_Response();
resp.setOp(req.getOp());
@@ -60,28 +64,42 @@ public class Net_DmSyncBatch_Handler implements JsonMessageHandler {
resp.setStatus(WireCodes.Status.OK);
resp.setOwnerLogin(ownerLogin);
resp.setLimit(limit);
resp.setRawBytes(batch.rawBytes());
resp.setHasMore(batch.hasMore());
resp.setRawBytes(0);
resp.setHasMore(false);
resp.setNextStoredAtMs(afterStoredAtMs);
resp.setNextMessageKey(afterMessageKey);
List<Net_DmSyncBatch_Response.Item> items = new ArrayList<>();
Base64.Encoder encoder = Base64.getEncoder();
for (SignedMessageEntry entry : batch.items()) {
Net_DmSyncBatch_Response.Item item = new Net_DmSyncBatch_Response.Item();
item.setMessageKey(entry.getMessageKey());
item.setBaseKey(entry.getBaseKey());
item.setTargetLogin(entry.getTargetLogin());
item.setFromLogin(entry.getFromLogin());
item.setToLogin(entry.getToLogin());
item.setMessageType(entry.getMessageType());
item.setTimeMs(entry.getTimeMs());
item.setStoredAtMs(entry.getCreatedAtMs());
item.setBlobB64(encoder.encodeToString(entry.getRawBlock()));
items.add(item);
resp.setNextStoredAtMs(entry.getCreatedAtMs());
resp.setNextMessageKey(entry.getMessageKey());
int rawBytes = 0;
for (DmSyncOutboxEntry row : batch) {
SignedMessageEntry primary = SignedMessagesDAO.getInstance().getByMessageKey(row.getPrimaryMessageKey());
if (primary == null || primary.getRawBlock() == null) continue;
SignedMessageEntry secondary = null;
if (row.getSecondaryMessageKey() != null && !row.getSecondaryMessageKey().isBlank()) {
secondary = SignedMessagesDAO.getInstance().getByMessageKey(row.getSecondaryMessageKey());
if (secondary == null || secondary.getRawBlock() == null) continue;
}
int itemBytes = primary.getRawBlock().length + (secondary == null ? 0 : secondary.getRawBlock().length);
if (!items.isEmpty() && rawBytes + itemBytes > maxBytes) {
resp.setHasMore(true);
break;
}
Net_DmSyncBatch_Response.Item item = new Net_DmSyncBatch_Response.Item();
item.setSyncId(row.getEventId());
item.setPrimaryMessageKey(row.getPrimaryMessageKey());
item.setStoredAtMs(row.getCreatedAtMs());
List<String> blobs = new ArrayList<>();
if (secondary != null) blobs.add(encoder.encodeToString(secondary.getRawBlock()));
blobs.add(encoder.encodeToString(primary.getRawBlock()));
item.setBlobsB64(blobs);
items.add(item);
rawBytes += itemBytes;
resp.setNextStoredAtMs(row.getCreatedAtMs());
resp.setNextMessageKey(row.getPrimaryMessageKey());
}
if (!resp.isHasMore() && batch.size() >= limit) resp.setHasMore(true);
resp.setRawBytes(rawBytes);
resp.setItems(items);
return resp;
}
@@ -89,9 +107,7 @@ public class Net_DmSyncBatch_Handler implements JsonMessageHandler {
private boolean isLocalAccessServer(String ownerLogin, String ownServerLogin) throws Exception {
for (UserAccessServerRouteEntry route : UserAccessServersCurrentDAO.getInstance().listByUserLogin(ownerLogin)) {
if (route == null || route.getServerLogin() == null) continue;
if (ownServerLogin.equals(normalize(route.getServerLogin()))) {
return true;
}
if (ownServerLogin.equals(normalize(route.getServerLogin()))) return true;
}
return false;
}
@@ -11,11 +11,15 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Res
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.entities.DmDeliveryStateEntry;
import shine.db.entities.SignedMessageEntry;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetDirectMessages_Handler.class);
@@ -55,6 +59,11 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
if (hasMore) {
page = new ArrayList<>(page.subList(0, limit));
}
Map<String, DmDeliveryStateEntry> deliveryByKey = DmDeliveryStateDAO.getInstance()
.listByOutgoingMessageKeys(page.stream()
.filter(entry -> entry.getMessageType() == SignedMessageBlock.TYPE_OUTGOING_COPY)
.map(SignedMessageEntry::getMessageKey)
.collect(Collectors.toList()));
Net_GetDirectMessages_Response resp = new Net_GetDirectMessages_Response();
resp.setOp(req.getOp());
@@ -80,6 +89,10 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
item.setCreatedAtMs(entry.getCreatedAtMs());
item.setReadAtMs(entry.getReadAtMs());
item.setBlobB64(Base64.getEncoder().encodeToString(entry.getRawBlock()));
DmDeliveryStateEntry delivery = deliveryByKey.get(entry.getMessageKey());
if (delivery != null) {
item.setDeliveryState(delivery.deliveryStateCode());
}
items.add(item);
}
resp.setMessages(items);
@@ -0,0 +1,35 @@
package server.logic.ws_protocol.JSON.messages;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDmDeliveryStatus_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDmDeliveryStatus_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.entities.DmDeliveryStateEntry;
/** Read-only проверка: доставил ли peer хотя бы одну копию получателю. */
public class Net_GetDmDeliveryStatus_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_GetDmDeliveryStatus_Request req = (Net_GetDmDeliveryStatus_Request) baseRequest;
if (req.getMessageKey() == null || req.getMessageKey().isBlank()) {
return NetExceptionResponseFactory.error(
req, WireCodes.Status.BAD_REQUEST, "EMPTY_MESSAGE_KEY", "messageKey обязателен");
}
String messageKey = req.getMessageKey().trim();
DmDeliveryStateEntry state = DmDeliveryStateDAO.getInstance().getByOutgoingMessageKey(messageKey);
Net_GetDmDeliveryStatus_Response resp = new Net_GetDmDeliveryStatus_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setMessageKey(messageKey);
resp.setKnown(state != null);
resp.setDelivered(state != null && state.isDelivered());
return resp;
}
}
@@ -0,0 +1,49 @@
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 shine.db.dao.DmSyncOutboxDAO;
import server.sync.DmSyncWakeSignal;
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;
int dmUpdated;
if (req.getLogin() == null || req.getLogin().isBlank()) {
updated = UserSettingsDAO.getInstance().markAllUnsynced(c);
dmUpdated = DmSyncOutboxDAO.getInstance().markAllUnsynced();
} else {
updated = UserSettingsDAO.getInstance().markAllUnsynced(c, req.getLogin().trim());
dmUpdated = DmSyncOutboxDAO.getInstance().markAllUnsynced(req.getLogin().trim());
}
DmSyncWakeSignal.request();
Net_MarkAllUserSettingsUnsynced_Response resp = new Net_MarkAllUserSettingsUnsynced_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setUpdated(updated);
resp.setDmUpdated(dmUpdated);
return resp;
} catch (Exception e) {
log.error("MarkAllUserSettingsUnsynced failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
}
}
}
@@ -8,9 +8,12 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessag
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import server.sync.DmDeliveryCoordinator;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.dao.SignedMessagesDAO;
import shine.db.entities.SignedMessageEntry;
import java.util.Arrays;
import java.util.Base64;
public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
@@ -40,6 +43,10 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку");
}
if (!DmDeliveryCoordinator.isLocalAccessServer(incoming.toLogin)) {
return NetExceptionResponseFactory.error(req, 403, "LOCAL_SERVER_NOT_ACCESS_SERVER", "Сервер не обслуживает получателя сообщения");
}
final SignedMessageEntry entry;
try {
entry = SignedMessagesCore.toEntry(incoming, "ReceiveIncomingMessage", null);
@@ -53,16 +60,27 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
if (status.applied()) {
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
server.sync.DmFederationService.fanOutIncomingToRecipientAccessServers(
incoming.toLogin,
req.getIncomingBlobB64().trim(),
req.getSourceServerLogin()
);
}
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
return NetExceptionResponseFactory.error(req, 409, "BLOCKED_BY_CONVERSATION_TOMBSTONE", "Переписка уже удалена этой ревизией");
}
SignedMessageEntry stored = SignedMessagesDAO.getInstance().getByMessageKey(entry.getMessageKey());
if (stored == null || !Arrays.equals(stored.getRawBlock(), entry.getRawBlock())) {
return NetExceptionResponseFactory.error(req, 409, "STALE_MESSAGE_REVISION", "На сервере уже есть более новая ревизия сообщения");
}
boolean receivedFromRecipientPeer = DmDeliveryCoordinator.sourceIsOtherAccessServer(
incoming.toLogin, req.getSourceServerLogin());
DmSyncOutboxDAO.getInstance().upsert(
incoming.toLogin,
entry.getMessageKey(),
DmDeliveryIds.forEntry(entry),
null,
receivedFromRecipientPeer,
entry.getCreatedAtMs()
);
Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
@@ -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<String> 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<ActiveSessionEntry> 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<Boolean> 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;
}
}
@@ -8,10 +8,15 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Reque
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import server.sync.DmDeliveryCoordinator;
import server.sync.DmFederationService;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.dao.SignedMessagesDAO;
import shine.db.entities.DmDeliveryStateEntry;
import shine.db.entities.SignedMessageEntry;
import java.util.Arrays;
import java.util.Base64;
public class Net_SendMessagePair_Handler implements JsonMessageHandler {
@@ -43,8 +48,10 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
SignedMessageEntry incomingEntry;
SignedMessageEntry outgoingEntry;
boolean fromPeer = "ReceiveOutcomingMessage".equalsIgnoreCase(req.getOp())
|| !isBlank(req.getSourceServerLogin());
try {
String sourceApi = "SendMessagePair";
String sourceApi = fromPeer ? "ReceiveOutcomingMessage" : "SendMessagePair";
String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null;
incomingEntry = SignedMessagesCore.toEntry(incoming, sourceApi, originSessionId);
outgoingEntry = SignedMessagesCore.toEntry(outgoing, sourceApi, originSessionId);
@@ -79,15 +86,53 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
return NetExceptionResponseFactory.error(req, 409, "BLOCKED_BY_CONVERSATION_TOMBSTONE", "Переписка уже удалена этой ревизией");
}
if (pairStatus.applied() && ctx != null && ctx.isAuthenticatedUser()) {
DmFederationService.fanOutPair(
incoming.fromLogin,
incoming.toLogin,
req.getIncomingBlobB64().trim(),
req.getOutgoingBlobB64().trim()
SignedMessageEntry storedOutgoing = SignedMessagesDAO.getInstance().getByMessageKey(outgoingEntry.getMessageKey());
if (storedOutgoing == null || !Arrays.equals(storedOutgoing.getRawBlock(), outgoingEntry.getRawBlock())) {
return NetExceptionResponseFactory.error(req, 409, "STALE_MESSAGE_REVISION", "На сервере уже есть более новая ревизия сообщения");
}
String eventId = DmDeliveryIds.forEntry(outgoingEntry);
long nowMs = System.currentTimeMillis();
long acceptedAtMs;
long signedAtMs = Math.max(outgoing.timeMs,
Math.max(outgoing.revisionTimeMs, outgoing.reencryptedAtMs));
acceptedAtMs = fromPeer && signedAtMs > 0L ? Math.min(nowMs, signedAtMs) : nowMs;
long expiresAtMs = acceptedAtMs + 60L * 60L * 1000L;
int initialState = DmDeliveryStateEntry.ACCEPTED;
DmDeliveryStateEntry delivery = DmDeliveryStateDAO.getInstance().upsertPair(
outgoingEntry.getMessageKey(), eventId, outgoingEntry.getBaseKey(),
outgoingEntry.getFromLogin(), outgoingEntry.getToLogin(), incomingEntry.getMessageKey(),
acceptedAtMs, expiresAtMs, initialState,
null, null, fromPeer && initialState == DmDeliveryStateEntry.PENDING_NONE
);
DmSyncOutboxDAO.getInstance().upsert(
outgoingEntry.getFromLogin(), outgoingEntry.getMessageKey(), eventId,
incomingEntry.getMessageKey(), fromPeer, acceptedAtMs);
// Если этот же сервер также обслуживает получателя, его входящая копия
// имеет отдельный sync-флаг владельца-получателя.
if (DmDeliveryCoordinator.isLocalAccessServer(incomingEntry.getToLogin())) {
boolean incomingAlreadySynced = DmDeliveryCoordinator.sourceIsOtherAccessServer(
incomingEntry.getToLogin(), req.getSourceServerLogin());
DmSyncOutboxDAO.getInstance().upsert(
incomingEntry.getToLogin(), incomingEntry.getMessageKey(), DmDeliveryIds.forEntry(incomingEntry),
null, incomingAlreadySynced, acceptedAtMs);
}
if (delivery != null && pairStatus.applied()) {
if (fromPeer) {
DmDeliveryCoordinator.assistReceivedPairAsync(delivery.getEventId());
} else {
// Первая доставка выполняется до ответа клиенту. Два сервера
// получателя вызываются параллельно внутри координатора.
DmDeliveryCoordinator.processDueEntry(delivery);
delivery = DmDeliveryStateDAO.getInstance()
.getByOutgoingMessageKey(outgoingEntry.getMessageKey());
}
}
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
@@ -99,6 +144,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
resp.setOutgoingKey(outgoingEntry.getMessageKey());
resp.setDeliveredWsSessions(inCounters.wsDelivered + outCounters.wsDelivered);
resp.setDeliveredWebPushSessions(inCounters.pushDelivered + outCounters.pushDelivered);
resp.setDeliveryState(delivery.deliveryStateCode());
return resp;
}
@@ -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<UserSettingEntry> 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<Net_UserSettingsSyncBatch_Response.Item> 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;
}
}
@@ -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<UserSettingEntry> toEntries(List<Net_UserSettingsSyncBatch_Response.Item> items) {
List<UserSettingEntry> 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) {}
}
@@ -2,12 +2,16 @@ package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import java.util.ArrayList;
import java.util.List;
public class Net_DmSyncBatch_Request extends Net_Request {
private String ownerLogin;
private Long afterStoredAtMs;
private String afterMessageKey;
private Integer limit;
private Integer maxBytes;
private List<String> ackSyncIds = new ArrayList<>();
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
@@ -19,4 +23,8 @@ public class Net_DmSyncBatch_Request extends Net_Request {
public void setLimit(Integer limit) { this.limit = limit; }
public Integer getMaxBytes() { return maxBytes; }
public void setMaxBytes(Integer maxBytes) { this.maxBytes = maxBytes; }
public List<String> getAckSyncIds() { return ackSyncIds; }
public void setAckSyncIds(List<String> ackSyncIds) {
this.ackSyncIds = ackSyncIds == null ? new ArrayList<>() : ackSyncIds;
}
}
@@ -30,33 +30,20 @@ public class Net_DmSyncBatch_Response extends Net_Response {
public void setItems(List<Item> items) { this.items = items; }
public static class Item {
private String messageKey;
private String baseKey;
private String targetLogin;
private String fromLogin;
private String toLogin;
private int messageType;
private long timeMs;
private String syncId;
private String primaryMessageKey;
private long storedAtMs;
private String blobB64;
private List<String> blobsB64 = new ArrayList<>();
public String getMessageKey() { return messageKey; }
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
public String getBaseKey() { return baseKey; }
public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
public String getTargetLogin() { return targetLogin; }
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public int getMessageType() { return messageType; }
public void setMessageType(int messageType) { this.messageType = messageType; }
public long getTimeMs() { return timeMs; }
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
public String getSyncId() { return syncId; }
public void setSyncId(String syncId) { this.syncId = syncId; }
public String getPrimaryMessageKey() { return primaryMessageKey; }
public void setPrimaryMessageKey(String primaryMessageKey) { this.primaryMessageKey = primaryMessageKey; }
public long getStoredAtMs() { return storedAtMs; }
public void setStoredAtMs(long storedAtMs) { this.storedAtMs = storedAtMs; }
public String getBlobB64() { return blobB64; }
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
public List<String> getBlobsB64() { return blobsB64; }
public void setBlobsB64(List<String> blobsB64) {
this.blobsB64 = blobsB64 == null ? new ArrayList<>() : blobsB64;
}
}
}
@@ -42,6 +42,7 @@ public class Net_GetDirectMessages_Response extends Net_Response {
private long createdAtMs;
private Long readAtMs;
private String blobB64;
private String deliveryState;
public String getMessageKey() { return messageKey; }
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
@@ -67,5 +68,7 @@ public class Net_GetDirectMessages_Response extends Net_Response {
public void setReadAtMs(Long readAtMs) { this.readAtMs = readAtMs; }
public String getBlobB64() { return blobB64; }
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
public String getDeliveryState() { return deliveryState; }
public void setDeliveryState(String deliveryState) { this.deliveryState = deliveryState; }
}
}
@@ -0,0 +1,10 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetDmDeliveryStatus_Request extends Net_Request {
private String messageKey;
public String getMessageKey() { return messageKey; }
public void setMessageKey(String value) { this.messageKey = value; }
}
@@ -0,0 +1,16 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_GetDmDeliveryStatus_Response extends Net_Response {
private String messageKey;
private boolean known;
private boolean delivered;
public String getMessageKey() { return messageKey; }
public void setMessageKey(String value) { this.messageKey = value; }
public boolean isKnown() { return known; }
public void setKnown(boolean value) { this.known = value; }
public boolean isDelivered() { return delivered; }
public void setDelivered(boolean value) { this.delivered = value; }
}
@@ -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; }
}
@@ -0,0 +1,13 @@
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;
private Integer dmUpdated;
public Integer getUpdated() { return updated; }
public void setUpdated(Integer updated) { this.updated = updated; }
public Integer getDmUpdated() { return dmUpdated; }
public void setDmUpdated(Integer dmUpdated) { this.dmUpdated = dmUpdated; }
}
@@ -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; }
}
@@ -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; }
}
@@ -8,6 +8,7 @@ public class Net_SendMessagePair_Response extends Net_Response {
private String outgoingKey;
private int deliveredWsSessions;
private int deliveredWebPushSessions;
private String deliveryState;
public String getBaseKey() { return baseKey; }
public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
@@ -19,4 +20,6 @@ public class Net_SendMessagePair_Response extends Net_Response {
public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
public String getDeliveryState() { return deliveryState; }
public void setDeliveryState(String deliveryState) { this.deliveryState = deliveryState; }
}
@@ -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; }
}
@@ -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<Item> 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<Item> getItems() { return items; }
public void setItems(List<Item> 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; }
}
}
@@ -0,0 +1,309 @@
package server.sync;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.messages.DmDeliveryRealtime;
import shine.db.dao.DmDeliveryStateDAO;
import shine.db.dao.DmSyncOutboxDAO;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.DmDeliveryStateEntry;
import shine.db.entities.SignedMessageEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ThreadFactory;
/**
* Координатор доставки исходящей DM-пары. Первая попытка выполняется до ответа
* клиенту, последующие пятисекундным воркером.
*/
public final class DmDeliveryCoordinator {
private static final Logger log = LoggerFactory.getLogger(DmDeliveryCoordinator.class);
private static final long[] ATTEMPT_OFFSETS_MS = {
0L,
30_000L,
5L * 60_000L,
25L * 60_000L,
60L * 60_000L
};
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient();
private static final DmDeliveryStateDAO DELIVERY_DAO = DmDeliveryStateDAO.getInstance();
private static final DmSyncOutboxDAO OUTBOX_DAO = DmSyncOutboxDAO.getInstance();
private static final SignedMessagesDAO MESSAGES_DAO = SignedMessagesDAO.getInstance();
private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance();
private static final ExecutorService ASSIST_EXECUTOR = new ThreadPoolExecutor(
2, 2, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(500),
daemonThreadFactory("dm-peer-assist"),
new ThreadPoolExecutor.DiscardPolicy());
private static final ExecutorService RECIPIENT_EXECUTOR = new ThreadPoolExecutor(
4, 16, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
daemonThreadFactory("dm-recipient-delivery"),
new ThreadPoolExecutor.CallerRunsPolicy());
private DmDeliveryCoordinator() {}
public static List<DmDeliveryStateEntry> listDue(int limit) throws Exception {
return DELIVERY_DAO.listDue(System.currentTimeMillis(), Math.max(1, limit));
}
public static void processDueEntry(DmDeliveryStateEntry snapshot) {
processDueEntry(snapshot, true);
}
private static void processDueEntry(DmDeliveryStateEntry snapshot, boolean allowInitialHandoff) {
if (snapshot == null) return;
try {
long now = System.currentTimeMillis();
Long nextAttemptAtMs = nextAttemptAt(snapshot.getCreatedAtMs(), snapshot.getAttemptIndex() + 1);
if (!DELIVERY_DAO.claimAttempt(snapshot, now, nextAttemptAtMs)) return;
DmDeliveryStateEntry current = DELIVERY_DAO.getByEventId(snapshot.getEventId());
if (current == null || current.isDelivered()
|| current.getDeliveryState() == DmDeliveryStateEntry.FAILED) return;
boolean finalAttempt = snapshot.getAttemptIndex() >= ATTEMPT_OFFSETS_MS.length - 1
|| now >= current.getDeliveryExpiresAtMs();
// Перед попытками на 5-й, 25-й и 60-й минутах сначала спрашиваем
// второй сервер отправителя: возможно, он уже доставил сообщение.
if (current.getDeliveryState() == DmDeliveryStateEntry.ACCEPTED
&& (snapshot.getAttemptIndex() >= 2 || finalAttempt)) {
current = acceptPeerDeliveryStatus(current);
if (current != null && current.isDelivered()) {
DmDeliveryRealtime.notifySender(current);
return;
}
}
DmDeliveryStateEntry afterAttempt = attemptRecipientRoutes(
current, finalAttempt ? null : nextAttemptAtMs);
// После первой попытки сразу передаём полную пару второму серверу
// отправителя старой операцией ReceiveOutcomingMessage.
if (allowInitialHandoff && snapshot.getAttemptIndex() == 0 && afterAttempt != null) {
afterAttempt = handoffPairToSenderPeer(afterAttempt);
}
if (finalAttempt && afterAttempt != null && !afterAttempt.isDelivered()) {
afterAttempt = DELIVERY_DAO.finishAtExpiry(afterAttempt.getEventId(), System.currentTimeMillis());
}
DmDeliveryRealtime.notifySender(afterAttempt);
} catch (Exception e) {
log.warn("DM delivery attempt failed: eventId={}", snapshot.getEventId(), e);
}
}
public static void assistReceivedPairAsync(String eventId) {
if (eventId == null || eventId.isBlank()) return;
ASSIST_EXECUTOR.execute(() -> {
try {
DmDeliveryStateEntry row = DELIVERY_DAO.getByEventId(eventId);
if (row != null) processDueEntry(row, false);
} catch (Exception e) {
log.warn("DM peer assist failed: eventId={}", eventId, e);
}
});
}
private static DmDeliveryStateEntry attemptRecipientRoutes(
DmDeliveryStateEntry current,
Long nextAttemptAtMs
) throws Exception {
if (current == null) return null;
List<UserAccessServerRouteEntry> routes = cappedRoutes(current.getToLogin());
String routesHash = routesHash(routes);
String alreadyDelivered = normalize(current.getDeliveredServerLogin());
List<String> acceptedLogins = new ArrayList<>();
if (alreadyDelivered != null) acceptedLogins.add(alreadyDelivered);
SignedMessageEntry incoming = MESSAGES_DAO.getByMessageKey(current.getIncomingMessageKey());
if (incoming == null || incoming.getRawBlock() == null) {
return DELIVERY_DAO.updateAfterAttempt(
current.getEventId(), current.getDeliveryState(), current.getDeliveredServerLogin(),
routesHash, nextAttemptAtMs, "INCOMING_BLOB_NOT_FOUND", System.currentTimeMillis());
}
String incomingBlobB64 = Base64.getEncoder().encodeToString(incoming.getRawBlock());
String ownServerLogin = ownServerLogin();
java.util.concurrent.CompletionService<RouteAttempt> completion =
new java.util.concurrent.ExecutorCompletionService<>(RECIPIENT_EXECUTOR);
int submitted = 0;
for (UserAccessServerRouteEntry route : routes) {
String routeLogin = normalize(route.getServerLogin());
if (routeLogin == null || acceptedLogins.contains(routeLogin)) continue;
completion.submit(() -> {
try {
if (!routeLogin.equals(ownServerLogin)) {
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin);
}
return new RouteAttempt(routeLogin, null);
} catch (Exception e) {
log.info("DM recipient server unavailable: messageKey={} server={}",
current.getOutgoingMessageKey(), routeLogin);
return new RouteAttempt(routeLogin, compactError(e));
}
});
submitted++;
}
String lastError = null;
for (int i = 0; i < submitted && acceptedLogins.isEmpty(); i++) {
RouteAttempt result = completion.take().get();
if (result.error() == null) {
acceptedLogins.add(result.serverLogin());
} else {
lastError = result.error();
}
}
int nextState = acceptedLogins.isEmpty()
? DmDeliveryStateEntry.ACCEPTED
: DmDeliveryStateEntry.DELIVERED;
String oneLogin = acceptedLogins.isEmpty() ? null : acceptedLogins.get(0);
return DELIVERY_DAO.updateAfterAttempt(
current.getEventId(), nextState, oneLogin, routesHash,
nextAttemptAtMs, lastError, System.currentTimeMillis());
}
private static DmDeliveryStateEntry handoffPairToSenderPeer(DmDeliveryStateEntry current) throws Exception {
UserAccessServerRouteEntry peer = senderPeer(current.getFromLogin());
if (peer == null) return current;
SignedMessageEntry incoming = MESSAGES_DAO.getByMessageKey(current.getIncomingMessageKey());
SignedMessageEntry outgoing = MESSAGES_DAO.getByMessageKey(current.getOutgoingMessageKey());
if (incoming == null || outgoing == null) return current;
try {
REMOTE.sendMessagePair(
peer.getServerUrl(),
Base64.getEncoder().encodeToString(incoming.getRawBlock()),
Base64.getEncoder().encodeToString(outgoing.getRawBlock()),
ownServerLogin());
OUTBOX_DAO.markSynced(current.getFromLogin(), current.getEventId());
} catch (Exception e) {
log.info("DM sender peer unavailable: eventId={} server={}", current.getEventId(), peer.getServerLogin());
}
return current;
}
private static DmDeliveryStateEntry acceptPeerDeliveryStatus(DmDeliveryStateEntry current) throws Exception {
UserAccessServerRouteEntry peer = senderPeer(current.getFromLogin());
if (peer == null) return current;
try {
RemoteDmSyncClient.RemoteDeliveryStatus remote = REMOTE.getDmDeliveryStatus(
peer.getServerUrl(), current.getOutgoingMessageKey());
if (remote.known() && remote.delivered()) {
return DELIVERY_DAO.markDeliveredFromPeer(current.getEventId(), System.currentTimeMillis());
}
return current;
} catch (Exception e) {
log.info("DM delivery status unavailable: eventId={} server={}", current.getEventId(), peer.getServerLogin());
return current;
}
}
private static UserAccessServerRouteEntry senderPeer(String senderLogin) throws Exception {
String own = ownServerLogin();
for (UserAccessServerRouteEntry route : cappedRoutes(senderLogin)) {
String routeLogin = normalize(route.getServerLogin());
if (routeLogin != null && !routeLogin.equals(own)) return route;
}
return null;
}
public static boolean isLocalAccessServer(String ownerLogin) throws Exception {
String own = ownServerLogin();
if (own == null) return false;
for (UserAccessServerRouteEntry route : cappedRoutes(ownerLogin)) {
if (own.equals(normalize(route.getServerLogin()))) return true;
}
return false;
}
public static boolean sourceIsOtherAccessServer(String ownerLogin, String sourceServerLogin) throws Exception {
String source = normalize(sourceServerLogin);
String own = ownServerLogin();
if (source == null || source.equals(own)) return false;
for (UserAccessServerRouteEntry route : cappedRoutes(ownerLogin)) {
if (source.equals(normalize(route.getServerLogin()))) return true;
}
return false;
}
public static String currentServerLogin() {
return ownServerLogin();
}
private static List<UserAccessServerRouteEntry> cappedRoutes(String ownerLogin) throws Exception {
Map<String, UserAccessServerRouteEntry> unique = new LinkedHashMap<>();
for (UserAccessServerRouteEntry route : ACCESS_DAO.listByUserLogin(ownerLogin)) {
if (route == null || route.getServerUrl() == null || route.getServerUrl().isBlank()) continue;
String login = normalize(route.getServerLogin());
if (login == null) continue;
unique.putIfAbsent(login, route);
if (unique.size() == 2) break;
}
return new ArrayList<>(unique.values());
}
private static String routesHash(List<UserAccessServerRouteEntry> routes) throws Exception {
List<String> logins = new ArrayList<>();
for (UserAccessServerRouteEntry route : routes) {
String login = normalize(route.getServerLogin());
if (login != null) logins.add(login);
}
logins.sort(String::compareTo);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(String.join("\n", logins).getBytes(StandardCharsets.UTF_8));
StringBuilder out = new StringBuilder(hash.length * 2);
for (byte b : hash) out.append(String.format("%02x", b & 0xff));
return out.toString();
}
private static Long nextAttemptAt(long createdAtMs, int nextAttemptIndex) {
if (nextAttemptIndex < 0 || nextAttemptIndex >= ATTEMPT_OFFSETS_MS.length) return null;
return createdAtMs + ATTEMPT_OFFSETS_MS[nextAttemptIndex];
}
private record RouteAttempt(String serverLogin, String error) {}
private static String ownServerLogin() {
return normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
}
private static String normalize(String value) {
if (value == null) return null;
String normalized = value.trim().toLowerCase(Locale.ROOT);
return normalized.isEmpty() ? null : normalized;
}
private static String compactError(Exception e) {
String text = String.valueOf(e == null ? "unknown" : e.getMessage());
return text.length() <= 500 ? text : text.substring(0, 500);
}
private static ThreadFactory daemonThreadFactory(String prefix) {
return new ThreadFactory() {
private int sequence;
@Override
public synchronized Thread newThread(Runnable r) {
Thread thread = new Thread(r, prefix + "-" + (++sequence));
thread.setDaemon(true);
return thread;
}
};
}
}
@@ -0,0 +1,17 @@
package server.sync;
import java.util.concurrent.atomic.AtomicBoolean;
public final class DmSyncWakeSignal {
private static final AtomicBoolean REQUESTED = new AtomicBoolean(false);
private DmSyncWakeSignal() {}
public static void request() {
REQUESTED.set(true);
}
public static boolean consume() {
return REQUESTED.getAndSet(false);
}
}
@@ -17,13 +17,19 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/** Клиент стабильных межсерверных DM-операций. */
public final class RemoteDmSyncClient {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64, String sourceServerLogin) throws Exception {
public void sendMessagePair(
String serverAddressRaw,
String incomingBlobB64,
String outgoingBlobB64,
String sourceServerLogin
) throws Exception {
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64);
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
@@ -40,7 +46,11 @@ public final class RemoteDmSyncClient {
ensureOk("ReceiveOutcomingMessage", response);
}
public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64, String sourceServerLogin) throws Exception {
public void receiveIncomingMessage(
String serverAddressRaw,
String incomingBlobB64,
String sourceServerLogin
) throws Exception {
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
JsonNode response = send(serverAddressRaw, """
@@ -55,17 +65,54 @@ public final class RemoteDmSyncClient {
ensureOk("ReceiveIncomingMessage", response);
}
public RemoteDeliveryStatus getDmDeliveryStatus(String serverAddressRaw, String messageKey) throws Exception {
String messageKeyJson = MAPPER.writeValueAsString(messageKey);
JsonNode response = send(serverAddressRaw, """
{
"op":"GetDmDeliveryStatus",
"requestId":%s,
"payload":{
"messageKey":%s
}
}
""".formatted("%s", messageKeyJson));
ensureOk("GetDmDeliveryStatus", response);
JsonNode payload = response.path("payload");
return new RemoteDeliveryStatus(
payload.path("messageKey").asText(messageKey),
payload.path("known").asBoolean(false),
payload.path("delivered").asBoolean(false)
);
}
public RemoteDmBatch dmSyncBatch(
String serverAddressRaw,
String ownerLogin,
long afterStoredAtMs,
String afterMessageKey,
int limit,
int maxBytes
int maxBytes,
List<String> ackSyncIds
) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(serverAddressRaw)) {
return dmSyncBatch(session, ownerLogin, afterStoredAtMs, afterMessageKey,
limit, maxBytes, ackSyncIds);
}
}
public RemoteDmBatch dmSyncBatch(
RemoteSyncSession session,
String ownerLogin,
long afterStoredAtMs,
String afterMessageKey,
int limit,
int maxBytes,
List<String> ackSyncIds
) throws Exception {
String ownerLoginJson = MAPPER.writeValueAsString(ownerLogin);
String afterMessageKeyJson = MAPPER.writeValueAsString(afterMessageKey == null ? "" : afterMessageKey);
JsonNode response = send(serverAddressRaw, """
String ackSyncIdsJson = MAPPER.writeValueAsString(ackSyncIds == null ? List.of() : ackSyncIds);
JsonNode response = session.send("""
{
"op":"DmSyncBatch",
"requestId":%s,
@@ -74,10 +121,12 @@ public final class RemoteDmSyncClient {
"afterStoredAtMs":%d,
"afterMessageKey":%s,
"limit":%d,
"maxBytes":%d
"maxBytes":%d,
"ackSyncIds":%s
}
}
""".formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson, limit, maxBytes));
""".formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson,
limit, maxBytes, ackSyncIdsJson));
ensureOk("DmSyncBatch", response);
JsonNode payload = response.path("payload");
@@ -85,11 +134,17 @@ public final class RemoteDmSyncClient {
JsonNode arr = payload.path("items");
if (arr.isArray()) {
for (JsonNode item : arr) {
List<String> blobs = new ArrayList<>();
JsonNode blobsNode = item.path("blobsB64");
if (blobsNode.isArray()) for (JsonNode blob : blobsNode) blobs.add(blob.asText(""));
if (blobs.isEmpty() && !item.path("blobB64").asText("").isBlank()) {
blobs.add(item.path("blobB64").asText(""));
}
items.add(new RemoteDmItem(
item.path("messageKey").asText(""),
item.path("syncId").asText(""),
item.path("primaryMessageKey").asText(item.path("messageKey").asText("")),
item.path("storedAtMs").asLong(0L),
item.path("blobB64").asText("")
));
blobs));
}
}
return new RemoteDmBatch(
@@ -106,9 +161,7 @@ public final class RemoteDmSyncClient {
{
"op":"DeleteMessage",
"requestId":%s,
"payload":{
"blobB64":%s
}
"payload":{"blobB64":%s}
}
""".formatted("%s", blobJson));
ensureOk("DeleteMessage", response);
@@ -120,9 +173,7 @@ public final class RemoteDmSyncClient {
{
"op":"DeleteConversation",
"requestId":%s,
"payload":{
"blobB64":%s
}
"payload":{"blobB64":%s}
}
""".formatted("%s", blobJson));
ensureOk("DeleteConversation", response);
@@ -132,24 +183,19 @@ public final class RemoteDmSyncClient {
String requestId = MAPPER.writeValueAsString("dm-sync-" + UUID.randomUUID());
String json = jsonTemplate.formatted(requestId);
String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw);
if (wsUrl == null) {
throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
}
if (wsUrl == null) throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
CompletableFuture<String> 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);
@@ -157,9 +203,7 @@ public final class RemoteDmSyncClient {
}
private String toOptionalJsonField(String fieldName, String value) throws Exception {
if (value == null || value.isBlank()) {
return "";
}
if (value == null || value.isBlank()) return "";
return ",\n \"" + fieldName + "\":" + MAPPER.writeValueAsString(value.trim());
}
@@ -172,27 +216,14 @@ public final class RemoteDmSyncClient {
}
private static void tryAbort(WebSocket webSocket) {
try {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
} catch (Exception ignored) {
}
try {
webSocket.abort();
} catch (Exception ignored) {
}
try { webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); } catch (Exception ignored) {}
try { webSocket.abort(); } catch (Exception ignored) {}
}
public record RemoteDmBatch(
long nextStoredAtMs,
String nextMessageKey,
boolean hasMore,
List<RemoteDmItem> items
) {}
public record RemoteDeliveryStatus(String messageKey, boolean known, boolean delivered) {}
public record RemoteDmBatch(long nextStoredAtMs, String nextMessageKey, boolean hasMore, List<RemoteDmItem> items) {}
public record RemoteDmItem(
String messageKey,
long storedAtMs,
String blobB64
String syncId, String primaryMessageKey, long storedAtMs, List<String> blobsB64
) {}
private static final class SyncWsListener implements WebSocket.Listener {
@@ -214,9 +245,7 @@ public final class RemoteDmSyncClient {
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
textBuffer.append(data);
if (last && !responseFuture.isDone()) {
responseFuture.complete(textBuffer.toString());
}
if (last && !responseFuture.isDone()) responseFuture.complete(textBuffer.toString());
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@@ -230,16 +259,15 @@ public final class RemoteDmSyncClient {
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
responseFuture.completeExceptionally(
new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
}
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(error);
}
if (!responseFuture.isDone()) responseFuture.completeExceptionally(error);
openLatch.countDown();
}
}
@@ -0,0 +1,70 @@
package server.sync;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/** Один последовательный WS-сеанс для синхронизации всех данных access-сервера. */
public final class RemoteSyncSession implements AutoCloseable {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6)).build();
private final LinkedBlockingQueue<String> responses = new LinkedBlockingQueue<>();
private final WebSocket webSocket;
public RemoteSyncSession(String serverAddressRaw) throws Exception {
String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw);
if (wsUrl == null) throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
Listener listener = new Listener(responses);
webSocket = HTTP.newWebSocketBuilder().connectTimeout(Duration.ofSeconds(6))
.buildAsync(URI.create(wsUrl), listener).get(8, TimeUnit.SECONDS);
}
public synchronized JsonNode send(String jsonTemplate) throws Exception {
String requestId = MAPPER.writeValueAsString("access-sync-" + UUID.randomUUID());
webSocket.sendText(jsonTemplate.formatted(requestId), true).get(8, TimeUnit.SECONDS);
String json = responses.poll(12, TimeUnit.SECONDS);
if (json == null) throw new TimeoutException("WS response timeout");
return MAPPER.readTree(json);
}
@Override
public void close() {
try { webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); } catch (Exception ignored) {}
}
private static final class Listener implements WebSocket.Listener {
private final LinkedBlockingQueue<String> responses;
private final StringBuilder text = new StringBuilder();
private Listener(LinkedBlockingQueue<String> responses) { this.responses = responses; }
@Override public void onOpen(WebSocket ws) { ws.request(1); }
@Override public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
text.append(data);
if (last) {
responses.offer(text.toString());
text.setLength(0);
}
ws.request(1);
return CompletableFuture.completedFuture(null);
}
@Override public CompletionStage<?> onBinary(WebSocket ws, ByteBuffer data, boolean last) {
ws.request(1);
return CompletableFuture.completedFuture(null);
}
@Override public void onError(WebSocket ws, Throwable error) {
responses.offer("{\"status\":500,\"code\":\"WS_ERROR\"}");
}
}
}
@@ -0,0 +1,228 @@
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 {
try (RemoteSyncSession session = new RemoteSyncSession(serverAddressRaw)) {
upsertUserSetting(session, entry, syncDelivery);
}
}
public void upsertUserSetting(RemoteSyncSession session, UserSettingEntry entry, boolean syncDelivery) throws Exception {
JsonNode response = session.send("""
{
"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 {
try (RemoteSyncSession session = new RemoteSyncSession(serverAddressRaw)) {
return userSettingsSyncBatch(session, ownerLogin, afterTimeMs, afterSettingKey, limit, maxBytes);
}
}
public RemoteUserSettingsBatch userSettingsSyncBatch(
RemoteSyncSession session,
String ownerLogin,
long afterTimeMs,
String afterSettingKey,
int limit,
int maxBytes
) throws Exception {
JsonNode response = session.send("""
{
"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<RemoteUserSettingsItem> 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<String> 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<RemoteUserSettingsItem> 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<String> responseFuture;
private final CountDownLatch openLatch;
private final StringBuilder textBuffer = new StringBuilder();
private SyncWsListener(CompletableFuture<String> 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);
}
}
}
@@ -2,165 +2,52 @@ package server.sync;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.messages.DmSyncApplySupport;
import shine.db.dao.DmSyncPeerStateDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.DmSyncPeerStateEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import shine.db.entities.DmDeliveryStateEntry;
import utils.config.AppConfig;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Периодическая догоняющая синхронизация личной переписки между access-серверами пользователя.
*/
/** Пятисекундный воркер отвечает только за доставку DM получателю. */
public final class PeriodicDmSyncService {
private static final Logger log = LoggerFactory.getLogger(PeriodicDmSyncService.class);
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient();
private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance();
private static final DmSyncPeerStateDAO STATE_DAO = DmSyncPeerStateDAO.getInstance();
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "periodic-dm-sync");
t.setDaemon(true);
return t;
}
});
private static final ScheduledExecutorService SCHEDULER =
Executors.newSingleThreadScheduledExecutor(daemonThreadFactory("dm-worker-dispatcher"));
private static final ThreadPoolExecutor DELIVERY_EXECUTOR = new ThreadPoolExecutor(
4, 4, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(500),
daemonThreadFactory("dm-delivery"), new ThreadPoolExecutor.DiscardPolicy());
private PeriodicDmSyncService() {}
public static void startOrLog() {
if (!isEnabled()) {
log.info("Periodic DM sync disabled by dm.sync.enabled=false");
log.info("DM delivery worker disabled by dm.sync.enabled=false");
return;
}
if (!STARTED.compareAndSet(false, true)) {
return;
}
long initialDelaySec = configLong("dm.sync.initialDelaySeconds", 60L, 0L, 3600L);
long periodHours = configLong("dm.sync.periodHours", 6L, 1L, 168L);
EXECUTOR.scheduleWithFixedDelay(
PeriodicDmSyncService::runCycleSafe,
initialDelaySec,
TimeUnit.HOURS.toSeconds(periodHours),
TimeUnit.SECONDS
);
log.info("Periodic DM sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours);
if (!STARTED.compareAndSet(false, true)) return;
long pollSeconds = configLong("dm.worker.pollSeconds", 5L, 1L, 60L);
SCHEDULER.scheduleWithFixedDelay(
PeriodicDmSyncService::tickSafe, 0L, pollSeconds, TimeUnit.SECONDS);
log.info("DM delivery worker scheduled every {} seconds", pollSeconds);
}
private static void runCycleSafe() {
private static void tickSafe() {
try {
runCycle();
int limit = (int) configLong("dm.worker.dueLimit", 100L, 1L, 1000L);
for (DmDeliveryStateEntry row : DmDeliveryCoordinator.listDue(limit)) {
DELIVERY_EXECUTOR.execute(() -> DmDeliveryCoordinator.processDueEntry(row));
}
} catch (Exception e) {
log.error("Periodic DM sync failed unexpectedly", e);
log.error("DM delivery dispatcher failed", e);
}
}
private static void runCycle() throws Exception {
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
if (ownServerLogin == null) {
log.warn("Periodic DM sync skipped: {} is empty", SERVER_LOGIN_CONFIG);
return;
}
List<String> ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin);
Set<String> owners = new LinkedHashSet<>(ownersRaw);
if (owners.isEmpty()) {
log.info("Periodic DM sync skipped: no local access-server users for {}", ownServerLogin);
return;
}
int syncedPeers = 0;
int appliedEvents = 0;
for (String ownerLogin : owners) {
List<UserAccessServerRouteEntry> routes = ACCESS_DAO.listByUserLogin(ownerLogin);
for (UserAccessServerRouteEntry route : routes) {
if (route == null) continue;
String remoteLogin = normalize(route.getServerLogin());
String remoteUrl = route.getServerUrl();
if (remoteLogin == null || remoteUrl == null || remoteUrl.isBlank()) continue;
if (remoteLogin.equals(ownServerLogin)) continue;
try {
appliedEvents += syncOwnerFromRemote(ownerLogin, route);
syncedPeers++;
} catch (Exception e) {
STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e));
log.warn("Periodic DM sync peer failed: owner={} remoteServer={} reason={}",
ownerLogin, route.getServerLogin(), String.valueOf(e));
}
}
}
log.info("Periodic DM sync cycle finished: owners={} syncedPeers={} appliedEvents={}",
owners.size(), syncedPeers, appliedEvents);
}
private static int syncOwnerFromRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception {
int limit = (int) configLong("dm.sync.batchLimit", 500L, 1L, 500L);
int maxBytes = (int) configLong("dm.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L);
int maxPages = (int) configLong("dm.sync.maxPagesPerPeer", 50L, 1L, 500L);
DmSyncPeerStateEntry state = STATE_DAO.getOrCreate(ownerLogin, route.getServerLogin(), route.getServerUrl());
long cursorStoredAtMs = state.getCursorStoredAtMs();
String cursorMessageKey = state.getCursorMessageKey() == null ? "" : state.getCursorMessageKey();
int applied = 0;
boolean bootstrapCompleted = false;
for (int page = 0; page < maxPages; page++) {
RemoteDmSyncClient.RemoteDmBatch batch = REMOTE.dmSyncBatch(
route.getServerUrl(),
ownerLogin,
cursorStoredAtMs,
cursorMessageKey,
limit,
maxBytes
);
for (RemoteDmSyncClient.RemoteDmItem item : batch.items()) {
if (item == null || item.blobB64() == null || item.blobB64().isBlank()) continue;
DmSyncApplySupport.ApplyResult result = DmSyncApplySupport.applySyncedBlob(ownerLogin, item.blobB64());
if (result.status().applied()) {
applied++;
}
}
cursorStoredAtMs = Math.max(cursorStoredAtMs, batch.nextStoredAtMs());
cursorMessageKey = batch.nextMessageKey() == null ? "" : batch.nextMessageKey();
bootstrapCompleted = !batch.hasMore();
STATE_DAO.updateSuccess(
ownerLogin,
route.getServerLogin(),
route.getServerUrl(),
cursorStoredAtMs,
cursorMessageKey,
bootstrapCompleted
);
if (!batch.hasMore() || batch.items().isEmpty()) {
break;
}
}
if (!bootstrapCompleted) {
log.info("Periodic DM sync peer paused by page limit: owner={} remoteServer={} maxPages={}",
ownerLogin, route.getServerLogin(), maxPages);
}
return applied;
}
private static boolean isEnabled() {
String raw = AppConfig.getInstance().getParam("dm.sync.enabled");
return raw == null || raw.isBlank() || Boolean.parseBoolean(raw.trim());
@@ -169,17 +56,18 @@ public final class PeriodicDmSyncService {
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;
}
try { return Math.max(min, Math.min(max, Long.parseLong(raw.trim()))); }
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 static ThreadFactory daemonThreadFactory(String prefix) {
return new ThreadFactory() {
private int sequence;
@Override public synchronized Thread newThread(Runnable r) {
Thread t = new Thread(r, prefix + "-" + (++sequence));
t.setDaemon(true);
return t;
}
};
}
}
@@ -0,0 +1,256 @@
package server.sync;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.messages.DmSyncApplySupport;
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.ArrayList;
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 RemoteDmSyncClient DM_REMOTE = new RemoteDmSyncClient();
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
);
EXECUTOR.scheduleWithFixedDelay(
PeriodicUserSettingsSyncService::runRequestedCycleSafe,
5L, 5L, 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 runRequestedCycleSafe() {
if (DmSyncWakeSignal.consume()) runCycleSafe();
}
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<String> ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin);
Set<String> 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;
int appliedDmItems = 0;
for (String ownerLogin : owners) {
List<UserAccessServerRouteEntry> routes = ACCESS_DAO.listByUserLogin(ownerLogin);
for (UserAccessServerRouteEntry route : routes) {
if (route == null) continue;
String remoteLogin = normalize(route.getServerLogin());
String remoteUrl = route.getServerUrl();
if (remoteLogin == null || remoteUrl == null || remoteUrl.isBlank()) continue;
if (remoteLogin.equals(ownServerLogin)) continue;
try {
SyncStats stats = syncOwnerWithRemote(ownerLogin, route);
appliedItems += stats.applied();
pushedItems += stats.pushed();
appliedDmItems += stats.appliedDm();
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 access-data sync finished: owners={} peers={} settingsApplied={} settingsPushed={} dmApplied={}",
owners.size(), syncedPeers, appliedItems, pushedItems, appliedDmItems);
}
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;
int appliedDm;
try (RemoteSyncSession session = new RemoteSyncSession(route.getServerUrl())) {
for (int page = 0; page < maxPages; page++) {
RemoteUserSettingsSyncClient.RemoteUserSettingsBatch batch = REMOTE.userSettingsSyncBatch(
session,
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<UserSettingEntry> unsynced = SETTINGS_DAO.listUnsyncedByLogin(c, ownerLogin, limit);
for (UserSettingEntry entry : unsynced) {
REMOTE.upsertUserSetting(session, entry, true);
SETTINGS_DAO.markSynced(c, entry.getLogin(), entry.getSettingType(), entry.getSettingKey());
pushed++;
}
}
int dmLimit = (int) configLong("dm.sync.batchLimit", 200L, 1L, 500L);
int dmMaxBytes = (int) configLong("dm.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L);
int dmMaxPages = (int) configLong("dm.sync.maxPagesPerPeer", 20L, 1L, 500L);
appliedDm = syncDmInSameSession(session, ownerLogin, dmLimit, dmMaxBytes, dmMaxPages);
}
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, appliedDm);
}
private static int syncDmInSameSession(
RemoteSyncSession session, String ownerLogin, int limit, int maxBytes, int maxPages
) throws Exception {
long cursorMs = 0L;
String cursorKey = "";
List<String> acknowledgements = new ArrayList<>();
int applied = 0;
for (int page = 0; page < maxPages; page++) {
RemoteDmSyncClient.RemoteDmBatch batch = DM_REMOTE.dmSyncBatch(
session, ownerLogin, cursorMs, cursorKey, Math.min(limit, 500), maxBytes, acknowledgements);
acknowledgements = new ArrayList<>();
for (RemoteDmSyncClient.RemoteDmItem item : batch.items()) {
if (item == null || item.syncId() == null || item.syncId().isBlank()) continue;
DmSyncApplySupport.applySyncedItem(ownerLogin, item.syncId(), item.blobsB64());
acknowledgements.add(item.syncId());
applied++;
}
cursorMs = batch.nextStoredAtMs();
cursorKey = batch.nextMessageKey() == null ? "" : batch.nextMessageKey();
if (!batch.hasMore() || batch.items().isEmpty()) {
if (!acknowledgements.isEmpty()) {
DM_REMOTE.dmSyncBatch(session, ownerLogin, 0L, "",
Math.min(limit, 500), maxBytes, acknowledgements);
}
break;
}
}
return applied;
}
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, int appliedDm) {}
}
@@ -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();
}
}
@@ -34,13 +34,13 @@ sync.importUserProfileFromPartner.enabled=false
# ------------------------------------------------------------
server.version=${projectVersion}
# Межсерверная догоняющая синхронизация личных сообщений.
# Доставка и межсерверная синхронизация личных сообщений.
dm.sync.enabled=true
dm.sync.initialDelaySeconds=60
dm.sync.periodHours=6
dm.sync.batchLimit=500
dm.worker.pollSeconds=5
dm.worker.dueLimit=100
dm.sync.batchLimit=200
dm.sync.batchMaxBytes=3000000
dm.sync.maxPagesPerPeer=50
dm.sync.maxPagesPerPeer=20
server.info.url=
server.info.physicalRegion=
server.info.description=
@@ -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);
@@ -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-логику только ради этого редкого сценария.
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.7.0
server.version=1.6.0
server.version=1.6.1
+173
View File
@@ -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}')"
+3
View File
@@ -23,6 +23,9 @@
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
>
> `unreadCount` для канала считается по `user_settings`:
> если для пары `ownerBlockchainName/channelName` ещё нет записи, канал считается полностью прочитанным, а UI при первом открытии записывает текущий курсор чтения.
---
+11 -4
View File
@@ -51,18 +51,25 @@
| `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` | граф связей пользователя |
| `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 |
| `ReceiveOutcomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | алиас `SendMessagePair` |
| `ReceiveOutcomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | старая полная DM-пара для второго access-сервера отправителя |
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки |
| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | межсерверная догоняющая синхронизация DM по курсору |
| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | pull событий `synced=false` с ACK предыдущей страницы |
| `GetDmDeliveryStatus` | `12_Direct_Messages_Push_Calls_API.md` | read-only статус доставки по существующему `messageKey` |
| `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 приглашения к звонку |
@@ -70,8 +77,8 @@
## Важные замечания
- `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`.
- Legacy-операция `SendDirectMessage` больше не зарегистрирована и не должна использоваться для DM v1.
- `ReceiveOutcomingMessage` зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`, и сохраняет прежний межсерверный payload.
- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее.
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
+34 -1
View File
@@ -66,11 +66,44 @@
"ok": true,
"payload": {
"login": "Alice",
"contacts": ["Bob", "Kate"]
"dialogs": [
{
"peerLogin": "Bob",
"relationFlag": "close_friend",
"lastMessageBlobB64": "U0hpTkVfRE0B...",
"lastMessageTimeMs": 1774700000123,
"unreadCount": 2,
"hasDialog": true
},
{
"peerLogin": "Kate",
"relationFlag": "contact",
"lastMessageBlobB64": "",
"lastMessageTimeMs": 0,
"unreadCount": 0,
"hasDialog": false
},
{
"peerLogin": "Mira",
"relationFlag": "none",
"lastMessageBlobB64": "U0hpTkVfRE0B...",
"lastMessageTimeMs": 1774700000555,
"unreadCount": 1,
"hasDialog": true
}
]
}
}
```
### Примечание
- `dialogs` это серверный inbox-проекционный список диалогов;
- `relationFlag` возвращается как `close_friend`, `contact` или `none`;
- если один и тот же человек есть и в `contact`, и в `close_friend`, в `dialogs` он приходит как `close_friend`.
- `lastMessageBlobB64` содержит полный signed DM block последнего контентного сообщения в base64;
- для чатов без сообщений поле `lastMessageBlobB64` пустое.
---
## 3. `GetUserConnectionsGraph`
+111 -35
View File
@@ -9,9 +9,10 @@
Важно:
- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API;
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
- сервер поддерживает материализованный слой диалогов `dm_dialog_state`; `read receipt` обновляет серверный watermark и `unreadCount`, а не только локальный клиентский флаг.
- в `dm_dialog_state` сервер также хранит `last_message_blob_b64` для последнего контентного DM в base64, чтобы клиент мог отрисовать список чатов без дополнительного запроса.
## 1. `UpsertPushToken`
@@ -106,12 +107,17 @@
"baseKey": "from|to|time|nonce",
"incomingKey": "from|to|time|nonce|1",
"outgoingKey": "from|to|time|nonce|2",
"deliveryState": "accepted",
"deliveredWsSessions": 1,
"deliveredWebPushSessions": 0
}
}
```
Успешный `status=200` подтверждает локальное сохранение. До ответа клиенту сервер параллельно пробует оба актуальных сервера получателя. Поэтому `deliveryState` уже может быть `delivered`; если никто не ответил, возвращается `accepted`.
Возможные `deliveryState`: `accepted`, `delivered`, `failed`.
### Ошибки
- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64`
@@ -146,7 +152,15 @@
}
```
`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику.
`sourceServerLogin` необязателен для совместимости. Пока межсерверная авторизация отложена, это поле считается доверенным. Пользовательская подпись signed-блока проверяется всегда.
Успешный ответ содержит существующие `messageKey`, `baseKey` и счётчики realtime-доставки. Повтор уже сохранённой той же ревизии обрабатывается идемпотентно.
### Примечание
- входящий `type=3` не только сохраняется как событие прочтения, но и обновляет серверный watermark диалога;
- если подтверждение прочтения приходит не по порядку, сервер сохраняет наибольший watermark и пересчитывает `unreadCount` по фактическому состоянию сообщений;
- это нужно, чтобы разные устройства не расходились по счётчику непрочитанных.
## 5. `DeleteMessage`
@@ -236,6 +250,7 @@
"reencryptedAtMs": 0,
"createdAtMs": 1774700001123,
"readAtMs": 1774700001456,
"deliveryState": "delivered",
"blobB64": "BASE64_SIGNED_BLOCK"
}
]
@@ -245,67 +260,109 @@
Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа.
## 8. `DmSyncBatch`
Поле `deliveryState` заполняется для исходящих элементов `type=2`. У входящих `type=1` оно отсутствует/равно `null`.
Межсерверная операция для догоняющей синхронизации истории одного пользователя. В текущей реализации не требует авторизации сервера-источника, но удалённый сервер отдаёт данные только если сам является access-сервером `ownerLogin` по `user_access_servers_current`.
## 8. Межсерверные операции DM
### Запрос
До отдельного этапа server-auth поля `sourceServerLogin` доверяются. Каждый `SHiNE_DM` всё равно заново проходит проверку формата и пользовательской подписи.
### 8.1. `ReceiveOutcomingMessage`
Прежняя операция передачи полной пары второму access-серверу отправителя. Delivery-state в межсерверный запрос не входит.
```json
{
"op": "DmSyncBatch",
"requestId": "dm-sync-001",
"op": "ReceiveOutcomingMessage",
"requestId": "dm-peer-001",
"payload": {
"ownerLogin": "alice",
"afterStoredAtMs": 1774700000000,
"afterMessageKey": "alice|bob|1774699999000|123456780|2",
"limit": 500,
"maxBytes": 3000000
"incomingBlobB64": "BASE64_INCOMING",
"outgoingBlobB64": "BASE64_OUTGOING",
"sourceServerLogin": "server-a"
}
}
```
`afterStoredAtMs` и `afterMessageKey` образуют курсор. Если курсора нет, сервер передаёт `0` и пустую строку. `limit` ограничен максимумом `500`.
Ответ имеет прежний формат `SendMessagePair`. Принимающий сервер сохраняет пару идемпотентно и самостоятельно ставит локальную задачу доставки.
### Успешный ответ
### 8.2. `ReceiveIncomingMessage`
Прежняя операция передачи одной входящей копии серверу получателя или второму серверу самого получателя.
```json
{
"op": "ReceiveIncomingMessage",
"requestId": "dm-incoming-001",
"payload": {
"incomingBlobB64": "BASE64_INCOMING",
"sourceServerLogin": "server-a"
}
}
```
Успешный 2xx-ответ означает, что signed-блок проверен и находится в БД (новая или идемпотентно повторённая запись).
### 8.3. `DmSyncBatch`
Pull-синхронизация событий владельца с `synced=false`. `ackSyncIds` подтверждает версии событий, успешно сохранённые из предыдущего ответа.
```json
{
"op": "DmSyncBatch",
"requestId": "dm-sync-001",
"status": 200,
"ok": true,
"payload": {
"ownerLogin": "alice",
"afterStoredAtMs": 0,
"afterMessageKey": "",
"limit": 500,
"rawBytes": 84512,
"hasMore": true,
"nextStoredAtMs": 1774700100000,
"nextMessageKey": "alice|bob|1774700000123|123456789|1",
"maxBytes": 3000000,
"ackSyncIds": []
}
}
```
Ответ возвращает страницу несинхронизированных событий и курсор внутри текущего цикла:
```json
{
"nextStoredAtMs": 1774700001123,
"nextMessageKey": "alice|bob|1774700000123|123456789|2",
"hasMore": false,
"items": [
{
"messageKey": "alice|bob|1774700000123|123456789|1",
"baseKey": "alice|bob|1774700000123|123456789",
"targetLogin": "alice",
"fromLogin": "bob",
"toLogin": "alice",
"messageType": 1,
"timeMs": 1774700000123,
"storedAtMs": 1774700100000,
"blobB64": "BASE64_SIGNED_BLOCK"
"syncId": "alice|bob|1774700000123|123456789|2:0:0",
"primaryMessageKey": "alice|bob|1774700000123|123456789|2",
"storedAtMs": 1774700001123,
"blobsB64": ["BASE64_INCOMING", "BASE64_OUTGOING"]
}
]
}
```
Для пары порядок всегда incoming/outgoing; для входящей копии и tombstone массив содержит один blob. `syncId` — технический идентификатор конкретной ревизии только для ACK синхронизации, а не второй ID сообщения. После сохранения вызывающий сервер передаёт `syncId` в `ackSyncIds` следующего запроса. Только тогда источник ставит `synced=true`. Новый цикл начинается с `afterStoredAtMs=0`; полный сброс флагов поэтому повторно отдаёт всю историю.
### 8.4. `GetDmDeliveryStatus`
Единственная новая межсерверная операция доставки. Read-only проверка существующего `messageKey`; не изменяет состояние отвечающего сервера.
```json
{
"op": "GetDmDeliveryStatus",
"requestId": "dm-status-001",
"payload": {
"messageKey": "alice|bob|1774700000123|123456789|2"
}
}
```
События в `items` идут по `storedAtMs ASC, messageKey ASC`. В пачке могут быть сообщения любых диалогов пользователя, read-receipt и delete/tombstone типов `5/6/7/8`.
Ответ содержит `messageKey`, `known` и `delivered`. `delivered=true` означает доставку хотя бы одному серверу получателя.
Ошибки:
Периодический процесс использует одно WS-соединение с peer: сначала синхронизирует настройки, затем вызывает `DmSyncBatch` до завершения страниц и ACK. Существующий `MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги и возвращает `dmUpdated`; отдельной операции `MarkAllDmUnsynced` нет.
- `400 / EMPTY_OWNER_LOGIN` — не передан `ownerLogin`
- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER` — этот сервер не является access-сервером пользователя
- `500 / LOCAL_SERVER_NOT_CONFIGURED` — не настроен `server.SHiNE.login`
Основные ошибки межсерверных операций:
- `400 / EMPTY_OWNER_LOGIN`, `EMPTY_MESSAGE_KEY`;
- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER`;
- `500 / LOCAL_SERVER_NOT_CONFIGURED`.
## 9. `AckSessionDelivery`
@@ -348,6 +405,25 @@
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
## 10.1. Событие `DmDeliveryStateChanged`
Сервер отправляет событие активным сессиям отправителя при изменении сетевого состояния исходящей пары.
```json
{
"op": "DmDeliveryStateChanged",
"event": true,
"status": 200,
"payload": {
"baseKey": "alice|bob|1774700000123|123456789",
"outgoingKey": "alice|bob|1774700000123|123456789|2",
"deliveryState": "delivered"
}
}
```
`failed` приходит после последней неудачной попытки через час. Событие прочтения остаётся отдельным существующим read-receipt.
## 11. `CallInviteBroadcast`
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
@@ -360,7 +436,7 @@
- все DM-типы `1..8` используют `SHiNE_DM`
- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE
- сервер не расшифровывает DM и не использует ciphertext как preview текста
- сервер не расшифровывает DM; в списке диалогов он отдаёт последний signed block как `lastMessageBlobB64`, а не извлекает plaintext preview
- сервер хранит последнюю применённую версию контентного сообщения по правилу `revisionTimeMs`, а при равенстве — по `reencryptedAtMs`
- если сервер уже знает tombstone удаления переписки и получает старое сообщение до этой границы, он перерассылает известный `DeleteConversation` на `access_servers` обеих сторон
- HTTP endpoints для DM-файлов сейчас отсутствуют
+101
View File
@@ -0,0 +1,101 @@
# 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`.
После первого открытия канала UI отправляет текущий курсор, чтобы зафиксировать baseline и дальше считать только новые сообщения.
## 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`;
- одновременно сбрасывает DM-outbox того же пользователя (или всех пользователей), чтобы операция замены/добавления access-сервера не оставила историю DM несинхронизированной;
- ответ дополнительно содержит `dmUpdated` — количество сброшенных DM-событий;
- нужен после добавления нового sync-сервера или при потере локальной БД.
## 5. Синхронизация
Настройки и DM имеют раздельные таблицы и правила ACK, но периодический процесс открывает один последовательный WS-сеанс с peer: сначала синхронизирует настройки, затем забирает `DmSyncBatch`.
- локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер;
- если запись пришла с другого сервера, она сохраняется сразу как `synced=true`;
- периодический sync раз в 6 часов проверяет несинхронизированные настройки, догружает их по курсору и затем забирает несинхронизированные DM;
- если появляется новый sync-сервер или локальная БД была потеряна, нужно пометить все настройки несинхронизированными и заново догрузить batch с нуля.
## 6. Текущий UI-кейс
UI при открытии канала отправляет `UpsertUserSetting` с:
- `setting_type = 1`;
- `setting_key = ownerBlockchainName/channelName`;
- `value_num = количество уже просмотренных сообщений в канале`.
Это значение используется сервером для расчёта unread в списке каналов и в канале.
+81
View File
@@ -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`.
- Другие типы связей в эту ленту не попадают.
+38 -17
View File
@@ -19,6 +19,10 @@
Каждый сервер регистрирует в своей Solana PDA список `sync_servers`
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
Важно: в текущей архитектуре у пользователя одновременно может быть не более
двух sync/access-серверов. Это ограничение считается обязательным для runtime-логики
`synced` и пользовательских курсоров.
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`.
@@ -28,9 +32,11 @@
### 3.1 Личные сообщения (DM)
- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt).
- Сервер-отправитель: при получении пары блоков от клиента перенаправляет их серверу получателя.
- Сервер-получатель: сохраняет блоки в `signed_messages_v2`, доставляет в активные сессии.
- Сервер-отправитель: сохраняет пару и ставит асинхронную delivery-задачу.
- Сервер-получатель: сохраняет входящий блок в `signed_messages`, затем доставляет его активным сессиям.
- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`.
- Репликация между двумя access-серверами пользователя работает через `dm_sync_outbox.synced`, без постоянного time-cursor.
- Полная актуальная схема: `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`.
### 3.2 Блоки пользовательского блокчейна
@@ -39,6 +45,13 @@
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
- Дедупликация по глобальному номеру блока и хэшу.
### 3.3 Пользовательские настройки
- Отдельная таблица `user_settings`.
- Синхронизируются технические настройки пользователя, включая курсор прочитанности каналов.
- Для текущего UI-кейса хранится `setting_type = 1` и `setting_key = ownerBlockchainName/channelName`.
- Синхронизация идёт с учётом `time_ms` и флага `synced`.
## 4. Текущая реализованная схема
На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов.
@@ -170,7 +183,9 @@ Full resync запускается только тогда, когда:
Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync.
## 5. Целевой протокол следующего этапа
## 5. Возможное развитие server-to-server транспорта
Этот раздел не описывает текущую DM-доставку. DM уже использует короткие one-shot WebSocket-вызовы, indexed outbox и ACK. Ниже остаётся возможное развитие постоянного транспорта и server-auth.
### 5.1 Межсерверное соединение
@@ -181,14 +196,15 @@ Full resync запускается только тогда, когда:
### 5.2 Доставка новых данных (push)
- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам.
- При получении нового блока сервер может немедленно пушить его всем подключённым партнёрам.
- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff.
- DM использует отдельное расписание, описанное в `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`.
### 5.3 Начальная синхронизация (backfill)
- При первом подключении к партнёру серверы обмениваются «курсорами» состояния:
последний глобальный номер блока, последний известный DM-ключ.
- При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов.
- Сервер с более полной историей досылает недостающее партнёру.
- DM time-cursor удалён: новый peer получает историю после сброса `dm_sync_outbox.synced=false`.
### 5.4 Разрешение конфликтов
@@ -201,19 +217,21 @@ Full resync запускается только тогда, когда:
При отправке DM от пользователя A к пользователю B:
1. Клиент A отправляет пару блоков на свой сервер X.
2. Сервер X определяет, на каком сервере зарегистрирован пользователь B.
- Сначала проверяет локально (если B зарегистрирован на X).
- Иначе читает PDA пользователя B из Solana и смотрит `access_servers`.
- Выбирает первый доступный сервер из `access_servers` и перенаправляет туда DM.
3. Сервер Y (из `access_servers` B) сохраняет и доставляет блоки.
2. Сервер X валидирует и локально сохраняет пару.
3. До ответа клиенту X параллельно отправляет входящую копию максимум двум актуальным `access_servers` B.
4. ACK хотя бы одного маршрута B означает терминальный `delivered`; второй сервер B догоняется собственной DM-синхронизацией.
5. После первой попытки X передаёт полную пару второму access-серверу A старым `ReceiveOutcomingMessage`, без delivery-state.
6. При нулевой доставке выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час. Перед тремя последними X спрашивает peer A через `GetDmDeliveryStatus(messageKey)`.
7. После неудачной попытки через час ставится терминальный `failed`.
Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения).
Изменения routing B учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке.
## 7. Безопасность
- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое.
- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа).
- Серверы не расшифровывают DM-контент; E2EE уже выполняется клиентами.
- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере.
- Межсерверная авторизация DM-операций пока отложена; `sourceServerLogin` временно считается доверенным.
## 8. Статус реализации
@@ -229,15 +247,18 @@ Full resync запускается только тогда, когда:
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
| Межсерверный постоянный WebSocket-канал | Нужна реализация |
| Push новых DM партнёрам | Нужна реализация |
| Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано |
| Retry DM до 1 часа + UI-state | ✅ Реализовано |
| Репликация DM на второй access-сервер по `synced` | ✅ Реализовано |
| Read-only `GetDmDeliveryStatus` | ✅ Реализовано |
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
| Periodic backfill отсутствующего хвоста | ✅ Реализовано |
| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync |
| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано |
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
| Маршрутизация DM через один/два `access_servers` | ✅ Реализовано |
| Криптографическая server-to-server авторизация DM | Нужна реализация |
Текущая версия сервера уже умеет базовую синхронизацию блокчейнов между партнёрами.
Не реализованы ещё DM-sync и постоянные server-to-server соединения.
Текущая версия сервера умеет синхронизацию блокчейнов и DM. Постоянные server-to-server соединения не требуются для текущей one-shot WS-реализации; отдельной будущей задачей остаётся криптографическая авторизация DM-вызовов.
Следующие отдельные шаги после текущего этапа:
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.
+1
View File
@@ -6,6 +6,7 @@
- `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers`
- `docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM`
- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` — состояния доставки, retry-воркер, репликация между двумя access-серверами и UI-статусы
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<S:...>` вставок внутри plaintext DM после расшифровки
Исторический устаревший документ сохранён отдельно:
@@ -0,0 +1,136 @@
# Доставка и синхронизация личных сообщений
## 1. Главный принцип
DM считается доставленным, когда signed-входящую копию сохранил хотя бы один актуальный access-сервер получателя. Доставка на оба сервера не требуется: сервер получателя самостоятельно синхронизирует входящее сообщение со своим вторым сервером.
Клиентский `status=200` от `SendMessagePair` означает, что собственный сервер сохранил пару. Это отдельный факт от доставки получателю.
Формат подписанного контейнера `SHiNE_DM` не меняется. Состояние доставки и флаг синхронизации — изменяемые серверные метаданные.
## 2. Идентификаторы
- `baseKey` связывает входящую и исходящую копии одного логического сообщения;
- `incomingKey` идентифицирует копию получателя;
- `outgoingKey`/`messageKey` идентифицирует копию отправителя и используется для проверки доставки;
- дополнительный публичный `eventId` для доставки не создаётся;
- `syncId` используется только внутри `DmSyncBatch` как ACK конкретной ревизии, потому что редактирование сохраняет прежний `messageKey`.
## 3. Состояния доставки
| Состояние | Смысл | Повторные попытки |
|---|---|---|
| `accepted` | пара сохранена сервером отправителя, но ни один сервер получателя ещё не подтвердил запись | да |
| `delivered` | хотя бы один сервер получателя подтвердил запись | нет |
| `failed` | последняя попытка через час завершилась без доставки | никогда |
Состояние `delivered` терминальное. Сервер отправителя не пытается отдельно добиться второго ACK получателя.
## 4. Обычная отправка
1. Клиент отправляет `SendMessagePair` на один свой access-сервер.
2. Сервер проверяет формат, пользователей, подписи и согласованность пары.
3. Сервер атомарно сохраняет входящую и исходящую копии.
4. В том же request-процессе сервер читает до двух актуальных маршрутов получателя.
5. Оба вызова `ReceiveIncomingMessage` запускаются параллельно.
6. Если хотя бы один вызов успешен, устанавливается `delivered`.
7. После этой попытки сервер передаёт полную пару своему второму access-серверу старой операцией `ReceiveOutcomingMessage`.
8. `SendMessagePair` возвращает существующие ключи и единственное новое поле `deliveryState`.
Успешный повтор уже сохранённого signed-блока считается ACK. Все операции должны быть идемпотентными.
## 5. Расписание повторов
Воркер запускается каждые 5 секунд и выбирает только due-строки по индексу. Он не сканирует всю таблицу сообщений.
Попытки привязаны к времени первоначального принятия:
| Номер | Время от старта | Сначала спросить второй сервер отправителя |
|---:|---:|---|
| 1 | сразу | нет |
| 2 | 30 секунд | нет |
| 3 | 5 минут | да |
| 4 | 25 минут | да |
| 5 | 1 час | да |
Из-за шага воркера повтор может начаться на несколько секунд позже указанного времени. Первая попытка выполняется немедленно и от воркера не зависит.
На 5-й, 25-й и 60-й минутах сервер сначала вызывает у второго сервера отправителя:
```json
{
"op": "GetDmDeliveryStatus",
"payload": {
"messageKey": "alice|bob|1774700000123|123456789|2"
}
}
```
Ответ:
```json
{
"messageKey": "alice|bob|1774700000123|123456789|2",
"known": true,
"delivered": true
}
```
Операция read-only. Если peer отвечает `delivered=true`, локальный сервер устанавливает `delivered` и не обращается к серверам получателя. Ошибка или отсутствие операции на старом peer не блокирует собственную попытку.
Перед каждой попыткой маршруты получателя заново читаются из `user_access_servers_current`. Изменение серверов учитывается только пока сообщение находится в `accepted`. После `delivered` или `failed` состояние больше не открывается.
Если последняя проверка и попытка через час не дали ACK, устанавливается `failed`, `next_attempt_at_ms` очищается и сообщение больше никогда автоматически не отправляется.
## 6. Старая межсерверная доставка
Форматы существующих операций не расширяются данными о результате доставки:
- `ReceiveOutcomingMessage` получает прежнюю пару `incomingBlobB64` + `outgoingBlobB64` и необязательный `sourceServerLogin`;
- `ReceiveIncomingMessage` получает один `incomingBlobB64` и необязательный `sourceServerLogin`.
Второй сервер отправителя после получения пары создаёт собственное локальное состояние доставки и самостоятельно пробует маршруты получателя. Серверы обмениваются результатом только через read-only `GetDmDeliveryStatus`.
## 7. Догоняющая синхронизация двух серверов пользователя
Для каждого владельца сервер ведёт outbox с флагом `synced`:
- исходящая пара отправителя — один элемент с двумя blob в порядке incoming/outgoing;
- входящая копия получателя — один элемент с одним blob;
- read-receipt и tombstone применяются теми же проверенными обработчиками.
`DmSyncBatch` возвращает только элементы с `synced=false`. Получатель проверяет signed-блоки, сохраняет их идемпотентно и в следующем запросе подтверждает `syncId` через `ackSyncIds`. Источник ставит `synced=true` только после ACK.
При обрыве соединения неподтверждённый элемент остаётся `synced=false` и безопасно приходит повторно. Элемент, полученный от peer, локально сразу считается синхронизированным, чтобы не образовалась петля.
Синхронизация настроек и DM проходит последовательно через один WS-сеанс: сначала настройки, затем все страницы DM. Если второй сервер был выключен, после включения он сам догружает пропущенные элементы.
Существующий `MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги. После сброса история повторно передаётся как синхронизация, но старые сообщения получателю заново не отправляются: delivery-состояние создаётся с учётом их возраста и не открывает завершённую часовую очередь.
## 8. UI
| Вид | Значение |
|---|---|
| одна серая галочка | собственный сервер принял сообщение (`accepted`) |
| одна светлая галочка | хотя бы один сервер получателя принял сообщение (`delivered`) |
| две светлые галочки | пришёл существующий read-receipt |
| красный `!` и «Сообщение не доставлено» | окончательное состояние `failed` |
Read-receipt имеет приоритет над delivery-индикатором: если сообщение прочитано, оно заведомо было доставлено.
Сервер сообщает поздние изменения через `DmDeliveryStateChanged` с полями `outgoingKey`, `baseKey`, `deliveryState`. При повторном открытии чата то же состояние приходит в `GetDirectMessages`.
## 9. Нагрузка и отказоустойчивость
- воркер выбирает только due-записи по частичному индексу;
- терминальные строки не попадают в рабочую выборку;
- два маршрута первой попытки выполняются параллельно;
- ограниченный пул потоков и очередь защищают сервер при всплеске отправок;
- сетевой lease и сравнение версии строки предотвращают одновременную обработку одной задачи несколькими worker-потоками;
- повторная запись одного signed-блока безопасна;
- отсутствие второго сервера отправителя не мешает собственной доставке;
- отсутствие обоих серверов получателя завершает задачу через час.
## 10. Граница доверия
Межсерверная авторизация пока отложена. `sourceServerLogin` временно принимается на доверии, но каждый контейнер `SHiNE_DM` всё равно проходит проверку пользовательской подписи. `GetDmDeliveryStatus` сообщает только факт локального delivery-state и не изменяет данные.

Some files were not shown because too many files have changed in this diff Show More