Compare commits

...
Author SHA256 Message Date
AidarKC 3e4583bbe9 Обновить UI для меню и языка 2026-08-29 15:56:01 +04:00
AidarKC b47440dd72 Обновить UI и документы личных сообщений 2026-08-29 15:12:13 +04:00
AidarKC 357a05f7ec Улучшен вход пользователя в аккаунт 2026-08-28 16:51:14 +04:00
AidarKC f2fdb8195c Сделали кнопки стартовой страницы и настроек темнее
Перевёл общий синий акцент на более тёмный оттенок и применил его к кнопкам стартовой страницы и экранам настроек.

Проверено: deploy на t1 server/UI.
Не проверяли: ручной визуальный прогон в браузере и остальные экраны.
2026-08-28 15:07:43 +04:00
AidarKC 8a0275d962 Убрали старый sync и обновили bundle
Что сделано: вычистили неиспользуемый user-settings sync/DM sync хвост, сохранили сборку, обновили bundle.sh так, чтобы gradle-wrapper.jar всегда попадал в архив.

Проверено: compileJava и deploy на t2 (server + UI).
Не проверяли: полные интеграционные сценарии, ручные UI-флоу и продовый деплой.
2026-08-28 14:51:47 +04:00
AidarKC ef1fd9f579 Зафиксировали текущие изменения UI и документации 2026-08-28 12:55:28 +04:00
AidarKC f6b95d94bc Увеличили аватары в списках чатов и каналов 2026-08-26 12:39:49 +04:00
AidarKC 2a65e6d359 Стандартизировали размеры аватаров в UI 2026-08-26 12:33:18 +04:00
AidarKC 83f87ba22c Вернули логотип из исходного SVG 2026-08-26 12:05:42 +04:00
AidarKC 01321f5200 Добавили счётчик подписчиков в каналах и подписки пользователя 2026-08-26 11:11:46 +04:00
AidarKC 3a5851939e Починить межсерверную репликацию личных сообщений
Репликация личных сообщений между серверами теперь работает корректно.
2026-08-25 19:31:38 +04:00
AidarKC 7d929e626e Исправить определение клиентского DM-вызова 2026-08-25 19:30:44 +04:00
143 changed files with 6505 additions and 3617 deletions
+4 -2
View File
@@ -23,7 +23,7 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
с которыми синхронизируются блоки и DM;
с которыми синхронизируются пользовательские блокчейны;
- **корневой ключ** сервера (`root_key`).
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
@@ -49,7 +49,9 @@ shine-UI/server-ui.html
## Синхронизация с партнёрскими серверами
Сервер должен синхронизировать блоки блокчейна и DM с серверами-партнёрами из `sync_servers`.
Сервер должен синхронизировать блоки пользовательских блокчейнов с
серверами-партнёрами из `sync_servers`. DM между партнёрами не реплицируются:
они доставляются только на первый access-сервер получателя.
Детали: `docs/Blockchain/sync-between-servers.md`
## Деплой
@@ -30,6 +30,9 @@ public final class DatabaseInitializer {
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 int SCHEMA_VERSION_14 = 14;
public static final int SCHEMA_VERSION_15 = 15;
public static final int SCHEMA_VERSION_16 = 16;
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";
@@ -43,6 +46,9 @@ public final class DatabaseInitializer {
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";
public static final String POSTGRES_MIGRATION_V14_RESOURCE = "postgres/migration_v14.sql";
public static final String POSTGRES_MIGRATION_V15_RESOURCE = "postgres/migration_v15.sql";
public static final String POSTGRES_MIGRATION_V16_RESOURCE = "postgres/migration_v16.sql";
private DatabaseInitializer() {}
@@ -154,6 +160,18 @@ public final class DatabaseInitializer {
runSqlScript(conn, POSTGRES_MIGRATION_V13_RESOURCE);
currentVersion = SCHEMA_VERSION_13;
}
if (currentVersion < SCHEMA_VERSION_14) {
runSqlScript(conn, POSTGRES_MIGRATION_V14_RESOURCE);
currentVersion = SCHEMA_VERSION_14;
}
if (currentVersion < SCHEMA_VERSION_15) {
runSqlScript(conn, POSTGRES_MIGRATION_V15_RESOURCE);
currentVersion = SCHEMA_VERSION_15;
}
if (currentVersion < SCHEMA_VERSION_16) {
runSqlScript(conn, POSTGRES_MIGRATION_V16_RESOURCE);
currentVersion = SCHEMA_VERSION_16;
}
}
}
@@ -99,6 +99,8 @@ public final class BlockchainResyncCleanupDAO {
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
rebuildStatsState(c);
c.commit();
return new CleanupResult(
@@ -366,6 +368,100 @@ public final class BlockchainResyncCleanupDAO {
""", blockchainName);
}
private void rebuildStatsState(Connection c) throws SQLException {
try (PreparedStatement truncate = c.prepareStatement("""
TRUNCATE TABLE user_stats_state, channel_stats_state
""")) {
truncate.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
)
SELECT
u.login,
COALESCE(own.owned_public_channels_count, 0),
COALESCE(fu.following_users_count, 0),
COALESCE(fc.following_channels_count, 0),
COALESCE(cf.close_friends_count, 0),
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u
LEFT JOIN (
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
FROM channel_names_state
WHERE channel_type_code = 1
GROUP BY owner_login
) own ON LOWER(own.owner_login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS following_users_count
FROM connections_state
WHERE rel_type = 30
AND to_block_number = 0
GROUP BY login
) fu ON LOWER(fu.login) = LOWER(u.login)
LEFT JOIN (
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
FROM connections_state cs
JOIN channel_names_state cn
ON cn.owner_bch_name = cs.to_bch_name
AND cn.channel_root_block_number = cs.to_block_number
AND cn.channel_root_block_hash = cs.to_block_hash
WHERE cs.rel_type = 30
AND cn.channel_type_code = 1
GROUP BY cs.login
) fc ON LOWER(fc.login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS close_friends_count
FROM connections_state
WHERE rel_type = 10
GROUP BY login
) cf ON LOWER(cf.login) = LOWER(u.login)
""")) {
ps.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
)
SELECT
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code,
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM channel_names_state cn
LEFT JOIN connections_state cs
ON cs.rel_type = 30
AND cs.to_bch_name = cn.owner_bch_name
AND cs.to_block_number = cn.channel_root_block_number
AND cs.to_block_hash = cn.channel_root_block_hash
WHERE cn.channel_type_code = 1
GROUP BY
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code
""")) {
ps.executeUpdate();
}
}
private int executeDelete(Connection c, String sql, String value) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, value);
@@ -36,6 +36,48 @@ public final class UserAccessServersCurrentDAO {
}
}
/**
* Возвращает первый access server пользователя ровно в порядке
* solana_user_pda_current.access_servers_json (access_servers[0]).
*
* В user_access_servers_current порядок массива намеренно не хранится,
* поэтому для auth-routing первичный сервер читается напрямую из PDA snapshot.
*/
public UserAccessServerRouteEntry findPrimaryByUserLogin(String userLogin) throws SQLException {
String sql = """
SELECT
u.login AS user_login,
s.login AS server_login,
s.server_address AS server_url,
s.client_key AS server_client_key
FROM solana_user_pda_current u
CROSS JOIN LATERAL (
SELECT BTRIM(a.login_value) AS server_login
FROM jsonb_array_elements_text(
CASE
WHEN BTRIM(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS a(login_value, ord)
WHERE a.ord = 1
) primary_access
JOIN solana_user_pda_current s
ON s.normalized_login = LOWER(primary_access.server_login)
AND s.is_server = TRUE
AND BTRIM(COALESCE(s.server_address, '')) <> ''
WHERE u.normalized_login = LOWER(BTRIM(?))
LIMIT 1
""";
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, userLogin);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
}
}
}
public List<UserAccessServerRouteEntry> listByUserLogin(Connection c, String userLogin) throws SQLException {
String sql = """
SELECT user_login, server_login, server_url, server_client_key
@@ -75,6 +117,35 @@ public final class UserAccessServersCurrentDAO {
return result;
}
/**
* Возвращает уникальные физические серверы из текущей routing-проекции.
* Используется транспортным пулом: одно WSS-соединение держится на сервер,
* а не на каждого пользователя этого сервера.
*/
public List<UserAccessServerRouteEntry> listDistinctServers() throws SQLException {
String sql = """
SELECT DISTINCT ON (LOWER(server_login))
server_login, server_url
FROM user_access_servers_current
WHERE server_login IS NOT NULL AND BTRIM(server_login) <> ''
AND server_url IS NOT NULL AND BTRIM(server_url) <> ''
ORDER BY LOWER(server_login), server_login, server_url
""";
List<UserAccessServerRouteEntry> result = new ArrayList<>();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
entry.setUserLogin("");
entry.setServerLogin(rs.getString("server_login"));
entry.setServerUrl(rs.getString("server_url"));
result.add(entry);
}
}
return result;
}
private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException {
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
entry.setUserLogin(rs.getString("user_login"));
@@ -12,15 +12,11 @@ import java.util.ArrayList;
import java.util.List;
/**
* UserSettingsDAO — хранение пользовательских настроек.
*
* Правило:
* - уникальность: login + setting_type + setting_key
* - запись обновляется только если time_ms новее
* - synced=true означает, что значение уже дошло до второго сервера
* Локальное хранение подписанных пользовательских настроек.
* Запись уникальна по login + setting_type + setting_key и обновляется только
* более новым time_ms. Межсерверной репликации настроек нет.
*/
public final class UserSettingsDAO {
private static volatile UserSettingsDAO instance;
private final DbController db = DbController.getInstance();
@@ -39,16 +35,15 @@ public final class UserSettingsDAO {
String sql = """
INSERT INTO user_settings (
login, setting_type, setting_key, time_ms,
value_text, value_num, client_key, signature, synced
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
value_text, value_num, client_key, signature
) 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
signature = EXCLUDED.signature
WHERE user_settings.time_ms < EXCLUDED.time_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -58,27 +53,20 @@ public final class UserSettingsDAO {
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 {
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
SELECT login, setting_type, setting_key, time_ms,
value_text, value_num, client_key, signature
FROM user_settings
WHERE LOWER(login) = LOWER(?) AND setting_type = ? AND setting_key = ?
LIMIT 1
@@ -93,15 +81,10 @@ public final class UserSettingsDAO {
}
}
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
SELECT login, setting_type, setting_key, time_ms,
value_text, value_num, client_key, signature
FROM user_settings
WHERE LOWER(login) = LOWER(?)
ORDER BY time_ms DESC, setting_type ASC, setting_key ASC
@@ -122,92 +105,6 @@ public final class UserSettingsDAO {
}
}
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"));
@@ -216,16 +113,12 @@ public final class UserSettingsDAO {
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;
}
}
@@ -1,19 +1,6 @@
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;
@@ -23,11 +10,19 @@ public class UserSettingEntry {
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) {
public UserSettingEntry(
String login,
int settingType,
String settingKey,
long timeMs,
String valueText,
long valueNum,
String clientKey,
String signature
) {
this.login = login;
this.settingType = settingType;
this.settingKey = settingKey;
@@ -36,33 +31,22 @@ public class UserSettingEntry {
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,620 @@
BEGIN;
CREATE TABLE IF NOT EXISTS user_stats_state (
login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
owned_public_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (owned_public_channels_count >= 0),
following_users_count INTEGER NOT NULL DEFAULT 0 CHECK (following_users_count >= 0),
following_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (following_channels_count >= 0),
close_friends_count INTEGER NOT NULL DEFAULT 0 CHECK (close_friends_count >= 0),
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_stats_state_following_users_count
ON user_stats_state (following_users_count);
CREATE TABLE IF NOT EXISTS channel_stats_state (
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL CHECK (channel_root_block_number >= 0),
channel_root_block_hash BYTEA NOT NULL,
owner_login TEXT NOT NULL,
channel_type_code INTEGER NOT NULL DEFAULT 1,
subscribers_count INTEGER NOT NULL DEFAULT 0 CHECK (subscribers_count >= 0),
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_bch_name, channel_root_block_number, channel_root_block_hash)
);
CREATE INDEX IF NOT EXISTS idx_channel_stats_state_owner_login
ON channel_stats_state (owner_login);
CREATE OR REPLACE FUNCTION shine_user_stats_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
public_subscribers_count INTEGER;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
RETURN NEW;
END IF;
IF NEW.channel_type_code = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.owner_login,
1,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
SELECT COUNT(*)::INTEGER
INTO public_subscribers_count
FROM connections_state cs
JOIN channel_names_state cn
ON cn.owner_bch_name = cs.to_bch_name
AND cn.channel_root_block_number = cs.to_block_number
AND cn.channel_root_block_hash = cs.to_block_hash
WHERE cs.rel_type = 30
AND cn.owner_bch_name = NEW.owner_bch_name
AND cn.channel_root_block_number = NEW.channel_root_block_number
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
AND cn.channel_type_code = 1;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.owner_bch_name,
NEW.channel_root_block_number,
NEW.channel_root_block_hash,
NEW.owner_login,
NEW.channel_type_code,
COALESCE(public_subscribers_count, 0),
now_ms
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = EXCLUDED.subscribers_count,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
WITH pending AS (
SELECT cs.login, COUNT(*)::INTEGER AS cnt
FROM connections_state cs
WHERE cs.rel_type = 30
AND cs.to_bch_name = NEW.owner_bch_name
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash
GROUP BY cs.login
)
UPDATE user_stats_state us
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
updated_at_ms = now_ms
FROM pending
WHERE us.login = pending.login;
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
resolved_login TEXT;
positive_rel_type INTEGER;
existed_before BOOLEAN;
target_channel_type INTEGER;
BEGIN
IF NEW.msg_type <> 3 THEN
RETURN NEW;
END IF;
resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name);
IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN
IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
IF NEW.msg_sub_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = user_stats_state.close_friends_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.msg_sub_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
1,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = user_stats_state.following_users_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = channel_stats_state.subscribers_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
INSERT INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
) VALUES (
NEW.login,
NEW.msg_sub_type,
resolved_login,
NEW.to_bch_name,
COALESCE(NEW.to_block_number, 0),
COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
);
RETURN NEW;
END IF;
positive_rel_type := CASE NEW.msg_sub_type
WHEN 11 THEN 10
WHEN 21 THEN 20
WHEN 31 THEN 30
WHEN 41 THEN 40
WHEN 51 THEN 50
WHEN 53 THEN 52
WHEN 55 THEN 54
WHEN 61 THEN 60
WHEN 71 THEN 70
WHEN 75 THEN 74
ELSE NULL
END;
IF positive_rel_type IS NULL OR resolved_login IS NULL THEN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
RETURN NEW;
END IF;
IF positive_rel_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF positive_rel_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
CREATE TRIGGER trg_user_stats_state_ai
AFTER INSERT ON solana_user_pda_current
FOR EACH ROW
EXECUTE FUNCTION shine_user_stats_state_ai();
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
CREATE TRIGGER trg_channel_names_state_stats_ai
AFTER INSERT ON channel_names_state
FOR EACH ROW
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai ON blocks;
CREATE TRIGGER trg_blocks_connection_state_ai
AFTER INSERT ON blocks
FOR EACH ROW
EXECUTE FUNCTION shine_blocks_connection_state_ai();
TRUNCATE TABLE user_stats_state, channel_stats_state;
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
)
SELECT
u.login,
COALESCE(own.owned_public_channels_count, 0) AS owned_public_channels_count,
COALESCE(fu.following_users_count, 0) AS following_users_count,
COALESCE(fc.following_channels_count, 0) AS following_channels_count,
COALESCE(cf.close_friends_count, 0) AS close_friends_count,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms
FROM solana_user_pda_current u
LEFT JOIN (
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
FROM channel_names_state
WHERE channel_type_code = 1
GROUP BY owner_login
) own ON LOWER(own.owner_login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS following_users_count
FROM connections_state
WHERE rel_type = 30
AND to_block_number = 0
GROUP BY login
) fu ON LOWER(fu.login) = LOWER(u.login)
LEFT JOIN (
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
FROM connections_state cs
JOIN channel_names_state cn
ON cn.owner_bch_name = cs.to_bch_name
AND cn.channel_root_block_number = cs.to_block_number
AND cn.channel_root_block_hash = cs.to_block_hash
WHERE cs.rel_type = 30
AND cn.channel_type_code = 1
GROUP BY cs.login
) fc ON LOWER(fc.login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS close_friends_count
FROM connections_state
WHERE rel_type = 10
GROUP BY login
) cf ON LOWER(cf.login) = LOWER(u.login);
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
)
SELECT
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code,
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) AS updated_at_ms
FROM channel_names_state cn
LEFT JOIN connections_state cs
ON cs.rel_type = 30
AND cs.to_bch_name = cn.owner_bch_name
AND cs.to_block_number = cn.channel_root_block_number
AND cs.to_block_hash = cn.channel_root_block_hash
WHERE cn.channel_type_code = 1
GROUP BY
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 14, 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,105 @@
BEGIN;
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
public_subscribers_count INTEGER;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
RETURN NEW;
END IF;
IF NEW.channel_type_code = 1 THEN
IF EXISTS (
SELECT 1
FROM solana_user_pda_current su
WHERE su.login = NEW.owner_login
) THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.owner_login,
1,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
SELECT COUNT(*)::INTEGER
INTO public_subscribers_count
FROM connections_state cs
JOIN channel_names_state cn
ON cn.owner_bch_name = cs.to_bch_name
AND cn.channel_root_block_number = cs.to_block_number
AND cn.channel_root_block_hash = cs.to_block_hash
WHERE cs.rel_type = 30
AND cn.owner_bch_name = NEW.owner_bch_name
AND cn.channel_root_block_number = NEW.channel_root_block_number
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
AND cn.channel_type_code = 1;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.owner_bch_name,
NEW.channel_root_block_number,
NEW.channel_root_block_hash,
NEW.owner_login,
NEW.channel_type_code,
COALESCE(public_subscribers_count, 0),
now_ms
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = EXCLUDED.subscribers_count,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
WITH pending AS (
SELECT cs.login, COUNT(*)::INTEGER AS cnt
FROM connections_state cs
WHERE cs.rel_type = 30
AND cs.to_bch_name = NEW.owner_bch_name
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash
GROUP BY cs.login
)
UPDATE user_stats_state us
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
updated_at_ms = now_ms
FROM pending
WHERE us.login = pending.login;
END IF;
RETURN NEW;
END;
$$;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 15, 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,112 @@
BEGIN;
-- У пользователя действует только первый access_server из Solana PDA.
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_user(p_user_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
BEGIN
IF p_user_login IS NULL OR btrim(p_user_login) = '' THEN
RETURN;
END IF;
DELETE FROM user_access_servers_current
WHERE LOWER(user_login) = LOWER(p_user_login);
INSERT INTO user_access_servers_current (
user_login,
server_login,
server_url,
server_client_key,
user_record_number,
user_updated_at_ms,
server_record_number,
server_updated_at_ms,
refreshed_at_ms
)
SELECT
u.login,
s.login,
s.server_address,
s.client_key,
u.record_number,
u.updated_at_ms,
s.record_number,
s.updated_at_ms,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord = 1
) AS access_server
JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
AND s.is_server = TRUE
AND btrim(COALESCE(s.server_address, '')) <> ''
WHERE LOWER(u.login) = LOWER(p_user_login)
ON CONFLICT (user_login, server_login) DO UPDATE SET
server_url = EXCLUDED.server_url,
server_client_key = EXCLUDED.server_client_key,
user_record_number = EXCLUDED.user_record_number,
user_updated_at_ms = EXCLUDED.user_updated_at_ms,
server_record_number = EXCLUDED.server_record_number,
server_updated_at_ms = EXCLUDED.server_updated_at_ms,
refreshed_at_ms = EXCLUDED.refreshed_at_ms;
END;
$$;
CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_server(p_server_login TEXT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
affected_user RECORD;
BEGIN
IF p_server_login IS NULL OR btrim(p_server_login) = '' THEN
RETURN;
END IF;
DELETE FROM user_access_servers_current
WHERE LOWER(server_login) = LOWER(p_server_login);
FOR affected_user IN
SELECT u.login
FROM solana_user_pda_current u
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord = 1
) AS access_server
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
END LOOP;
END;
$$;
DROP TABLE IF EXISTS dm_sync_outbox;
DROP TABLE IF EXISTS dm_sync_peer_state;
DROP TABLE IF EXISTS user_settings_sync_peer_state;
DROP INDEX IF EXISTS idx_user_settings_synced;
ALTER TABLE user_settings DROP COLUMN IF EXISTS synced;
SELECT shine_refresh_user_access_servers_all();
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 16, 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, 13, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
VALUES (1, 16, 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;
@@ -177,7 +177,7 @@ BEGIN
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
WHERE ord = 1
) AS access_server
JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
@@ -220,7 +220,7 @@ BEGIN
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
WHERE ord = 1
) AS access_server
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP
@@ -291,6 +291,12 @@ AFTER TRUNCATE ON solana_user_pda_current
FOR EACH STATEMENT
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
CREATE TRIGGER trg_user_stats_state_ai
AFTER INSERT ON solana_user_pda_current
FOR EACH ROW
EXECUTE FUNCTION shine_user_stats_state_ai();
CREATE TABLE IF NOT EXISTS solana_user_pda_history (
id BIGSERIAL PRIMARY KEY,
tx_signature TEXT NOT NULL,
@@ -410,35 +416,15 @@ CREATE TABLE IF NOT EXISTS user_settings (
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,
@@ -605,6 +591,32 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target
CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner
ON channel_names_state (owner_login, owner_bch_name);
CREATE TABLE IF NOT EXISTS user_stats_state (
login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
owned_public_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (owned_public_channels_count >= 0),
following_users_count INTEGER NOT NULL DEFAULT 0 CHECK (following_users_count >= 0),
following_channels_count INTEGER NOT NULL DEFAULT 0 CHECK (following_channels_count >= 0),
close_friends_count INTEGER NOT NULL DEFAULT 0 CHECK (close_friends_count >= 0),
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_stats_state_following_users_count
ON user_stats_state (following_users_count);
CREATE TABLE IF NOT EXISTS channel_stats_state (
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL CHECK (channel_root_block_number >= 0),
channel_root_block_hash BYTEA NOT NULL,
owner_login TEXT NOT NULL,
channel_type_code INTEGER NOT NULL DEFAULT 1,
subscribers_count INTEGER NOT NULL DEFAULT 0 CHECK (subscribers_count >= 0),
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_bch_name, channel_root_block_number, channel_root_block_hash)
);
CREATE INDEX IF NOT EXISTS idx_channel_stats_state_owner_login
ON channel_stats_state (owner_login);
CREATE TABLE IF NOT EXISTS chat200_state (
owner_login TEXT NOT NULL,
owner_bch_name TEXT NOT NULL,
@@ -792,22 +804,6 @@ CREATE TABLE IF NOT EXISTS dm_dialog_state (
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,
remote_server_url TEXT NOT NULL,
cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0,
cursor_message_key TEXT NOT NULL DEFAULT '',
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
last_sync_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, remote_server_login)
);
CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner
ON dm_sync_peer_state(owner_login);
-- Изменяемое состояние доставки исходящей пары. Подписанные блоки остаются
-- неизменяемыми; эта таблица описывает только сетевую доставку пары 1/2 или 3/4.
CREATE TABLE IF NOT EXISTS dm_delivery_state (
@@ -836,23 +832,6 @@ CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_due
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,
@@ -921,6 +900,132 @@ BEGIN
END;
$$;
CREATE OR REPLACE FUNCTION shine_user_stats_state_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
public_subscribers_count INTEGER;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
RETURN NEW;
END IF;
IF NEW.channel_type_code = 1 THEN
IF EXISTS (
SELECT 1
FROM solana_user_pda_current su
WHERE su.login = NEW.owner_login
) THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.owner_login,
1,
0,
0,
0,
now_ms
)
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
SELECT COUNT(*)::INTEGER
INTO public_subscribers_count
FROM connections_state cs
JOIN channel_names_state cn
ON cn.owner_bch_name = cs.to_bch_name
AND cn.channel_root_block_number = cs.to_block_number
AND cn.channel_root_block_hash = cs.to_block_hash
WHERE cs.rel_type = 30
AND cn.owner_bch_name = NEW.owner_bch_name
AND cn.channel_root_block_number = NEW.channel_root_block_number
AND cn.channel_root_block_hash = NEW.channel_root_block_hash
AND cn.channel_type_code = 1;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.owner_bch_name,
NEW.channel_root_block_number,
NEW.channel_root_block_hash,
NEW.owner_login,
NEW.channel_type_code,
COALESCE(public_subscribers_count, 0),
now_ms
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = EXCLUDED.subscribers_count,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
WITH pending AS (
SELECT cs.login, COUNT(*)::INTEGER AS cnt
FROM connections_state cs
WHERE cs.rel_type = 30
AND cs.to_bch_name = NEW.owner_bch_name
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash
GROUP BY cs.login
)
UPDATE user_stats_state us
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
updated_at_ms = now_ms
FROM pending
WHERE us.login = pending.login;
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION shine_blocks_line_integrity_bi()
RETURNS TRIGGER
LANGUAGE plpgsql
@@ -999,6 +1104,8 @@ AS $$
DECLARE
resolved_login TEXT;
positive_rel_type INTEGER;
existed_before BOOLEAN;
target_channel_type INTEGER;
BEGIN
IF NEW.msg_type <> 3 THEN
RETURN NEW;
@@ -1011,10 +1118,159 @@ BEGIN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
IF NEW.msg_sub_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = user_stats_state.close_friends_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.msg_sub_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
1,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = user_stats_state.following_users_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
1,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = channel_stats_state.subscribers_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
1,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = user_stats_state.following_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = NEW.msg_sub_type
AND to_login = resolved_login;
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
INSERT INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
@@ -1048,10 +1304,161 @@ BEGIN
RETURN NEW;
END IF;
SELECT EXISTS (
SELECT 1
FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'))
)
INTO existed_before;
IF NOT existed_before THEN
RETURN NEW;
END IF;
IF positive_rel_type = 10 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
close_friends_count = GREATEST(0, user_stats_state.close_friends_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF positive_rel_type = 30 THEN
IF NEW.to_block_number IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF NEW.to_block_number = 0 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_users_count = GREATEST(0, user_stats_state.following_users_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSE
SELECT cn.channel_type_code
INTO target_channel_type
FROM channel_names_state cn
WHERE cn.owner_bch_name = NEW.to_bch_name
AND cn.channel_root_block_number = NEW.to_block_number
AND cn.channel_root_block_hash = NEW.to_block_hash
LIMIT 1;
IF target_channel_type = 1 THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
) VALUES (
NEW.to_bch_name,
NEW.to_block_number,
NEW.to_block_hash,
resolved_login,
target_channel_type,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = GREATEST(0, channel_stats_state.subscribers_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
ELSIF target_channel_type IS NULL THEN
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
) VALUES (
NEW.login,
0,
0,
0,
0,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
)
ON CONFLICT (login) DO UPDATE SET
following_channels_count = GREATEST(0, user_stats_state.following_channels_count - 1),
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
END IF;
END IF;
DELETE FROM connections_state
WHERE login = NEW.login
AND rel_type = positive_rel_type
AND to_login = resolved_login;
AND to_login = resolved_login
AND to_bch_name = NEW.to_bch_name
AND to_block_number = COALESCE(NEW.to_block_number, 0)
AND to_block_hash = COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex'));
RETURN NEW;
END;
@@ -1231,4 +1638,10 @@ AFTER INSERT ON blocks
FOR EACH ROW
EXECUTE FUNCTION shine_blocks_edit_apply_ai();
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
CREATE TRIGGER trg_channel_names_state_stats_ai
AFTER INSERT ON channel_names_state
FOR EACH ROW
EXECUTE FUNCTION shine_channel_names_state_stats_ai();
COMMIT;
@@ -4,6 +4,8 @@ import org.eclipse.jetty.websocket.api.Session;
import shine.db.entities.CurrentUserEntry;
import shine.db.entities.ActiveSessionEntry;
import java.util.List;
/**
* ConnectionContext — контекст состояния одного WebSocket-соединения.
* Живёт ровно столько же, сколько живёт подключение.
@@ -25,6 +27,11 @@ public class ConnectionContext {
public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge)
public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь
// При успешной авторизации отдельно фиксируем отношение пользователя к этому серверу.
public static final int USER_SERVER_SCOPE_NONE = 0;
public static final int USER_SERVER_SCOPE_LOCAL = 1;
public static final int USER_SERVER_SCOPE_REMOTE = 2;
// Полный пользователь из runtime БД (current users / solana_user_pda_current)
private CurrentUserEntry currentUserEntry;
@@ -71,12 +78,24 @@ public class ConnectionContext {
*/
private int authenticationStatus = AUTH_STATUS_NONE;
/**
* LOCAL — этот сервер является первым access server пользователя.
* REMOTE — пользователь криптографически авторизован, но его access server другой.
*/
private int userServerScope = USER_SERVER_SCOPE_NONE;
/**
* WebSocket-сессия Jetty для данного подключения.
* Нужна, чтобы через ConnectionContext можно было отправлять сообщения клиенту.
*/
private Session wsSession;
/** Временная server-to-server роль, заявленная через ServerHello. */
private boolean serverConnection;
private String remoteServerLogin;
private int remoteServerProtocolVersion;
private List<String> remoteServerCapabilities = List.of();
// --- WebSocket Session ---
public Session getWsSession() {
@@ -87,6 +106,20 @@ public class ConnectionContext {
this.wsSession = wsSession;
}
public boolean isServerConnection() { return serverConnection; }
public void setServerConnection(boolean serverConnection) { this.serverConnection = serverConnection; }
public String getRemoteServerLogin() { return remoteServerLogin; }
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
public int getRemoteServerProtocolVersion() { return remoteServerProtocolVersion; }
public void setRemoteServerProtocolVersion(int value) { this.remoteServerProtocolVersion = value; }
public List<String> getRemoteServerCapabilities() { return remoteServerCapabilities; }
public void setRemoteServerCapabilities(List<String> capabilities) {
this.remoteServerCapabilities = capabilities == null ? List.of() : List.copyOf(capabilities);
}
// --- SolanaUser / ActiveSession ---
public CurrentUserEntry getCurrentUser() {
@@ -175,6 +208,28 @@ public class ConnectionContext {
return authenticationStatus == AUTH_STATUS_NONE;
}
public int getUserServerScope() {
return userServerScope;
}
public void setUserServerScope(int userServerScope) {
this.userServerScope = userServerScope;
}
public boolean isLocalAuthenticatedUser() {
return isAuthenticatedUser() && userServerScope == USER_SERVER_SCOPE_LOCAL;
}
public boolean isRemoteAuthenticatedUser() {
return isAuthenticatedUser() && userServerScope == USER_SERVER_SCOPE_REMOTE;
}
public String getUserServerScopeName() {
return userServerScope == USER_SERVER_SCOPE_LOCAL ? "LOCAL"
: userServerScope == USER_SERVER_SCOPE_REMOTE ? "REMOTE"
: "NONE";
}
public void reset() {
currentUserEntry = null;
activeSessionEntry = null;
@@ -187,7 +242,12 @@ public class ConnectionContext {
sessionLoginNonceExpiresAtMs = 0;
authenticationStatus = AUTH_STATUS_NONE;
userServerScope = USER_SERVER_SCOPE_NONE;
wsSession = null;
serverConnection = false;
remoteServerLogin = null;
remoteServerProtocolVersion = 0;
remoteServerCapabilities = List.of();
}
@Override
@@ -196,6 +256,7 @@ public class ConnectionContext {
"login='" + getLogin() + '\'' +
", sessionId=" + sessionId +
", authenticationStatus=" + authenticationStatus +
", userServerScope=" + getUserServerScopeName() +
'}';
}
}
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_ResolveLoginForAuth_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler;
@@ -22,6 +23,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.Net_UpsertEspPairingSettings_
// --- auth entities ---
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ResolveLoginForAuth_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request;
@@ -98,11 +100,7 @@ import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_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_SendMessagePair_Handler;
@@ -113,11 +111,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_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_SendMessagePair_Request;
@@ -133,6 +127,7 @@ import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ListBlockchainHeads_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_CallDeliveryReport_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ServerHello_Handler;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request;
@@ -141,6 +136,7 @@ import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ServerHello_Request;
import java.util.Map;
@@ -161,6 +157,7 @@ public final class JsonHandlerRegistry {
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
// --- auth ---
Map.entry("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()),
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()),
Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()),
@@ -217,14 +214,9 @@ public final class JsonHandlerRegistry {
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
Map.entry("SendTestWebPush", new Net_SendTestWebPush_Handler()),
Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()),
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()),
Map.entry("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()),
@@ -232,6 +224,7 @@ public final class JsonHandlerRegistry {
Map.entry("SendSignal", new Net_SendSignal_Handler()),
// --- system ---
Map.entry("ServerHello", new Net_ServerHello_Handler()),
Map.entry("Ping", new Net_Ping_Handler()),
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
Map.entry("ListBlockchainHeads", new Net_ListBlockchainHeads_Handler()),
@@ -252,6 +245,7 @@ public final class JsonHandlerRegistry {
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
// --- auth ---
Map.entry("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class),
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class),
Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class),
@@ -308,14 +302,9 @@ public final class JsonHandlerRegistry {
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
Map.entry("SendTestWebPush", Net_SendTestWebPush_Request.class),
Map.entry("SendMessagePair", Net_SendMessagePair_Request.class),
Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class),
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class),
Map.entry("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),
@@ -323,6 +312,7 @@ public final class JsonHandlerRegistry {
Map.entry("SendSignal", Net_SendSignal_Request.class),
// --- system ---
Map.entry("ServerHello", Net_ServerHello_Request.class),
Map.entry("Ping", Net_Ping_Request.class),
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
Map.entry("ListBlockchainHeads", Net_ListBlockchainHeads_Request.class),
@@ -205,6 +205,22 @@ public final class JsonInboundProcessor {
ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(safeToString(request), 1200));
}
// REMOTE-пользователь успешно авторизован, но этот сервер не является
// его access server. Такой контекст нужен для realtime/call signaling,
// однако обычные homeserver-операции здесь запрещены централизованно.
if (ctx != null
&& ctx.isRemoteAuthenticatedUser()
&& !RemoteUserOperationPolicy.isAllowed(op)) {
Net_Exception_Response err = NetExceptionResponseFactory.error(
op,
requestId,
403,
"USER_NOT_LOCAL",
"Пользователь авторизован, но относится к другому access server"
);
return writeResponse(err);
}
// 5) Вызываем хэндлер
Net_Response response;
try {
@@ -0,0 +1,31 @@
package server.logic.ws_protocol.JSON;
import java.util.Set;
/**
* REMOTE-пользователь полностью авторизован криптографически, но этот сервер
* не является его access server. Поэтому ему разрешён только минимальный набор
* realtime/звонковых операций.
*/
public final class RemoteUserOperationPolicy {
private static final Set<String> ALLOWED = Set.of(
"Ping",
"GetServerInfo",
"GetCallIceConfig",
"CallInviteBroadcast",
"CallSignalToSession",
"SendSignal",
"CallDeliveryReport",
"ClientErrorLog",
"ClientDebugLog",
"CloseActiveSession",
"ResolveLoginForAuth"
);
private RemoteUserOperationPolicy() {}
public static boolean isAllowed(String op) {
return op != null && ALLOWED.contains(op);
}
}
@@ -0,0 +1,81 @@
package server.logic.ws_protocol.JSON.handlers.auth;
import server.logic.ws_protocol.JSON.ConnectionContext;
import shine.db.dao.CurrentUsersDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.CurrentUserEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
import java.sql.SQLException;
import java.util.Locale;
/**
* Единая серверная логика определения, является ли текущий сервер
* домашним access server для пользователя.
*/
public final class AuthAccessScopeSupport {
public static final String RESOLUTION_LOCAL = "LOCAL";
public static final String RESOLUTION_REMOTE = "REMOTE";
public static final String RESOLUTION_NOT_FOUND = "NOT_FOUND";
public static final String RESOLUTION_NO_ACCESS_SERVER = "NO_ACCESS_SERVER";
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
private AuthAccessScopeSupport() {}
public static Resolution resolveLogin(String login) throws SQLException {
String cleanLogin = login == null ? "" : login.trim();
if (cleanLogin.isBlank()) {
return new Resolution(RESOLUTION_NOT_FOUND, null, null, null);
}
CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(cleanLogin);
if (user == null) {
return new Resolution(RESOLUTION_NOT_FOUND, null, null, null);
}
UserAccessServerRouteEntry route =
UserAccessServersCurrentDAO.getInstance().findPrimaryByUserLogin(user.getLogin());
if (route == null) {
return new Resolution(RESOLUTION_NO_ACCESS_SERVER, user, null, null);
}
String ownServerLogin = currentServerLogin();
String routeServerLogin = normalize(route.getServerLogin());
String resolution = ownServerLogin != null && ownServerLogin.equals(routeServerLogin)
? RESOLUTION_LOCAL
: RESOLUTION_REMOTE;
return new Resolution(resolution, user, route, ownServerLogin);
}
public static int connectionScopeForLogin(String login) throws SQLException {
Resolution resolution = resolveLogin(login);
return RESOLUTION_LOCAL.equals(resolution.resolution())
? ConnectionContext.USER_SERVER_SCOPE_LOCAL
: ConnectionContext.USER_SERVER_SCOPE_REMOTE;
}
public static String connectionScopeName(int scope) {
return scope == ConnectionContext.USER_SERVER_SCOPE_LOCAL ? "LOCAL" : "REMOTE";
}
public static String currentServerLogin() {
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;
}
public record Resolution(
String resolution,
CurrentUserEntry user,
UserAccessServerRouteEntry primaryAccessServer,
String currentServerLogin
) {}
}
@@ -71,6 +71,7 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler {
}
ctx.setCurrentUser(solanaUserEntry);
ctx.setUserServerScope(AuthAccessScopeSupport.connectionScopeForLogin(solanaUserEntry.getLogin()));
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS);
byte[] buf = new byte[32];
@@ -414,10 +414,15 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
return err;
}
// Между AuthChallenge и CreateAuthSession routing мог измениться,
// поэтому окончательный LOCAL/REMOTE scope вычисляем повторно.
int userServerScope = AuthAccessScopeSupport.connectionScopeForLogin(canonicalLogin);
// --- обновляем контекст ---
ctx.setActiveSession(activeSessionEntry);
ctx.setSessionId(sessionId);
ctx.setAuthNonce(null);
ctx.setUserServerScope(userServerScope);
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
ActiveConnectionsRegistry.getInstance().register(ctx);
@@ -428,6 +433,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setSessionId(sessionId);
resp.setConnectionScope(AuthAccessScopeSupport.connectionScopeName(userServerScope));
return resp;
}
@@ -0,0 +1,45 @@
package server.logic.ws_protocol.JSON.handlers.auth;
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.auth.entyties.Net_ResolveLoginForAuth_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ResolveLoginForAuth_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.entities.CurrentUserEntry;
import shine.db.entities.UserAccessServerRouteEntry;
public class Net_ResolveLoginForAuth_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
Net_ResolveLoginForAuth_Request req = (Net_ResolveLoginForAuth_Request) baseReq;
String login = req.getLogin() == null ? "" : req.getLogin().trim();
if (login.isBlank()) {
return NetExceptionResponseFactory.error(
req, WireCodes.Status.BAD_REQUEST, "EMPTY_LOGIN", "Пустой login");
}
AuthAccessScopeSupport.Resolution resolved = AuthAccessScopeSupport.resolveLogin(login);
Net_ResolveLoginForAuth_Response resp = new Net_ResolveLoginForAuth_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setResolution(resolved.resolution());
CurrentUserEntry user = resolved.user();
if (user != null) {
resp.setLogin(user.getLogin());
}
UserAccessServerRouteEntry route = resolved.primaryAccessServer();
if (route != null) {
resp.setAccessServerLogin(route.getServerLogin());
resp.setAccessServerUrl(route.getServerUrl());
}
return resp;
}
}
@@ -292,10 +292,13 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
session.setClientPlatform(clientPlatform);
session.setUserLanguage(userLanguage);
int userServerScope = AuthAccessScopeSupport.connectionScopeForLogin(user.getLogin());
// ctx
ctx.setActiveSession(session);
ctx.setCurrentUser(user);
ctx.setSessionId(sessionId);
ctx.setUserServerScope(userServerScope);
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
ActiveConnectionsRegistry.getInstance().register(ctx);
@@ -306,6 +309,7 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setStoragePwd(session.getStoragePwd());
resp.setConnectionScope(AuthAccessScopeSupport.connectionScopeName(userServerScope));
return resp;
}
@@ -66,6 +66,16 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
}
String canonicalLogin = user.getLogin();
AuthAccessScopeSupport.Resolution authResolution = AuthAccessScopeSupport.resolveLogin(canonicalLogin);
if (!AuthAccessScopeSupport.RESOLUTION_LOCAL.equals(authResolution.resolution())) {
return NetExceptionResponseFactory.error(
req,
409,
"USER_NOT_LOCAL",
"Вход через доверенное устройство нужно начинать на access server пользователя"
);
}
EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin);
boolean enabled = settings == null || settings.isEnabled();
if (!enabled) {
@@ -100,14 +110,6 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
int ttlSeconds = EspPairingSupport.DEFAULT_TTL_SECONDS;
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
if (approverConnections.isEmpty()) {
return NetExceptionResponseFactory.error(
req,
422,
"PAIRING_NO_TRUSTED_SESSION_ONLINE",
"Нет ни одной активной доверенной сессии пользователя в сети"
);
}
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
canonicalLogin,
requesterSessionKey,
@@ -27,7 +27,7 @@ public final class SolanaUserPdaImportService {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final String MAGIC = "SHiNE";
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 2;
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 1;
private SolanaUserPdaImportService() {}
@@ -22,6 +22,7 @@ public class Net_CreateAuthSession_Response extends Net_Response {
/** Идентификатор сессии, base64 от 32 байт. */
private String sessionId;
private String connectionScope;
public String getSessionId() {
return sessionId;
@@ -30,4 +31,7 @@ public class Net_CreateAuthSession_Response extends Net_Response {
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public String getConnectionScope() { return connectionScope; }
public void setConnectionScope(String connectionScope) { this.connectionScope = connectionScope; }
}
@@ -0,0 +1,11 @@
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Первый шаг UI-входа: определить существование пользователя и его access server. */
public class Net_ResolveLoginForAuth_Request extends Net_Request {
private String login;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
}
@@ -0,0 +1,31 @@
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
/**
* Ответ ResolveLoginForAuth.
*
* resolution:
* - LOCAL пользователь существует и относится к этому серверу;
* - REMOTE пользователь существует, но его первый access server другой;
* - NOT_FOUND пользователя нет в текущем Solana PDA snapshot;
* - NO_ACCESS_SERVER пользователь существует, но корректный access server не найден.
*/
public class Net_ResolveLoginForAuth_Response extends Net_Response {
private String resolution;
private String login;
private String accessServerLogin;
private String accessServerUrl;
public String getResolution() { return resolution; }
public void setResolution(String resolution) { this.resolution = resolution; }
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getAccessServerLogin() { return accessServerLogin; }
public void setAccessServerLogin(String accessServerLogin) { this.accessServerLogin = accessServerLogin; }
public String getAccessServerUrl() { return accessServerUrl; }
public void setAccessServerUrl(String accessServerUrl) { this.accessServerUrl = accessServerUrl; }
}
@@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_SessionLogin_Response extends Net_Response {
private String storagePwd;
private String connectionScope;
public String getStoragePwd() {
return storagePwd;
@@ -17,4 +18,7 @@ public class Net_SessionLogin_Response extends Net_Response {
public void setStoragePwd(String storagePwd) {
this.storagePwd = storagePwd;
}
public String getConnectionScope() { return connectionScope; }
public void setConnectionScope(String connectionScope) { this.connectionScope = connectionScope; }
}
@@ -16,6 +16,8 @@ import utils.blockchain.BlockchainNameUtil;
import blockchain.body.CreateChannelBody;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
@@ -65,6 +67,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
channel.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
channel.setChannelTypeCode(meta.channelTypeCode);
channel.setChannelTypeVersion(meta.channelTypeVersion);
channel.setSubscribersCount(loadSubscribersCount(c, ownerBch, lineCode, meta.channelTypeCode));
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
rootRef.setBlockNumber(lineCode);
rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash());
@@ -180,4 +183,28 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
private static int loadSubscribersCount(Connection c, String ownerBch, int rootNumber, int channelTypeCode) {
if (channelTypeCode != (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
return 0;
}
String sql = """
SELECT subscribers_count
FROM channel_stats_state
WHERE owner_bch_name = ?
AND channel_root_block_number = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, rootNumber);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return 0;
return Math.max(0, rs.getInt("subscribers_count"));
}
} catch (Exception e) {
log.warn("GetChannelMessages: не удалось загрузить subscribers_count для {}#{}", ownerBch, rootNumber, e);
return 0;
}
}
}
@@ -32,6 +32,7 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private Long metaUpdatedAtMs;
private Integer channelTypeCode;
private Integer channelTypeVersion;
private Integer subscribersCount;
private BlockRef channelRoot;
public String getOwnerLogin() { return ownerLogin; }
@@ -67,6 +68,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public Integer getChannelTypeVersion() { return channelTypeVersion; }
public void setChannelTypeVersion(Integer channelTypeVersion) { this.channelTypeVersion = channelTypeVersion; }
public Integer getSubscribersCount() { return subscribersCount; }
public void setSubscribersCount(Integer subscribersCount) { this.subscribersCount = subscribersCount; }
public BlockRef getChannelRoot() { return channelRoot; }
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
}
@@ -0,0 +1,70 @@
package server.logic.ws_protocol.JSON.handlers.system;
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.system.entyties.Net_ServerHello_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ServerHello_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import utils.config.AppConfig;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
/**
* Временное server-to-server представление без криптографической проверки.
* Заявленный serverLogin принимается на доверии и привязывается к WS-контексту.
*/
public final class Net_ServerHello_Handler implements JsonMessageHandler {
private static final int PROTOCOL_VERSION = 1;
private static final List<String> CAPABILITIES = List.of(
"dm-sync", "settings-sync", "block-sync", "connection-pool");
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_ServerHello_Request req = (Net_ServerHello_Request) baseRequest;
String remoteLogin = normalize(req.getServerLogin());
int remoteVersion = req.getProtocolVersion() == null ? 0 : req.getProtocolVersion();
if (ctx == null) {
return NetExceptionResponseFactory.error(
req, WireCodes.Status.BAD_REQUEST, "NO_CONNECTION_CONTEXT", "ServerHello требует WebSocket-контекст");
}
if (remoteLogin == null || remoteVersion <= 0) {
return NetExceptionResponseFactory.error(
req, WireCodes.Status.BAD_REQUEST, "BAD_SERVER_HELLO", "serverLogin/protocolVersion обязательны");
}
LinkedHashSet<String> unique = new LinkedHashSet<>();
if (req.getCapabilities() != null) {
for (String capability : req.getCapabilities()) {
String normalized = normalize(capability);
if (normalized != null && unique.size() < 64) unique.add(normalized);
}
}
ctx.setServerConnection(true);
ctx.setRemoteServerLogin(remoteLogin);
ctx.setRemoteServerProtocolVersion(remoteVersion);
ctx.setRemoteServerCapabilities(new ArrayList<>(unique));
Net_ServerHello_Response resp = new Net_ServerHello_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setAccepted(true);
String localLogin = normalize(AppConfig.getInstance().getParam("server.SHiNE.login"));
resp.setServerLogin(localLogin == null ? "" : localLogin);
resp.setProtocolVersion(PROTOCOL_VERSION);
resp.setCapabilities(CAPABILITIES);
return resp;
}
private static String normalize(String value) {
if (value == null) return null;
String normalized = value.trim().toLowerCase(Locale.ROOT);
return normalized.isEmpty() ? null : normalized;
}
}
@@ -0,0 +1,20 @@
package server.logic.ws_protocol.JSON.handlers.system.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import java.util.List;
public final class Net_ServerHello_Request extends Net_Request {
private String serverLogin;
private Integer protocolVersion;
private List<String> capabilities;
public String getServerLogin() { return serverLogin; }
public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; }
public Integer getProtocolVersion() { return protocolVersion; }
public void setProtocolVersion(Integer protocolVersion) { this.protocolVersion = protocolVersion; }
public List<String> getCapabilities() { return capabilities; }
public void setCapabilities(List<String> capabilities) { this.capabilities = capabilities; }
}
@@ -0,0 +1,24 @@
package server.logic.ws_protocol.JSON.handlers.system.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.List;
public final class Net_ServerHello_Response extends Net_Response {
private boolean accepted;
private String serverLogin;
private int protocolVersion;
private List<String> capabilities;
public boolean isAccepted() { return accepted; }
public void setAccepted(boolean accepted) { this.accepted = accepted; }
public String getServerLogin() { return serverLogin; }
public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; }
public int getProtocolVersion() { return protocolVersion; }
public void setProtocolVersion(int protocolVersion) { this.protocolVersion = protocolVersion; }
public List<String> getCapabilities() { return capabilities; }
public void setCapabilities(List<String> capabilities) { this.capabilities = capabilities; }
}
@@ -11,11 +11,15 @@ import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Re
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.BlockchainStateDAO;
import shine.db.DbController;
import shine.db.dao.CurrentUsersDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.CurrentUserEntry;
import java.sql.SQLException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Arrays;
public class Net_GetUser_Handler implements JsonMessageHandler {
@@ -63,6 +67,7 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
resp.setSolanaKey(u.getSolanaKey());
resp.setBlockchainKey(u.getBlockchainKey());
resp.setClientKey(u.getClientKey());
loadUserStats(resp, u.getLogin());
// Возвращаем актуальный курсор блокчейна и, если запись состояния потеряна,
// автоматически восстанавливаем её для существующего пользователя.
@@ -125,4 +130,35 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
}
return new String(out);
}
private static void loadUserStats(Net_GetUser_Response resp, String login) {
resp.setOwnedPublicChannelsCount(0);
resp.setFollowingUsersCount(0);
resp.setFollowingChannelsCount(0);
resp.setCloseFriendsCount(0);
String sql = """
SELECT owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count
FROM user_stats_state
WHERE login = ?
LIMIT 1
""";
try (Connection c = DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
return;
}
resp.setOwnedPublicChannelsCount(rs.getInt("owned_public_channels_count"));
resp.setFollowingUsersCount(rs.getInt("following_users_count"));
resp.setFollowingChannelsCount(rs.getInt("following_channels_count"));
resp.setCloseFriendsCount(rs.getInt("close_friends_count"));
}
} catch (Exception e) {
log.warn("GetUser: не удалось загрузить статистику для login={}", login, e);
}
}
}
@@ -43,6 +43,10 @@ public class Net_GetUser_Response extends Net_Response {
private String serverLastGlobalHash;
private Long serverBlockchainSizeBytes;
private Long serverBlockchainSizeLimitBytes;
private Integer ownedPublicChannelsCount;
private Integer followingUsersCount;
private Integer followingChannelsCount;
private Integer closeFriendsCount;
public Boolean getExists() { return exists; }
public void setExists(Boolean exists) { this.exists = exists; }
@@ -74,4 +78,16 @@ public class Net_GetUser_Response extends Net_Response {
public Long getServerBlockchainSizeLimitBytes() { return serverBlockchainSizeLimitBytes; }
public void setServerBlockchainSizeLimitBytes(Long serverBlockchainSizeLimitBytes) { this.serverBlockchainSizeLimitBytes = serverBlockchainSizeLimitBytes; }
public Integer getOwnedPublicChannelsCount() { return ownedPublicChannelsCount; }
public void setOwnedPublicChannelsCount(Integer ownedPublicChannelsCount) { this.ownedPublicChannelsCount = ownedPublicChannelsCount; }
public Integer getFollowingUsersCount() { return followingUsersCount; }
public void setFollowingUsersCount(Integer followingUsersCount) { this.followingUsersCount = followingUsersCount; }
public Integer getFollowingChannelsCount() { return followingChannelsCount; }
public void setFollowingChannelsCount(Integer followingChannelsCount) { this.followingChannelsCount = followingChannelsCount; }
public Integer getCloseFriendsCount() { return closeFriendsCount; }
public void setCloseFriendsCount(Integer closeFriendsCount) { this.closeFriendsCount = closeFriendsCount; }
}
@@ -54,7 +54,6 @@ public class Net_GetUserSetting_Handler implements JsonMessageHandler {
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);
@@ -49,7 +49,6 @@ public class Net_ListUserSettings_Handler implements JsonMessageHandler {
item.setValue_num(e.getValueNum());
item.setClient_key(e.getClientKey());
item.setSignature(e.getSignature());
item.setSynced(e.isSynced());
items.add(item);
}
@@ -11,27 +11,21 @@ import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUs
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) {
@@ -54,7 +48,6 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
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;
@@ -63,7 +56,8 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
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");
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST,
"BAD_BASE64", "client_key/signature должны быть Base64");
}
String signText = ShineSignatureConstants.USER_SETTINGS_PREFIX
@@ -74,65 +68,29 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
+ 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);
try (Connection c = DbController.getInstance().getConnection()) {
CurrentUserEntry user = CurrentUsersDAO.getInstance().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 в БД");
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 не соответствует пользователю");
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);
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
"INVALID_SIGNATURE", "Подпись настройки не прошла проверку");
}
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);
}
}
login, settingType, settingKey, timeMs,
valueText, valueNum, clientKeyB64, signatureB64);
UserSettingsDAO.getInstance().upsertIfNewer(c, entry);
Net_UpsertUserSetting_Response resp = new Net_UpsertUserSetting_Response();
resp.setOp(req.getOp());
@@ -142,15 +100,18 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
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", "Ошибка БД");
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));
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
NetExceptionResponseFactory.detailedMessage(
"Внутренняя ошибка сервера при UpsertUserSetting", e));
}
}
@@ -11,7 +11,6 @@ public class Net_GetUserSetting_Response extends Net_Response {
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; }
@@ -37,6 +36,4 @@ public class Net_GetUserSetting_Response extends Net_Response {
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; }
}
@@ -24,7 +24,6 @@ public class Net_ListUserSettings_Response extends Net_Response {
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; }
@@ -42,7 +41,5 @@ public class Net_ListUserSettings_Response extends Net_Response {
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; }
}
}
@@ -11,7 +11,6 @@ public class Net_UpsertUserSetting_Request extends Net_Request {
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; }
@@ -37,6 +36,4 @@ public class Net_UpsertUserSetting_Request extends Net_Request {
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; }
}
@@ -7,7 +7,6 @@ public class Net_UpsertUserSetting_Response extends Net_Response {
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; }
@@ -21,6 +20,4 @@ public class Net_UpsertUserSetting_Response extends Net_Response {
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; }
}
@@ -1,108 +0,0 @@
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() {}
public static ApplyResult applySyncedBlob(String ownerLogin, String blobB64) throws Exception {
if (ownerLogin == null || ownerLogin.isBlank()) {
throw new IllegalArgumentException("EMPTY_OWNER_LOGIN");
}
if (blobB64 == null || blobB64.isBlank()) {
throw new IllegalArgumentException("EMPTY_BLOB");
}
SignedMessageBlock block = SignedMessagesCore.parseFromB64(blobB64);
SignedMessagesCore.verifyUsersAndSignature(block);
SignedMessageEntry entry = SignedMessagesCore.toEntry(block, "DmSyncBatch", null);
String owner = ownerLogin.trim();
boolean ownerMatchesTarget = entry.getTargetLogin().equalsIgnoreCase(owner);
boolean ownerMatchesDeletePair = block.isDeleteType()
&& (entry.getFromLogin().equalsIgnoreCase(owner) || entry.getToLogin().equalsIgnoreCase(owner));
if (!ownerMatchesTarget && !ownerMatchesDeletePair) {
throw new IllegalArgumentException("TARGET_LOGIN_MISMATCH");
}
SignedMessagesDAO.ApplyStatus status;
if (block.isContentType()) {
status = SignedMessagesDAO.getInstance().upsertIncomingCopy(entry);
} else if (block.isReadReceiptType()) {
status = SignedMessagesDAO.getInstance().insertIfAbsent(entry);
} else if (block.isMessageDeleteType()) {
status = SignedMessagesDAO.getInstance().applyDeleteMessage(entry);
} else if (block.isConversationDeleteType()) {
status = SignedMessagesDAO.getInstance().applyDeleteConversation(entry);
} else {
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
}
return new ApplyResult(entry.getMessageKey(), entry.getBaseKey(), entry.getMessageType(), status);
}
public record ApplyResult(
String messageKey,
String baseKey,
int messageType,
SignedMessagesDAO.ApplyStatus status
) {}
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);
}
}
}
@@ -11,7 +11,6 @@ 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 {
@@ -46,7 +45,6 @@ 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()) {
@@ -68,14 +66,4 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler {
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());
}
}
}
@@ -11,7 +11,6 @@ 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 {
@@ -46,7 +45,6 @@ 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()) {
@@ -68,14 +66,4 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler {
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());
}
}
}
@@ -1,49 +0,0 @@
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", "Внутренняя ошибка сервера");
}
}
}
@@ -9,7 +9,6 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessag
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;
@@ -70,17 +69,6 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
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());
@@ -11,7 +11,6 @@ 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;
@@ -48,13 +47,10 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
SignedMessageEntry incomingEntry;
SignedMessageEntry outgoingEntry;
boolean fromPeer = "ReceiveOutcomingMessage".equalsIgnoreCase(req.getOp())
|| !isBlank(req.getSourceServerLogin());
try {
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);
incomingEntry = SignedMessagesCore.toEntry(incoming, "SendMessagePair", originSessionId);
outgoingEntry = SignedMessagesCore.toEntry(outgoing, "SendMessagePair", originSessionId);
} catch (IllegalArgumentException ex) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
}
@@ -97,42 +93,22 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
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;
acceptedAtMs = 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
null, null, false
);
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());
}
// Первая доставка на единственный access-сервер получателя
// выполняется до ответа клиенту.
DmDeliveryCoordinator.processDueEntry(delivery);
delivery = DmDeliveryStateDAO.getInstance()
.getByOutgoingMessageKey(outgoingEntry.getMessageKey());
}
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
@@ -1,129 +0,0 @@
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;
}
}
@@ -1,61 +0,0 @@
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) {}
}
@@ -1,10 +0,0 @@
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; }
}
@@ -1,13 +0,0 @@
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; }
}
@@ -5,12 +5,9 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_SendMessagePair_Request extends Net_Request {
private String incomingBlobB64;
private String outgoingBlobB64;
private String sourceServerLogin;
public String getIncomingBlobB64() { return incomingBlobB64; }
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
public String getOutgoingBlobB64() { return outgoingBlobB64; }
public void setOutgoingBlobB64(String outgoingBlobB64) { this.outgoingBlobB64 = outgoingBlobB64; }
public String getSourceServerLogin() { return sourceServerLogin; }
public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; }
}
@@ -1,26 +0,0 @@
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; }
}
@@ -1,62 +0,0 @@
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; }
}
}
@@ -11,27 +11,19 @@ import shine.db.entities.BlockEntry;
import shine.db.entities.SyncServerEntry;
import utils.blockchain.BlockchainNameUtil;
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.List;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
/**
* Фоновая one-shot репликация AddBlock на серверы из локальной таблицы sync_servers.
* Фоновая репликация AddBlock через общий постоянный WSS-пул на серверы
* из локальной таблицы sync_servers.
*/
public final class AddBlockSyncService {
private static final Logger log = LoggerFactory.getLogger(AddBlockSyncService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(
1,
Math.max(2, Runtime.getRuntime().availableProcessors()),
@@ -100,14 +92,14 @@ public final class AddBlockSyncService {
}
private void replicateToPartner(SyncServerEntry partner, String blockchainName, int blockNumber, BlockEntry currentBlock) throws Exception {
String wsUrl = buildWsUrl(partner.getServerAddress());
String wsUrl = ServerConnectionPool.buildWsUrl(partner.getServerAddress());
if (wsUrl == null) {
log.warn("AddBlock sync skipped: invalid server_address for partner login={} address={}",
partner.getLogin(), partner.getServerAddress());
return;
}
AddBlockPushResult firstTry = pushBlock(wsUrl, blockchainName, currentBlock);
AddBlockPushResult firstTry = pushBlock(partner, blockchainName, currentBlock);
if (firstTry.ok()) {
log.info("AddBlock sync ok: partner={} blockchainName={} blockNumber={}",
partner.getLogin(), blockchainName, blockNumber);
@@ -142,7 +134,7 @@ public final class AddBlockSyncService {
}
for (BlockEntry blockEntry : missingBlocks) {
AddBlockPushResult backfillResult = pushBlock(wsUrl, blockchainName, blockEntry);
AddBlockPushResult backfillResult = pushBlock(partner, blockchainName, blockEntry);
if (!backfillResult.ok()) {
log.warn("AddBlock sync backfill failed: partner={} blockchainName={} blockNumber={} code={}",
partner.getLogin(), blockchainName, blockEntry.getBlockNumber(), backfillResult.code());
@@ -154,8 +146,8 @@ public final class AddBlockSyncService {
partner.getLogin(), blockchainName, fromBlockNumber, blockNumber);
}
private AddBlockPushResult pushBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception {
JsonNode response = sendAddBlock(wsUrl, blockchainName, blockEntry);
private AddBlockPushResult pushBlock(SyncServerEntry partner, String blockchainName, BlockEntry blockEntry) throws Exception {
JsonNode response = sendAddBlock(partner, blockchainName, blockEntry);
int status = response.path("status").asInt(500);
if (status >= 200 && status < 300) {
return AddBlockPushResult.success();
@@ -172,31 +164,16 @@ public final class AddBlockSyncService {
return new AddBlockPushResult(false, status, code, serverLastGlobalNumber, serverLastGlobalHash);
}
private JsonNode sendAddBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception {
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");
}
String requestId = "sync-" + UUID.randomUUID();
String json = buildAddBlockJson(requestId, blockchainName, blockEntry);
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
tryAbort(webSocket);
return MAPPER.readTree(responseJson);
private JsonNode sendAddBlock(SyncServerEntry partner, String blockchainName, BlockEntry blockEntry) throws Exception {
String jsonTemplate = buildAddBlockJsonTemplate(blockchainName, blockEntry);
return ServerConnectionPool.getInstance().request(
partner.getLogin(),
partner.getServerAddress(),
jsonTemplate,
ServerConnectionPool.Priority.BULK);
}
private String buildAddBlockJson(String requestId, String blockchainName, BlockEntry blockEntry) throws Exception {
private String buildAddBlockJsonTemplate(String blockchainName, BlockEntry blockEntry) throws Exception {
String prevHashHex = blockEntry.getBlockNumber() <= 0
? ""
: toHex(extractPrevHash32(blockEntry.getBlockBytes()));
@@ -205,8 +182,6 @@ public final class AddBlockSyncService {
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
String safePrevHashHex = MAPPER.writeValueAsString(prevHashHex);
String safeBlockBytes = MAPPER.writeValueAsString(blockBytesB64);
String safeRequestId = MAPPER.writeValueAsString(requestId);
return """
{
"op":"AddBlock",
@@ -218,7 +193,7 @@ public final class AddBlockSyncService {
"blockBytesB64":%s
}
}
""".formatted(safeRequestId, safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes);
""".formatted("%s", safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes);
}
private static byte[] extractPrevHash32(byte[] blockBytes) {
@@ -240,32 +215,6 @@ public final class AddBlockSyncService {
return s.isEmpty() ? null : s;
}
private static String buildWsUrl(String serverAddressRaw) {
String host = normalizeHostLike(serverAddressRaw);
if (host == null) return null;
return "wss://" + host + "/ws";
}
private static String normalizeHostLike(String value) {
if (value == null) return null;
String raw = value.trim();
if (raw.isEmpty()) return null;
try {
String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw;
URI uri = URI.create(withScheme);
String host = uri.getHost();
if (host == null || host.isBlank()) return null;
return host.trim().toLowerCase(Locale.ROOT);
} catch (Exception e) {
String cleaned = raw
.replaceFirst("^[a-zA-Z]+://", "")
.replaceFirst("/.*$", "")
.trim()
.toLowerCase(Locale.ROOT);
return cleaned.isEmpty() ? null : cleaned;
}
}
private static String toHex(byte[] bytes) {
if (bytes == null) return "";
StringBuilder sb = new StringBuilder(bytes.length * 2);
@@ -276,17 +225,6 @@ public final class AddBlockSyncService {
return sb.toString();
}
private static void tryAbort(WebSocket webSocket) {
try {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
} catch (Exception ignored) {
}
try {
webSocket.abort();
} catch (Exception ignored) {
}
}
private record AddBlockPushResult(
boolean ok,
int status,
@@ -309,52 +247,4 @@ public final class AddBlockSyncService {
}
}
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 CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (!responseFuture.isDone()) {
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);
}
openLatch.countDown();
}
}
}
@@ -4,7 +4,6 @@ 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;
@@ -14,21 +13,14 @@ 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-пары. Первая попытка выполняется до ответа
* клиенту, последующие пятисекундным воркером.
* Координатор доставки исходящей DM-пары на единственный первый access-сервер
* получателя. Первая попытка выполняется до ответа клиенту, последующие
* пятисекундным воркером.
*/
public final class DmDeliveryCoordinator {
private static final Logger log = LoggerFactory.getLogger(DmDeliveryCoordinator.class);
@@ -40,21 +32,10 @@ public final class DmDeliveryCoordinator {
60L * 60_000L
};
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient();
private static final RemoteDmDeliveryClient REMOTE = new RemoteDmDeliveryClient();
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() {}
@@ -63,10 +44,6 @@ public final class DmDeliveryCoordinator {
}
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();
@@ -79,27 +56,9 @@ public final class DmDeliveryCoordinator {
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(
DmDeliveryStateEntry afterAttempt = attemptPrimaryRecipientRoute(
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());
}
@@ -109,28 +68,18 @@ public final class DmDeliveryCoordinator {
}
}
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(
private static DmDeliveryStateEntry attemptPrimaryRecipientRoute(
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);
UserAccessServerRouteEntry route = primaryRoute(current.getToLogin());
String routesHash = routesHash(route);
if (route == null) {
return DELIVERY_DAO.updateAfterAttempt(
current.getEventId(), DmDeliveryStateEntry.ACCEPTED, null,
routesHash, nextAttemptAtMs, "RECIPIENT_ACCESS_SERVER_NOT_FOUND", System.currentTimeMillis());
}
SignedMessageEntry incoming = MESSAGES_DAO.getByMessageKey(current.getIncomingMessageKey());
if (incoming == null || incoming.getRawBlock() == null) {
@@ -138,136 +87,51 @@ public final class DmDeliveryCoordinator {
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;
String routeLogin = normalize(route.getServerLogin());
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());
if (!routeLogin.equals(ownServerLogin())) {
REMOTE.receiveIncomingMessage(
routeLogin,
route.getServerUrl(),
Base64.getEncoder().encodeToString(incoming.getRawBlock()),
ownServerLogin());
}
return current;
return DELIVERY_DAO.updateAfterAttempt(
current.getEventId(), DmDeliveryStateEntry.DELIVERED, routeLogin,
routesHash, nextAttemptAtMs, null, System.currentTimeMillis());
} catch (Exception e) {
log.info("DM delivery status unavailable: eventId={} server={}", current.getEventId(), peer.getServerLogin());
return current;
log.info("DM recipient server unavailable: messageKey={} server={}",
current.getOutgoingMessageKey(), routeLogin);
return DELIVERY_DAO.updateAfterAttempt(
current.getEventId(), DmDeliveryStateEntry.ACCEPTED, null,
routesHash, nextAttemptAtMs, compactError(e), System.currentTimeMillis());
}
}
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 {
UserAccessServerRouteEntry route = primaryRoute(ownerLogin);
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;
return route != null && own != null && own.equals(normalize(route.getServerLogin()));
}
public static String currentServerLogin() {
return ownServerLogin();
}
private static List<UserAccessServerRouteEntry> cappedRoutes(String ownerLogin) throws Exception {
Map<String, UserAccessServerRouteEntry> unique = new LinkedHashMap<>();
private static UserAccessServerRouteEntry primaryRoute(String ownerLogin) throws Exception {
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;
if (normalize(route.getServerLogin()) != null) return route;
}
return new ArrayList<>(unique.values());
return null;
}
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);
private static String routesHash(UserAccessServerRouteEntry route) throws Exception {
String normalized = route == null ? null : normalize(route.getServerLogin());
String login = normalized == null ? "" : normalized;
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(String.join("\n", logins).getBytes(StandardCharsets.UTF_8));
byte[] hash = digest.digest(login.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();
@@ -278,8 +142,6 @@ public final class DmDeliveryCoordinator {
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));
}
@@ -294,16 +156,4 @@ public final class DmDeliveryCoordinator {
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;
}
};
}
}
@@ -8,59 +8,17 @@ import utils.config.AppConfig;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/** Доставка DM-tombstone на единственные access-серверы обеих сторон. */
public final class DmFederationService {
private static final Logger log = LoggerFactory.getLogger(DmFederationService.class);
private static final String CONFIG_KEY = "server.SHiNE.login";
private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient();
private static final RemoteDmDeliveryClient REMOTE = new RemoteDmDeliveryClient();
private DmFederationService() {}
public static void fanOutPair(String fromLogin, String toLogin, String incomingBlobB64, String outgoingBlobB64) {
try {
Map<String, UserAccessServerRouteEntry> senderRoutes =
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin));
Map<String, UserAccessServerRouteEntry> recipientRoutes =
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin));
String ownServerLogin = ownServerLogin();
for (UserAccessServerRouteEntry route : senderRoutes.values()) {
if (isOwnServer(route, ownServerLogin)) continue;
REMOTE.sendMessagePair(route.getServerUrl(), incomingBlobB64, outgoingBlobB64, ownServerLogin);
}
for (UserAccessServerRouteEntry route : recipientRoutes.values()) {
if (isOwnServer(route, ownServerLogin)) continue;
if (senderRoutes.containsKey(normalize(route.getServerLogin()))) continue;
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin);
}
} catch (Exception e) {
log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e);
}
}
public static void fanOutIncomingToRecipientAccessServers(
String toLogin,
String incomingBlobB64,
String sourceServerLogin
) {
try {
Map<String, UserAccessServerRouteEntry> recipientRoutes =
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin));
String ownServerLogin = ownServerLogin();
String normalizedSource = normalize(sourceServerLogin);
for (UserAccessServerRouteEntry route : recipientRoutes.values()) {
String routeLogin = normalize(route.getServerLogin());
if (routeLogin == null) continue;
if (ownServerLogin != null && ownServerLogin.equalsIgnoreCase(routeLogin)) continue;
if (normalizedSource != null && normalizedSource.equalsIgnoreCase(routeLogin)) continue;
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin);
}
} catch (Exception e) {
log.warn("DM federation incoming relay failed: to={}", toLogin, e);
}
}
public static void fanOutDeleteMessage(String fromLogin, String toLogin, int messageType, String blobB64) {
fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true);
}
@@ -69,49 +27,50 @@ public final class DmFederationService {
fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, false);
}
private static void fanOutSingleDelete(String fromLogin, String toLogin, int messageType, String blobB64, boolean oneMessageDelete) {
private static void fanOutSingleDelete(
String fromLogin,
String toLogin,
int messageType,
String blobB64,
boolean oneMessageDelete
) {
try {
Map<String, UserAccessServerRouteEntry> routes = routesByLogin(
UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin),
UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin)
);
Map<String, UserAccessServerRouteEntry> routes = new LinkedHashMap<>();
putPrimaryRoute(routes, fromLogin);
putPrimaryRoute(routes, toLogin);
String ownServerLogin = ownServerLogin();
for (UserAccessServerRouteEntry route : routes.values()) {
if (isOwnServer(route, ownServerLogin)) continue;
if (oneMessageDelete) {
REMOTE.deleteMessage(route.getServerUrl(), blobB64);
REMOTE.deleteMessage(route.getServerLogin(), route.getServerUrl(), blobB64);
} else {
REMOTE.deleteConversation(route.getServerUrl(), blobB64);
REMOTE.deleteConversation(route.getServerLogin(), route.getServerUrl(), blobB64);
}
}
} catch (Exception e) {
log.warn("DM federation delete fan-out failed: from={} to={} type={}",
log.warn("DM delete delivery failed: from={} to={} type={}",
fromLogin, toLogin, messageType, e);
}
}
@SafeVarargs
private static Map<String, UserAccessServerRouteEntry> routesByLogin(
List<UserAccessServerRouteEntry>... routeLists
) {
Map<String, UserAccessServerRouteEntry> out = new LinkedHashMap<>();
for (List<UserAccessServerRouteEntry> routeList : routeLists) {
for (UserAccessServerRouteEntry route : routeList) {
if (route == null) continue;
String login = normalize(route.getServerLogin());
String address = route.getServerUrl() == null ? "" : route.getServerUrl().trim();
if (login == null || address.isBlank()) continue;
out.putIfAbsent(login, route);
}
private static void putPrimaryRoute(Map<String, UserAccessServerRouteEntry> out, String ownerLogin)
throws Exception {
List<UserAccessServerRouteEntry> routes =
UserAccessServersCurrentDAO.getInstance().listByUserLogin(ownerLogin);
for (UserAccessServerRouteEntry route : routes) {
if (route == null) continue;
String login = normalize(route.getServerLogin());
String address = route.getServerUrl() == null ? "" : route.getServerUrl().trim();
if (login == null || address.isBlank()) continue;
out.putIfAbsent(login, route);
return;
}
return out;
}
private static boolean isOwnServer(UserAccessServerRouteEntry route, String ownServerLogin) {
return ownServerLogin != null
&& route != null
&& route.getServerLogin() != null
&& ownServerLogin.equalsIgnoreCase(route.getServerLogin());
&& ownServerLogin.equals(normalize(route.getServerLogin()));
}
private static String ownServerLogin() {
@@ -120,7 +79,7 @@ public final class DmFederationService {
private static String normalize(String value) {
if (value == null) return null;
String s = value.trim().toLowerCase();
return s.isEmpty() ? null : s;
String normalized = value.trim().toLowerCase(Locale.ROOT);
return normalized.isEmpty() ? null : normalized;
}
}
@@ -2,37 +2,22 @@ package server.sync;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.ArrayList;
import java.util.List;
import java.util.Locale;
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;
/**
* Минимальный клиент для межсерверных JSON-op запросов по WSS.
*/
public final class RemoteBlockchainSyncClient {
private static final Logger log = LoggerFactory.getLogger(RemoteBlockchainSyncClient.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
public List<RemoteBlockchainHead> listBlockchainHeads(String serverAddressRaw) throws Exception {
JsonNode response = send(serverAddressRaw, """
return listBlockchainHeads(null, serverAddressRaw);
}
public List<RemoteBlockchainHead> listBlockchainHeads(String serverLogin, String serverAddressRaw) throws Exception {
JsonNode response = send(serverLogin, serverAddressRaw, """
{
"op":"ListBlockchainHeads",
"requestId":%s,
@@ -62,8 +47,12 @@ public final class RemoteBlockchainSyncClient {
}
public RemoteSyncUserProfile getSyncUserProfile(String serverAddressRaw, String login) throws Exception {
return getSyncUserProfile(null, serverAddressRaw, login);
}
public RemoteSyncUserProfile getSyncUserProfile(String serverLogin, String serverAddressRaw, String login) throws Exception {
String safeLogin = MAPPER.writeValueAsString(login);
JsonNode response = send(serverAddressRaw, """
JsonNode response = send(serverLogin, serverAddressRaw, """
{
"op":"GetSyncUserProfile",
"requestId":%s,
@@ -96,9 +85,17 @@ public final class RemoteBlockchainSyncClient {
);
}
public RemoteBlockchainBlock getBlockchainBlock(String serverAddressRaw, String blockchainName, int blockNumber) throws Exception {
public RemoteBlockchainBlock getBlockchainBlock(
String serverAddressRaw, String blockchainName, int blockNumber
) throws Exception {
return getBlockchainBlock(null, serverAddressRaw, blockchainName, blockNumber);
}
public RemoteBlockchainBlock getBlockchainBlock(
String serverLogin, String serverAddressRaw, String blockchainName, int blockNumber
) throws Exception {
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
JsonNode response = send(serverAddressRaw, """
JsonNode response = send(serverLogin, serverAddressRaw, """
{
"op":"GetBlockchainBlock",
"requestId":%s,
@@ -126,32 +123,12 @@ public final class RemoteBlockchainSyncClient {
);
}
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception {
String requestId = MAPPER.writeValueAsString("sync-" + UUID.randomUUID());
String json = jsonTemplate.formatted(requestId);
String wsUrl = 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 JsonNode send(String serverLogin, String serverAddressRaw, String jsonTemplate) throws Exception {
return ServerConnectionPool.getInstance().request(
serverLogin,
serverAddressRaw,
jsonTemplate,
ServerConnectionPool.Priority.BULK);
}
private static String errorCode(JsonNode response) {
@@ -161,40 +138,7 @@ public final class RemoteBlockchainSyncClient {
}
static String buildWsUrl(String serverAddressRaw) {
String host = normalizeHostLike(serverAddressRaw);
if (host == null) return null;
return "wss://" + host + "/ws";
}
private static String normalizeHostLike(String value) {
if (value == null) return null;
String raw = value.trim();
if (raw.isEmpty()) return null;
try {
String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw;
URI uri = URI.create(withScheme);
String host = uri.getHost();
if (host == null || host.isBlank()) return null;
return host.trim().toLowerCase(Locale.ROOT);
} catch (Exception e) {
String cleaned = raw
.replaceFirst("^[a-zA-Z]+://", "")
.replaceFirst("/.*$", "")
.trim()
.toLowerCase(Locale.ROOT);
return cleaned.isEmpty() ? null : cleaned;
}
}
private static void tryAbort(WebSocket webSocket) {
try {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
} catch (Exception ignored) {
}
try {
webSocket.abort();
} catch (Exception ignored) {
}
return ServerConnectionPool.buildWsUrl(serverAddressRaw);
}
public record RemoteBlockchainHead(
@@ -220,53 +164,4 @@ public final class RemoteBlockchainSyncClient {
long blockchainSizeLimitBytes
) {}
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 CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
}
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
log.warn("Remote sync websocket error: {}", String.valueOf(error));
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(error);
}
openLatch.countDown();
}
}
}
@@ -0,0 +1,74 @@
package server.sync;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
/** Клиент межсерверной доставки DM на единственный access-сервер получателя. */
public final class RemoteDmDeliveryClient {
private static final ObjectMapper MAPPER = new ObjectMapper();
public void receiveIncomingMessage(
String targetServerLogin,
String serverAddressRaw,
String incomingBlobB64,
String sourceServerLogin
) throws Exception {
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
JsonNode response = send(targetServerLogin, serverAddressRaw, """
{
"op":"ReceiveIncomingMessage",
"requestId":%s,
"payload":{
"incomingBlobB64":%s%s
}
}
""".formatted("%s", incomingJson, sourceServerLoginJson));
ensureOk("ReceiveIncomingMessage", response);
}
public void deleteMessage(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception {
String blobJson = MAPPER.writeValueAsString(blobB64);
JsonNode response = send(targetServerLogin, serverAddressRaw, """
{
"op":"DeleteMessage",
"requestId":%s,
"payload":{"blobB64":%s}
}
""".formatted("%s", blobJson));
ensureOk("DeleteMessage", response);
}
public void deleteConversation(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception {
String blobJson = MAPPER.writeValueAsString(blobB64);
JsonNode response = send(targetServerLogin, serverAddressRaw, """
{
"op":"DeleteConversation",
"requestId":%s,
"payload":{"blobB64":%s}
}
""".formatted("%s", blobJson));
ensureOk("DeleteConversation", response);
}
private JsonNode send(String targetServerLogin, String serverAddressRaw, String jsonTemplate) throws Exception {
return ServerConnectionPool.getInstance().request(
targetServerLogin,
serverAddressRaw,
jsonTemplate,
ServerConnectionPool.Priority.REALTIME);
}
private String toOptionalJsonField(String fieldName, String value) throws Exception {
if (value == null || value.isBlank()) return "";
return ",\n \"" + fieldName + "\":" + MAPPER.writeValueAsString(value.trim());
}
private void ensureOk(String op, JsonNode response) {
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);
}
}
@@ -3,37 +3,33 @@ 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.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;
/** Клиент стабильных межсерверных 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 {
sendMessagePair(null, serverAddressRaw, incomingBlobB64, outgoingBlobB64, sourceServerLogin);
}
public void sendMessagePair(
String targetServerLogin,
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);
JsonNode response = send(serverAddressRaw, """
JsonNode response = send(targetServerLogin, serverAddressRaw, """
{
"op":"ReceiveOutcomingMessage",
"requestId":%s,
@@ -50,10 +46,19 @@ public final class RemoteDmSyncClient {
String serverAddressRaw,
String incomingBlobB64,
String sourceServerLogin
) throws Exception {
receiveIncomingMessage(null, serverAddressRaw, incomingBlobB64, sourceServerLogin);
}
public void receiveIncomingMessage(
String targetServerLogin,
String serverAddressRaw,
String incomingBlobB64,
String sourceServerLogin
) throws Exception {
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
JsonNode response = send(serverAddressRaw, """
JsonNode response = send(targetServerLogin, serverAddressRaw, """
{
"op":"ReceiveIncomingMessage",
"requestId":%s,
@@ -66,8 +71,14 @@ public final class RemoteDmSyncClient {
}
public RemoteDeliveryStatus getDmDeliveryStatus(String serverAddressRaw, String messageKey) throws Exception {
return getDmDeliveryStatus(null, serverAddressRaw, messageKey);
}
public RemoteDeliveryStatus getDmDeliveryStatus(
String targetServerLogin, String serverAddressRaw, String messageKey
) throws Exception {
String messageKeyJson = MAPPER.writeValueAsString(messageKey);
JsonNode response = send(serverAddressRaw, """
JsonNode response = send(targetServerLogin, serverAddressRaw, """
{
"op":"GetDmDeliveryStatus",
"requestId":%s,
@@ -94,7 +105,21 @@ public final class RemoteDmSyncClient {
int maxBytes,
List<String> ackSyncIds
) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(serverAddressRaw)) {
return dmSyncBatch(null, serverAddressRaw, ownerLogin, afterStoredAtMs,
afterMessageKey, limit, maxBytes, ackSyncIds);
}
public RemoteDmBatch dmSyncBatch(
String targetServerLogin,
String serverAddressRaw,
String ownerLogin,
long afterStoredAtMs,
String afterMessageKey,
int limit,
int maxBytes,
List<String> ackSyncIds
) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
return dmSyncBatch(session, ownerLogin, afterStoredAtMs, afterMessageKey,
limit, maxBytes, ackSyncIds);
}
@@ -156,8 +181,12 @@ public final class RemoteDmSyncClient {
}
public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception {
deleteMessage(null, serverAddressRaw, blobB64);
}
public void deleteMessage(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception {
String blobJson = MAPPER.writeValueAsString(blobB64);
JsonNode response = send(serverAddressRaw, """
JsonNode response = send(targetServerLogin, serverAddressRaw, """
{
"op":"DeleteMessage",
"requestId":%s,
@@ -168,8 +197,12 @@ public final class RemoteDmSyncClient {
}
public void deleteConversation(String serverAddressRaw, String blobB64) throws Exception {
deleteConversation(null, serverAddressRaw, blobB64);
}
public void deleteConversation(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception {
String blobJson = MAPPER.writeValueAsString(blobB64);
JsonNode response = send(serverAddressRaw, """
JsonNode response = send(targetServerLogin, serverAddressRaw, """
{
"op":"DeleteConversation",
"requestId":%s,
@@ -179,27 +212,12 @@ public final class RemoteDmSyncClient {
ensureOk("DeleteConversation", response);
}
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception {
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);
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 JsonNode send(String targetServerLogin, String serverAddressRaw, String jsonTemplate) throws Exception {
return ServerConnectionPool.getInstance().request(
targetServerLogin,
serverAddressRaw,
jsonTemplate,
ServerConnectionPool.Priority.REALTIME);
}
private String toOptionalJsonField(String fieldName, String value) throws Exception {
@@ -215,60 +233,10 @@ public final class RemoteDmSyncClient {
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 RemoteDeliveryStatus(String messageKey, boolean known, boolean delivered) {}
public record RemoteDmBatch(long nextStoredAtMs, String nextMessageKey, boolean hasMore, List<RemoteDmItem> items) {}
public record RemoteDmItem(
String syncId, String primaryMessageKey, long storedAtMs, List<String> blobsB64
) {}
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 CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (!responseFuture.isDone()) {
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);
openLatch.countDown();
}
}
}
@@ -1,70 +1,35 @@
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-сервера. */
/**
* Логический последовательный сеанс поверх общего постоянного WSS-пула.
* close() больше не закрывает физическое соединение с сервером.
*/
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;
private final String serverLogin;
private final String serverAddress;
/** Совместимый конструктор: при отсутствии логина пул использует адрес как ключ peer. */
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);
this(null, serverAddressRaw);
}
public RemoteSyncSession(String serverLogin, String serverAddressRaw) {
this.serverLogin = serverLogin;
this.serverAddress = serverAddressRaw;
}
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);
return ServerConnectionPool.getInstance().request(
serverLogin,
serverAddress,
jsonTemplate,
ServerConnectionPool.Priority.NORMAL);
}
@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\"}");
}
// Физическое соединение принадлежит ServerConnectionPool.
}
}
@@ -4,27 +4,22 @@ 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)) {
public void upsertUserSetting(
String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery
) throws Exception {
upsertUserSetting(null, serverAddressRaw, entry, syncDelivery);
}
public void upsertUserSetting(
String targetServerLogin, String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery
) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
upsertUserSetting(session, entry, syncDelivery);
}
}
@@ -69,7 +64,20 @@ public final class RemoteUserSettingsSyncClient {
int limit,
int maxBytes
) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(serverAddressRaw)) {
return userSettingsSyncBatch(null, serverAddressRaw, ownerLogin,
afterTimeMs, afterSettingKey, limit, maxBytes);
}
public RemoteUserSettingsBatch userSettingsSyncBatch(
String targetServerLogin,
String serverAddressRaw,
String ownerLogin,
long afterTimeMs,
String afterSettingKey,
int limit,
int maxBytes
) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
return userSettingsSyncBatch(session, ownerLogin, afterTimeMs, afterSettingKey, limit, maxBytes);
}
}
@@ -129,34 +137,6 @@ public final class RemoteUserSettingsSyncClient {
);
}
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;
@@ -165,17 +145,6 @@ public final class RemoteUserSettingsSyncClient {
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,
@@ -194,35 +163,4 @@ public final class RemoteUserSettingsSyncClient {
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);
}
}
}
@@ -0,0 +1,763 @@
package server.sync;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.dao.SyncServersDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.SyncServerEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
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.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.PriorityBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
/**
* Общий пул постоянных WSS-соединений между физическими серверами SHiNE.
*
* <p>Пул меняет только транспорт: существующие JSON-операции, ACK, outbox и
* расписания повторов остаются обязанностью вызывающих сервисов.</p>
*/
public final class ServerConnectionPool implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(ServerConnectionPool.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
private static final int REQUEST_WORKERS = Math.max(
8, Math.min(32, Runtime.getRuntime().availableProcessors() * 2));
private static final int CONNECTION_WORKERS = Math.max(
4, Math.min(16, Runtime.getRuntime().availableProcessors()));
private static final ServerConnectionPool INSTANCE = new ServerConnectionPool();
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
private final ConcurrentHashMap<String, PeerConnection> peers = new ConcurrentHashMap<>();
private final AtomicBoolean started = new AtomicBoolean(false);
private final AtomicBoolean closed = new AtomicBoolean(false);
private final AtomicLong requestSequence = new AtomicLong();
private final ThreadPoolExecutor requestExecutor = new ThreadPoolExecutor(
REQUEST_WORKERS,
REQUEST_WORKERS,
60L,
TimeUnit.SECONDS,
new java.util.concurrent.LinkedBlockingQueue<>(10_000),
daemonThreadFactory("server-pool-request"),
new ThreadPoolExecutor.CallerRunsPolicy());
private final ThreadPoolExecutor connectionExecutor = new ThreadPoolExecutor(
CONNECTION_WORKERS,
CONNECTION_WORKERS,
60L,
TimeUnit.SECONDS,
new java.util.concurrent.LinkedBlockingQueue<>(10_000),
daemonThreadFactory("server-pool-connect"),
new ThreadPoolExecutor.DiscardPolicy());
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(
2, daemonThreadFactory("server-pool-scheduler"));
private ServerConnectionPool() {}
public static ServerConnectionPool getInstance() {
return INSTANCE;
}
public void startOrLog() {
if (closed.get() || !started.compareAndSet(false, true)) return;
scheduler.scheduleWithFixedDelay(this::refreshKnownPeersSafe, 0L, 30L, TimeUnit.SECONDS);
scheduler.scheduleWithFixedDelay(this::healthCheckSafe, 10L, 10L, TimeUnit.SECONDS);
scheduler.scheduleWithFixedDelay(this::logMetricsSafe, 5L, 5L, TimeUnit.MINUTES);
log.info("Server connection pool started: adaptive ping={}s pongTimeout={}s",
pingIdleSeconds(), pongTimeoutSeconds());
}
public JsonNode request(
String serverLogin,
String serverAddress,
String jsonTemplate,
Priority priority
) throws Exception {
startOrLog();
String normalizedLogin = normalizeLogin(serverLogin);
if (normalizedLogin == null) {
normalizedLogin = loginFromAddress(serverAddress);
}
if (normalizedLogin == null) {
throw new IllegalArgumentException("Server login and address are empty");
}
PeerConnection peer = registerPeer(normalizedLogin, serverAddress);
return peer.request(jsonTemplate, priority == null ? Priority.NORMAL : priority);
}
public PeerConnection registerPeer(String serverLogin, String serverAddress) {
String login = normalizeLogin(serverLogin);
String wsUrl = buildWsUrl(serverAddress);
if (login == null || wsUrl == null) {
throw new IllegalArgumentException("Invalid server peer: login=" + serverLogin + " address=" + serverAddress);
}
PeerConnection peer = peers.computeIfAbsent(login, ignored -> new PeerConnection(login, wsUrl));
peer.updateWsUrl(wsUrl);
peer.ensureConnectedInBackground(0L);
return peer;
}
public List<PeerMetricsSnapshot> snapshotMetrics() {
List<PeerMetricsSnapshot> result = new ArrayList<>();
for (PeerConnection peer : peers.values()) result.add(peer.snapshot());
result.sort(Comparator.comparing(PeerMetricsSnapshot::serverLogin));
return List.copyOf(result);
}
private void refreshKnownPeersSafe() {
if (closed.get()) return;
try {
String ownLogin = normalizeLogin(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
Map<String, String> discovered = new LinkedHashMap<>();
for (SyncServerEntry entry : SyncServersDAO.getInstance().listAll()) {
if (entry == null) continue;
putDiscovered(discovered, ownLogin, entry.getLogin(), entry.getServerAddress());
}
for (UserAccessServerRouteEntry entry : UserAccessServersCurrentDAO.getInstance().listDistinctServers()) {
if (entry == null) continue;
putDiscovered(discovered, ownLogin, entry.getServerLogin(), entry.getServerUrl());
}
for (Map.Entry<String, String> entry : discovered.entrySet()) {
try {
registerPeer(entry.getKey(), entry.getValue());
} catch (Exception e) {
log.warn("Server pool peer registration failed: server={} reason={}",
entry.getKey(), compactError(e));
}
}
} catch (Exception e) {
log.warn("Server pool peer refresh failed: {}", compactError(e));
}
}
private static void putDiscovered(
Map<String, String> discovered,
String ownLogin,
String serverLogin,
String serverAddress
) {
String login = normalizeLogin(serverLogin);
if (login == null || login.equals(ownLogin) || buildWsUrl(serverAddress) == null) return;
discovered.put(login, serverAddress);
}
private void healthCheckSafe() {
if (closed.get()) return;
for (PeerConnection peer : peers.values()) {
try {
peer.healthCheck();
} catch (Exception e) {
log.debug("Server pool health check failed: server={} reason={}",
peer.serverLogin, compactError(e));
}
}
}
private void logMetricsSafe() {
if (closed.get() || peers.isEmpty()) return;
long connected = peers.values().stream().filter(p -> p.state == ConnectionState.CONNECTED).count();
int queued = peers.values().stream().mapToInt(p -> p.queuedCount.get()).sum();
long errors = peers.values().stream().mapToLong(p -> p.failedRequests.get()).sum();
log.info("Server pool metrics: peers={} connected={} queued={} failedRequests={}",
peers.size(), connected, queued, errors);
}
@Override
public void close() {
if (!closed.compareAndSet(false, true)) return;
for (PeerConnection peer : peers.values()) peer.closeConnection("pool_shutdown");
scheduler.shutdownNow();
requestExecutor.shutdownNow();
connectionExecutor.shutdownNow();
peers.clear();
}
public enum Priority {
REALTIME(0),
NORMAL(1),
BULK(2);
private final int rank;
Priority(int rank) { this.rank = rank; }
}
public enum ConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED,
CLOSED
}
public record PeerMetricsSnapshot(
String serverLogin,
String wsUrl,
ConnectionState state,
long connectedAtMs,
long lastActivityAtMs,
long lastPingAtMs,
long lastPongAtMs,
long reconnectCount,
int queuedRealtime,
int queuedNormal,
int queuedBulk,
long successfulRequests,
long failedRequests,
long timedOutRequests,
String lastError
) {}
public final class PeerConnection {
private final String serverLogin;
private final PriorityBlockingQueue<QueuedRequest> queue = new PriorityBlockingQueue<>();
private final ConcurrentHashMap<String, CompletableFuture<JsonNode>> pending = new ConcurrentHashMap<>();
private final AtomicBoolean drainScheduled = new AtomicBoolean(false);
private final AtomicBoolean reconnectScheduled = new AtomicBoolean(false);
private final AtomicInteger queuedCount = new AtomicInteger();
private final AtomicInteger reconnectAttempt = new AtomicInteger();
private final AtomicLong generation = new AtomicLong();
private final AtomicLong reconnectCount = new AtomicLong();
private final AtomicLong successfulRequests = new AtomicLong();
private final AtomicLong failedRequests = new AtomicLong();
private final AtomicLong timedOutRequests = new AtomicLong();
private final Object connectLock = new Object();
private volatile String wsUrl;
private volatile ConnectionState state = ConnectionState.DISCONNECTED;
private volatile WebSocket webSocket;
private volatile CompletableFuture<Void> readiness;
private volatile long connectedAtMs;
private volatile long lastActivityAtMs = System.currentTimeMillis();
private volatile long lastPingAtMs;
private volatile long lastPongAtMs;
private volatile long pingAwaitedSinceMs;
private volatile String lastError = "";
private PeerConnection(String serverLogin, String wsUrl) {
this.serverLogin = serverLogin;
this.wsUrl = wsUrl;
}
private JsonNode request(String jsonTemplate, Priority priority) throws Exception {
if (jsonTemplate == null || jsonTemplate.isBlank()) {
throw new IllegalArgumentException("JSON request template is empty");
}
int maxQueue = (int) configLong("server.pool.maxQueuePerPeer", 2_000L, 10L, 100_000L);
int queued = queuedCount.incrementAndGet();
if (queued > maxQueue) {
queuedCount.decrementAndGet();
throw new IllegalStateException("Server peer queue is full: " + serverLogin);
}
QueuedRequest task = new QueuedRequest(
priority,
requestSequence.incrementAndGet(),
jsonTemplate,
new CompletableFuture<>());
queue.offer(task);
scheduleDrain();
long timeoutSeconds = configLong("server.pool.callerTimeoutSeconds", 35L, 5L, 120L);
try {
return task.result.get(timeoutSeconds, TimeUnit.SECONDS);
} catch (java.util.concurrent.TimeoutException e) {
timedOutRequests.incrementAndGet();
if (queue.remove(task)) queuedCount.decrementAndGet();
throw new TimeoutException("Server pool caller timeout: " + serverLogin);
}
}
private void scheduleDrain() {
if (!drainScheduled.compareAndSet(false, true)) return;
requestExecutor.execute(this::drainQueue);
}
private void drainQueue() {
try {
for (;;) {
QueuedRequest task = queue.poll();
if (task == null) return;
queuedCount.decrementAndGet();
if (task.result.isDone()) continue;
try {
ensureConnectedBlocking();
JsonNode response = sendTemplate(task.jsonTemplate)
.get(requestTimeoutSeconds(), TimeUnit.SECONDS);
successfulRequests.incrementAndGet();
task.result.complete(response);
} catch (java.util.concurrent.TimeoutException e) {
timedOutRequests.incrementAndGet();
failedRequests.incrementAndGet();
task.result.completeExceptionally(
new TimeoutException("Server pool response timeout: " + serverLogin));
invalidateConnection("response_timeout", e);
} catch (Exception e) {
failedRequests.incrementAndGet();
task.result.completeExceptionally(unwrap(e));
if (state != ConnectionState.DISCONNECTED) {
invalidateConnection("request_failed", e);
}
}
}
} finally {
drainScheduled.set(false);
if (!queue.isEmpty()) scheduleDrain();
}
}
private void ensureConnectedBlocking() throws Exception {
CompletableFuture<Void> localReady;
synchronized (connectLock) {
if (state == ConnectionState.CONNECTED && webSocket != null && !webSocket.isOutputClosed()) return;
if (state == ConnectionState.CLOSED || closed.get()) {
throw new IllegalStateException("Server connection pool is closed");
}
if (readiness == null || readiness.isDone()) startConnectLocked();
localReady = readiness;
}
localReady.get(connectAndHelloTimeoutSeconds(), TimeUnit.SECONDS);
}
private void startConnectLocked() {
long currentGeneration = generation.incrementAndGet();
state = ConnectionState.CONNECTING;
lastError = "";
CompletableFuture<Void> ready = new CompletableFuture<>();
readiness = ready;
Listener listener = new Listener(this, currentGeneration);
http.newWebSocketBuilder()
.connectTimeout(Duration.ofSeconds(6))
.buildAsync(URI.create(wsUrl), listener)
.thenCompose(ws -> {
if (generation.get() != currentGeneration) {
try { ws.abort(); } catch (Exception ignored) {}
return CompletableFuture.failedFuture(new IllegalStateException("Superseded server connection"));
}
webSocket = ws;
return sendServerHello(ws);
})
.whenComplete((ignored, error) -> {
if (error != null) {
ready.completeExceptionally(unwrap(error));
invalidateConnection(currentGeneration, "connect_or_hello_failed", error);
return;
}
if (generation.get() != currentGeneration) {
ready.completeExceptionally(new IllegalStateException("Superseded server connection"));
return;
}
long now = System.currentTimeMillis();
connectedAtMs = now;
lastActivityAtMs = now;
lastPongAtMs = now;
pingAwaitedSinceMs = 0L;
reconnectAttempt.set(0);
state = ConnectionState.CONNECTED;
ready.complete(null);
log.info("Server pool connected: server={} url={}", serverLogin, wsUrl);
});
}
private CompletableFuture<Void> sendServerHello(WebSocket ws) {
try {
String ownLogin = normalizeLogin(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
if (ownLogin == null) ownLogin = "unconfigured-server";
String requestId = "server-hello-" + UUID.randomUUID();
CompletableFuture<JsonNode> response = new CompletableFuture<>();
pending.put(requestId, response);
String json = """
{
"op":"ServerHello",
"requestId":%s,
"payload":{
"serverLogin":%s,
"protocolVersion":1,
"capabilities":["dm-sync","settings-sync","block-sync","connection-pool"]
}
}
""".formatted(
MAPPER.writeValueAsString(requestId),
MAPPER.writeValueAsString(ownLogin));
lastActivityAtMs = System.currentTimeMillis();
ws.sendText(json, true).whenComplete((ignored, error) -> {
if (error != null) {
pending.remove(requestId);
response.completeExceptionally(error);
}
});
return response.orTimeout(requestTimeoutSeconds(), TimeUnit.SECONDS)
.thenAccept(node -> ensureOk("ServerHello", node));
} catch (Exception e) {
return CompletableFuture.failedFuture(e);
}
}
private CompletableFuture<JsonNode> sendTemplate(String jsonTemplate) throws Exception {
WebSocket ws = webSocket;
if (state != ConnectionState.CONNECTED || ws == null || ws.isOutputClosed()) {
return CompletableFuture.failedFuture(new IllegalStateException("Server peer is disconnected"));
}
String requestId = "server-pool-" + UUID.randomUUID();
String requestIdJson = MAPPER.writeValueAsString(requestId);
String json = fillRequestId(jsonTemplate, requestIdJson);
CompletableFuture<JsonNode> response = new CompletableFuture<>();
pending.put(requestId, response);
lastActivityAtMs = System.currentTimeMillis();
ws.sendText(json, true).whenComplete((ignored, error) -> {
if (error != null) {
pending.remove(requestId);
response.completeExceptionally(error);
}
});
return response;
}
private void acceptText(long listenerGeneration, String text) {
if (generation.get() != listenerGeneration || text == null || text.isBlank()) return;
lastActivityAtMs = System.currentTimeMillis();
try {
JsonNode node = MAPPER.readTree(text);
String requestId = node.path("requestId").asText("");
CompletableFuture<JsonNode> response = requestId.isBlank() ? null : pending.remove(requestId);
if (response != null) {
response.complete(node);
} else {
log.debug("Server pool ignored unmatched frame: server={} requestId={} op={}",
serverLogin, requestId, node.path("op").asText(""));
}
} catch (Exception e) {
log.warn("Server pool received invalid JSON: server={} reason={}", serverLogin, compactError(e));
}
}
private void healthCheck() {
if (state == ConnectionState.DISCONNECTED) {
ensureConnectedInBackground(reconnectDelayMillis(reconnectAttempt.get()));
return;
}
if (state != ConnectionState.CONNECTED) return;
long now = System.currentTimeMillis();
if (pingAwaitedSinceMs > 0L) {
if (now - pingAwaitedSinceMs >= TimeUnit.SECONDS.toMillis(pongTimeoutSeconds())) {
invalidateConnection("pong_timeout", new TimeoutException("Pong timeout"));
}
return;
}
if (now - lastActivityAtMs < TimeUnit.SECONDS.toMillis(pingIdleSeconds())) return;
WebSocket ws = webSocket;
if (ws == null || ws.isOutputClosed()) {
invalidateConnection("socket_closed", null);
return;
}
long nonce = now;
pingAwaitedSinceMs = now;
lastPingAtMs = now;
ws.sendPing(ByteBuffer.allocate(Long.BYTES).putLong(0, nonce))
.whenComplete((ignored, error) -> {
if (error != null) invalidateConnection("ping_failed", error);
});
}
private void acceptPong(long listenerGeneration) {
if (generation.get() != listenerGeneration) return;
long now = System.currentTimeMillis();
lastPongAtMs = now;
lastActivityAtMs = now;
pingAwaitedSinceMs = 0L;
}
private void updateWsUrl(String newWsUrl) {
if (newWsUrl.equals(wsUrl)) return;
wsUrl = newWsUrl;
invalidateConnection("peer_url_changed", null);
}
private void ensureConnectedInBackground(long delayMs) {
if (closed.get() || state == ConnectionState.CLOSED) return;
if (!reconnectScheduled.compareAndSet(false, true)) return;
scheduler.schedule(() -> {
reconnectScheduled.set(false);
if (closed.get() || state == ConnectionState.CLOSED || state == ConnectionState.CONNECTED) return;
connectionExecutor.execute(() -> {
try {
ensureConnectedBlocking();
} catch (Exception e) {
lastError = compactError(e);
// Ошибка connect/ServerHello сама инвалидирует поколение и планирует reconnect.
// Здесь второй schedule дал бы двойной рост backoff для одной попытки.
}
});
}, Math.max(0L, delayMs), TimeUnit.MILLISECONDS);
}
private void scheduleReconnect() {
if (closed.get() || state == ConnectionState.CLOSED) return;
int attempt = reconnectAttempt.getAndUpdate(value -> Math.min(value + 1, 30));
reconnectCount.incrementAndGet();
ensureConnectedInBackground(reconnectDelayMillis(attempt));
}
private long reconnectDelayMillis(int attempt) {
long[] seconds = {1L, 2L, 4L, 8L, 15L, 30L, 60L};
long base = seconds[Math.min(Math.max(0, attempt), seconds.length - 1)];
long halfMs = TimeUnit.SECONDS.toMillis(base) / 2L;
long jitterMs = java.util.concurrent.ThreadLocalRandom.current().nextLong(halfMs + 1L);
return halfMs + jitterMs;
}
private void invalidateConnection(String reason, Throwable error) {
invalidateConnection(generation.get(), reason, error);
}
private void invalidateConnection(long expectedGeneration, String reason, Throwable error) {
if (state == ConnectionState.CLOSED
|| !generation.compareAndSet(expectedGeneration, expectedGeneration + 1L)) return;
lastError = error == null ? reason : reason + ": " + compactError(error);
state = ConnectionState.DISCONNECTED;
pingAwaitedSinceMs = 0L;
WebSocket ws = webSocket;
webSocket = null;
if (ws != null) {
try { ws.abort(); } catch (Exception ignored) {}
}
Exception failure = new IllegalStateException("Server connection lost: " + serverLogin + " (" + reason + ")");
for (Map.Entry<String, CompletableFuture<JsonNode>> entry : pending.entrySet()) {
if (pending.remove(entry.getKey(), entry.getValue())) {
entry.getValue().completeExceptionally(failure);
}
}
scheduleReconnect();
}
private void closeConnection(String reason) {
state = ConnectionState.CLOSED;
generation.incrementAndGet();
WebSocket ws = webSocket;
webSocket = null;
if (ws != null) {
try { ws.sendClose(WebSocket.NORMAL_CLOSURE, reason); } catch (Exception ignored) {}
try { ws.abort(); } catch (Exception ignored) {}
}
IllegalStateException failure = new IllegalStateException("Server connection closed: " + serverLogin);
for (CompletableFuture<JsonNode> future : pending.values()) future.completeExceptionally(failure);
pending.clear();
for (QueuedRequest request : queue) request.result.completeExceptionally(failure);
queue.clear();
queuedCount.set(0);
}
private PeerMetricsSnapshot snapshot() {
int realtime = 0;
int normal = 0;
int bulk = 0;
for (QueuedRequest request : queue) {
switch (request.priority) {
case REALTIME -> realtime++;
case NORMAL -> normal++;
case BULK -> bulk++;
}
}
return new PeerMetricsSnapshot(
serverLogin, wsUrl, state, connectedAtMs, lastActivityAtMs,
lastPingAtMs, lastPongAtMs, reconnectCount.get(), realtime, normal, bulk,
successfulRequests.get(), failedRequests.get(), timedOutRequests.get(), lastError);
}
}
private record QueuedRequest(
Priority priority,
long sequence,
String jsonTemplate,
CompletableFuture<JsonNode> result
) implements Comparable<QueuedRequest> {
@Override
public int compareTo(QueuedRequest other) {
int byPriority = Integer.compare(priority.rank, other.priority.rank);
return byPriority != 0 ? byPriority : Long.compare(sequence, other.sequence);
}
}
private static final class Listener implements WebSocket.Listener {
private final PeerConnection peer;
private final long generation;
private final StringBuilder text = new StringBuilder();
private Listener(PeerConnection peer, long generation) {
this.peer = peer;
this.generation = generation;
}
@Override
public void onOpen(WebSocket webSocket) {
webSocket.request(1);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
text.append(data);
if (last) {
peer.acceptText(generation, text.toString());
text.setLength(0);
}
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onPong(WebSocket webSocket, ByteBuffer message) {
peer.acceptPong(generation);
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
peer.invalidateConnection(generation, "remote_close_" + statusCode, null);
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
peer.invalidateConnection(generation, "websocket_error", error);
}
}
public static String buildWsUrl(String serverAddressRaw) {
if (serverAddressRaw == null) return null;
String raw = serverAddressRaw.trim();
if (raw.isEmpty()) return null;
try {
String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw;
URI uri = URI.create(withScheme);
String host = uri.getHost();
if (host == null || host.isBlank()) return null;
int port = uri.getPort();
String authority = host.trim().toLowerCase(Locale.ROOT) + (port > 0 ? ":" + port : "");
return "wss://" + authority + "/ws";
} catch (Exception e) {
return null;
}
}
private static String loginFromAddress(String serverAddress) {
String wsUrl = buildWsUrl(serverAddress);
if (wsUrl == null) return null;
try {
return "address:" + URI.create(wsUrl).getAuthority().toLowerCase(Locale.ROOT);
} catch (Exception e) {
return null;
}
}
private static String normalizeLogin(String value) {
if (value == null) return null;
String normalized = value.trim().toLowerCase(Locale.ROOT);
return normalized.isEmpty() ? null : normalized;
}
private static void ensureOk(String op, JsonNode response) {
int status = response == null ? 500 : response.path("status").asInt(500);
if (status >= 200 && status < 300) return;
String code = response == null ? "EMPTY_RESPONSE" : response.path("code").asText("");
if (code.isBlank() && response != null) code = response.path("error").asText("");
throw new IllegalStateException(op + " failed: status=" + status + " code=" + code);
}
private static String fillRequestId(String template, String requestIdJson) {
int marker = template.indexOf("%s");
if (marker < 0) throw new IllegalArgumentException("JSON template has no requestId marker");
return template.substring(0, marker) + requestIdJson + template.substring(marker + 2);
}
private static Throwable unwrap(Throwable error) {
Throwable current = error;
while ((current instanceof java.util.concurrent.CompletionException
|| current instanceof java.util.concurrent.ExecutionException)
&& current.getCause() != null) {
current = current.getCause();
}
return current;
}
private static Exception unwrap(Exception error) {
Throwable unwrapped = unwrap((Throwable) error);
return unwrapped instanceof Exception e ? e : new IllegalStateException(unwrapped);
}
private static String compactError(Throwable error) {
String text = String.valueOf(error == null ? "unknown" : error.getMessage());
return text.length() <= 500 ? text : text.substring(0, 500);
}
private static long requestTimeoutSeconds() {
return configLong("server.pool.requestTimeoutSeconds", 12L, 3L, 120L);
}
private static long connectAndHelloTimeoutSeconds() {
return configLong("server.pool.connectTimeoutSeconds", 15L, 5L, 120L);
}
private static long pingIdleSeconds() {
return configLong("server.pool.pingIdleSeconds", 120L, 15L, 240L);
}
private static long pongTimeoutSeconds() {
return configLong("server.pool.pongTimeoutSeconds", 15L, 5L, 120L);
}
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 value = Long.parseLong(raw.trim());
return Math.max(min, Math.min(max, value));
} catch (Exception ignored) {
return defaultValue;
}
}
private static ThreadFactory daemonThreadFactory(String prefix) {
return new ThreadFactory() {
private final AtomicLong sequence = new AtomicLong();
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r, prefix + "-" + sequence.incrementAndGet());
thread.setDaemon(true);
return thread;
}
};
}
}
@@ -1091,7 +1091,7 @@ public final class PostgresStorageRepository
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
WHERE ord = 1
) AS access_server
JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
@@ -1138,7 +1138,7 @@ public final class PostgresStorageRepository
ELSE u.access_servers_json::jsonb
END
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
WHERE ord = 1
) AS access_server
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP
@@ -67,7 +67,8 @@ public final class BlockchainResyncRecoveryOnStartup {
blockchainName, partnerLogin, partnerAddress);
try {
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads = REMOTE.listBlockchainHeads(partnerAddress);
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads =
REMOTE.listBlockchainHeads(partnerLogin, partnerAddress);
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream()
.filter(h -> h != null && blockchainName.equals(h.blockchainName()))
.findFirst()
@@ -102,7 +102,7 @@ public final class PeriodicBlockchainSyncService {
if (partnerLogin == null) return;
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> remoteHeads =
REMOTE.listBlockchainHeads(partner.getServerAddress());
REMOTE.listBlockchainHeads(partner.getLogin(), partner.getServerAddress());
for (RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead : remoteHeads) {
if (remoteHead == null || remoteHead.blockchainName() == null || remoteHead.blockchainName().isBlank()) {
@@ -170,7 +170,8 @@ public final class PeriodicBlockchainSyncService {
int fromBlockNumber = Math.max(localLast + 1, 0);
for (int blockNumber = fromBlockNumber; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) {
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock =
REMOTE.getBlockchainBlock(partner.getServerAddress(), remoteHead.blockchainName(), blockNumber);
REMOTE.getBlockchainBlock(
partner.getLogin(), partner.getServerAddress(), remoteHead.blockchainName(), blockNumber);
if (remoteBlock == null) {
log.warn("Periodic blockchain sync: remote block not found. partner={} blockchainName={} blockNumber={}",
partnerLogin, remoteHead.blockchainName(), blockNumber);
@@ -284,7 +285,8 @@ public final class PeriodicBlockchainSyncService {
String localPrevHash = "";
for (int blockNumber = 0; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) {
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock =
REMOTE.getBlockchainBlock(partner.getServerAddress(), blockchainName, blockNumber);
REMOTE.getBlockchainBlock(
partner.getLogin(), partner.getServerAddress(), blockchainName, blockNumber);
if (remoteBlock == null) {
log.warn("Blockchain resync: remote block not found. partner={} blockchainName={} blockNumber={}",
partnerLogin, blockchainName, blockNumber);
@@ -0,0 +1,73 @@
package server.sync;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.entities.DmDeliveryStateEntry;
import utils.config.AppConfig;
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;
/** Пятисекундный воркер повторной доставки DM получателю. */
public final class PeriodicDmDeliveryService {
private static final Logger log = LoggerFactory.getLogger(PeriodicDmDeliveryService.class);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
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 PeriodicDmDeliveryService() {}
public static void startOrLog() {
if (!isEnabled()) {
log.info("DM delivery worker disabled by dm.delivery.enabled=false");
return;
}
if (!STARTED.compareAndSet(false, true)) return;
long pollSeconds = configLong("dm.worker.pollSeconds", 5L, 1L, 60L);
SCHEDULER.scheduleWithFixedDelay(
PeriodicDmDeliveryService::tickSafe, 0L, pollSeconds, TimeUnit.SECONDS);
log.info("DM delivery worker scheduled every {} seconds", pollSeconds);
}
private static void tickSafe() {
try {
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("DM delivery dispatcher failed", e);
}
}
private static boolean isEnabled() {
String raw = AppConfig.getInstance().getParam("dm.delivery.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 { return Math.max(min, Math.min(max, Long.parseLong(raw.trim()))); }
catch (Exception ignored) { return defaultValue; }
}
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;
}
};
}
}
@@ -1,256 +0,0 @@
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) {}
}
@@ -83,3 +83,11 @@ WebSocket-эндпоинт для одного соединения.
- `WsServer` = сервер, который слушает порт и вешает `/ws`.
- `BlockchainWsEndpoint` = обработчик одного WebSocket-подключения, мост между сетью и логикой.
## Постоянные server-to-server соединения
Исходящие межсерверные JSON-запросы проходят через
`server.sync.ServerConnectionPool`. Пул держит одно постоянное исходящее WSS-
соединение на `serverLogin`, отправляет `ServerHello`, поддерживает канал через
WebSocket ping/pong и переподключает его с backoff. DM, settings и blockchain
используют это соединение с разными приоритетами, не меняя свою бизнес-логику.
@@ -8,10 +8,10 @@ import org.slf4j.LoggerFactory;
import server.debug.DebugApiConfigurator;
import server.sync.BlockchainResyncRecoveryOnStartup;
import server.sync.PeriodicBlockchainSyncService;
import server.sync.PeriodicDmSyncService;
import server.sync.PeriodicUserSettingsSyncService;
import server.sync.PeriodicDmDeliveryService;
import server.sync.SolanaUsersSyncStartupService;
import server.sync.SyncServersBootstrapService;
import server.sync.ServerConnectionPool;
import utils.config.AppConfig;
import java.time.Duration;
@@ -104,8 +104,11 @@ public final class WsServer {
server.start();
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
PeriodicDmSyncService.startOrLog();
PeriodicUserSettingsSyncService.startOrLog();
ServerConnectionPool.getInstance().startOrLog();
Runtime.getRuntime().addShutdownHook(new Thread(
() -> ServerConnectionPool.getInstance().close(),
"server-connection-pool-shutdown"));
PeriodicDmDeliveryService.startOrLog();
server.join();
}
}
@@ -34,13 +34,16 @@ sync.importUserProfileFromPartner.enabled=false
# ------------------------------------------------------------
server.version=${projectVersion}
# Доставка и межсерверная синхронизация личных сообщений.
dm.sync.enabled=true
# Доставка личных сообщений на единственный access-сервер получателя.
dm.delivery.enabled=true
dm.worker.pollSeconds=5
dm.worker.dueLimit=100
dm.sync.batchLimit=200
dm.sync.batchMaxBytes=3000000
dm.sync.maxPagesPerPeer=20
server.pool.pingIdleSeconds=120
server.pool.pongTimeoutSeconds=15
server.pool.requestTimeoutSeconds=12
server.pool.connectTimeoutSeconds=15
server.pool.callerTimeoutSeconds=35
server.pool.maxQueuePerPeer=2000
server.info.url=
server.info.physicalRegion=
server.info.description=
@@ -5,6 +5,7 @@ import blockchain.body.ConnectionBody;
import blockchain.body.CreateChannelBody;
import blockchain.body.HeaderBody;
import blockchain.body.TextBody;
import shine.db.DbController;
import test.it.blockchain.AddBlockSender;
import test.it.blockchain.ChainState;
import test.it.utils.TestConfig;
@@ -13,6 +14,10 @@ import test.it.utils.log.TestResult;
import test.it.utils.ws.WsSession;
import java.time.Duration;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.*;
@@ -117,6 +122,27 @@ public class IT_03_AddBlock_NoAuth {
st1.registerTextChannelRoot(newsRootBlock, newsRootHash);
}
// CREATE_CHANNEL "Updates" второй публичный канал того же владельца
int updatesRootBlock;
byte[] updatesRootHash;
{
var ln = st1.nextLineByType(ChainState.TYPE_TECH);
sender1.send(new CreateChannelBody(
0,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"Updates",
"",
CreateChannelBody.CHANNEL_TYPE_PUBLIC,
CreateChannelBody.CHANNEL_TYPE_VERSION_DEFAULT
), t);
updatesRootBlock = st1.lastBlockNumber();
updatesRootHash = st1.getHash32(updatesRootBlock);
assertNotNull(updatesRootHash);
st1.registerTextChannelRoot(updatesRootBlock, updatesRootHash);
}
// POST #0 в канал "News"
int newsPost0Block;
byte[] newsPost0Hash;
@@ -188,7 +214,21 @@ public class IT_03_AddBlock_NoAuth {
bch1, newsRootBlock, newsRootHash,
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
// 3) FRIEND взаимно (на HEADER)
// 3) U2 подписался на второй канал U1 "Updates"
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
bch1, updatesRootBlock, updatesRootHash,
"U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t);
assertEquals(2, countConnectionsByOwner(u2, u1),
"U2 должен иметь две отдельные записи подписки на каналы U1");
assertEquals(2, countFollowingChannels(u2),
"following_channels_count должен учитывать два разных канала одного владельца");
assertEquals(1, countSubscribers(bch1, newsRootBlock, newsRootHash),
"У канала News должен быть 1 подписчик");
assertEquals(1, countSubscribers(bch1, updatesRootBlock, updatesRootHash),
"У канала Updates должен быть 1 подписчик");
// 4) FRIEND взаимно (на HEADER)
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
bch2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: FRIEND", t);
@@ -197,7 +237,7 @@ public class IT_03_AddBlock_NoAuth {
bch1, u1HeaderBlock, u1HeaderHash,
"U2 -> U1: FRIEND", t);
// 4) CONTACT несколько
// 5) CONTACT несколько
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
bch2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: CONTACT", t);
@@ -236,7 +276,21 @@ public class IT_03_AddBlock_NoAuth {
bch3, u3HeaderBlock, u3HeaderHash,
"U1 -> U3: CONTACT", t);
// 5) U1 убирает U2 из контактов (UNCONTACT)
// 6) U2 отписывается только от News
sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW,
bch1, newsRootBlock, newsRootHash,
"U2 unfollows U1 channel 'News'", t);
assertEquals(1, countConnectionsByOwner(u2, u1),
"После отписки от одного канала должна остаться одна запись подписки");
assertEquals(1, countFollowingChannels(u2),
"following_channels_count должен уменьшиться ровно на 1");
assertEquals(0, countSubscribers(bch1, newsRootBlock, newsRootHash),
"После отписки от News у канала должен остаться 0 подписчиков");
assertEquals(1, countSubscribers(bch1, updatesRootBlock, updatesRootHash),
"Подписка на Updates должна остаться");
// 7) U1 убирает U2 из контактов (UNCONTACT)
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
bch2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: UNCONTACT", t);
@@ -288,4 +342,58 @@ public class IT_03_AddBlock_NoAuth {
toBlockHash32
), timeout);
}
private static int countConnectionsByOwner(String login, String ownerLogin) {
return queryInt("""
SELECT COUNT(*)
FROM connections_state
WHERE login = ?
AND rel_type = 30
AND to_login = ?
""", login, ownerLogin);
}
private static int countFollowingChannels(String login) {
return queryInt("""
SELECT following_channels_count
FROM user_stats_state
WHERE login = ?
""", login);
}
private static int countSubscribers(String ownerBlockchainName, int rootBlockNumber, byte[] rootBlockHash) {
return queryInt("""
SELECT COALESCE(subscribers_count, 0)
FROM channel_stats_state
WHERE owner_bch_name = ?
AND channel_root_block_number = ?
AND channel_root_block_hash = ?
""", ownerBlockchainName, rootBlockNumber, rootBlockHash);
}
private static int queryInt(String sql, Object... params) {
try (Connection c = DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
for (int i = 0; i < params.length; i++) {
Object p = params[i];
if (p instanceof String s) {
ps.setString(i + 1, s);
} else if (p instanceof Integer n) {
ps.setInt(i + 1, n);
} else if (p instanceof byte[] bytes) {
ps.setBytes(i + 1, bytes);
} else {
throw new IllegalArgumentException("Unsupported SQL param type: " + (p == null ? "null" : p.getClass()));
}
}
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
throw new IllegalStateException("Query returned no rows: " + sql);
}
return rs.getInt(1);
}
} catch (SQLException e) {
throw new RuntimeException("DB query failed: " + sql, e);
}
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.7.0
server.version=1.6.1
client.version=1.7.6
server.version=1.6.2
+8
View File
@@ -124,6 +124,14 @@ done < "$TMP/files.z"
sort -u "$LIST" -o "$LIST"
# Always include Gradle wrapper bootstrap, even though generic JARs are denied.
if [[ -f "$ROOT/SHiNE-server/gradle/wrapper/gradle-wrapper.jar" ]]; then
if ! grep -Fxq 'SHiNE-server/gradle/wrapper/gradle-wrapper.jar' "$LIST"; then
printf '%s\n' 'SHiNE-server/gradle/wrapper/gradle-wrapper.jar' >> "$LIST"
sort -u "$LIST" -o "$LIST"
fi
fi
# 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,}'
+2 -2
View File
@@ -1,3 +1,3 @@
backup.schema.version=2
backup.full.version=3
last.full.backup.date=2026-08-08
backup.full.version=4
last.full.backup.date=2026-08-25
+9 -1
View File
@@ -57,7 +57,11 @@
"serverLastGlobalNumber": 128,
"serverLastGlobalHash": "4f...ab",
"serverBlockchainSizeBytes": 45212,
"serverBlockchainSizeLimitBytes": 100000
"serverBlockchainSizeLimitBytes": 100000,
"ownedPublicChannelsCount": 3,
"followingUsersCount": 18,
"followingChannelsCount": 27,
"closeFriendsCount": 4
}
}
```
@@ -68,6 +72,10 @@
- `serverLastGlobalHash` — hash последнего блока (hex-строка 64 символа);
- `serverBlockchainSizeBytes` — текущий размер пользовательского блокчейна на сервере в байтах;
- `serverBlockchainSizeLimitBytes` — текущий лимит размера блокчейна на сервере в байтах;
- `ownedPublicChannelsCount` — количество публичных каналов, владельцем которых является пользователь;
- `followingUsersCount` — количество пользователей, на которых подписан пользователь;
- `followingChannelsCount` — количество публичных каналов, на которые подписан пользователь;
- `closeFriendsCount` — количество близких друзей пользователя.
### Успешный ответ: пользователя нет
+82
View File
@@ -4,6 +4,7 @@
Здесь четыре базовых метода обычной авторизации:
- `ResolveLoginForAuth`
- `AuthChallenge`
- `CreateAuthSession`
- `SessionChallenge`
@@ -67,6 +68,57 @@ ed25519/BASE64_PUBLIC_KEY
---
## 1.1. `ResolveLoginForAuth`
Первый шаг интерактивного входа. Операция не заменяет `GetUser` и не меняет его семантику.
Она предназначена только для выбора правильного access server перед вводом пароля
или запуском входа через доверенное устройство.
Запрос:
```json
{
"op": "ResolveLoginForAuth",
"requestId": "login-route-1",
"payload": {
"login": "alice"
}
}
```
Успешный ответ всегда имеет `status=200`, а `payload.resolution` принимает одно из значений:
- `LOCAL` — пользователь существует, и `access_servers[0]` указывает на этот сервер;
- `REMOTE` — пользователь существует, но его первый access server другой;
- `NOT_FOUND` — пользователя нет в локально синхронизированном Solana PDA snapshot;
- `NO_ACCESS_SERVER` — пользователь существует, но корректный первый access server не найден.
Для `LOCAL` и `REMOTE` сервер возвращает канонический `login`. Для `REMOTE`
также возвращаются `accessServerLogin` и `accessServerUrl`, чтобы UI сразу мог
перенаправить пользователя на правильную точку входа.
Пример `REMOTE`:
```json
{
"op": "ResolveLoginForAuth",
"requestId": "login-route-1",
"status": 200,
"ok": true,
"payload": {
"resolution": "REMOTE",
"login": "Alice",
"accessServerLogin": "shine-node-2",
"accessServerUrl": "wss://node2.example.org/ws"
}
}
```
Важно: это UX-проверка, а не security boundary. `AuthChallenge/CreateAuthSession`
повторно определяют отношение пользователя к текущему серверу. Пользователь другого
access server может криптографически авторизоваться напрямую, но получает
`connectionScope=REMOTE`.
## 2. `AuthChallenge`
### Запрос
@@ -339,3 +391,33 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
}
}
```
### LOCAL/REMOTE scope успешной авторизации
`CreateAuthSession` и `SessionLogin` дополнительно возвращают:
```json
{
"connectionScope": "LOCAL"
}
```
или:
```json
{
"connectionScope": "REMOTE"
}
```
`REMOTE` означает, что пользователь успешно доказал владение ключом/сессией, но
текущий сервер не является его `access_servers[0]`. Такой WebSocket регистрируется
как активное соединение и может использоваться для realtime звонков, однако
центральная политика сервера разрешает ему только технические и call-signaling
операции. Обычные homeserver-операции отвечают `403 / USER_NOT_LOCAL`.
Разрешённый минимальный набор для `REMOTE`: `Ping`, `GetServerInfo`,
`GetCallIceConfig`, `CallInviteBroadcast`, `CallSignalToSession`, `SendSignal`,
`CallDeliveryReport`, `ClientErrorLog`, `ClientDebugLog`, `CloseActiveSession`
и `ResolveLoginForAuth`.
+6 -1
View File
@@ -313,9 +313,14 @@ TTL заявки фиксирован на сервере и сейчас все
- `400 / BAD_PAYLOAD_TYPE`
- `422 / PAIRING_NOT_AVAILABLE`
- `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля.
- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся.
Код создаётся даже если сейчас нет ни одной онлайн доверённой сессии пользователя.
В таком случае `trustedSessionOnline=false`, а pairing-заявка остаётся в состоянии
`created` до подтверждения, отмены или истечения TTL.
- `429 / PAIRING_RATE_LIMITED`
Также `StartTrustedDeviceLogin` разрешён только на первом access server пользователя.
На другом сервере операция возвращает `409 / USER_NOT_LOCAL`.
### 5.4. `ListTrustedDeviceLoginRequests`
Доступно для любой уже авторизованной доверенной сессии пользователя.
+1
View File
@@ -158,6 +158,7 @@
"avaSha256": "0123...",
"avaSize": 248193,
"metaUpdatedAtMs": 1760000000000,
"subscribersCount": 128,
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
},
"metaEvents": [
+7 -7
View File
@@ -16,6 +16,7 @@
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
| `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара |
| `TestUploadFreeAvatar` | `14_Test_Free_Avatar_Upload_API.md` | временная тестовая бесплатная загрузка маленького аватара в Arweave |
| `ResolveLoginForAuth` | `02_Authentication_API.md` | проверка login перед входом: LOCAL / REMOTE / NOT_FOUND / NO_ACCESS_SERVER + URL правильного access server |
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
@@ -32,6 +33,7 @@
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
| `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна |
| `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки |
| `Ping` | `05_Technical_Requests_API.md` | keep-alive |
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
| `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов |
@@ -62,14 +64,9 @@
| `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` | старая полная DM-пара для второго access-сервера отправителя |
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | внутренняя доставка входящего DM-блока на единственный сервер получателя |
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки |
| `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 приглашения к звонку |
@@ -77,8 +74,11 @@
## Важные замечания
- `ReceiveOutcomingMessage` зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`, и сохраняет прежний межсерверный payload.
- UI отправляет полную подписанную пару только через `SendMessagePair`.
- `ReceiveIncomingMessage` вызывается сервером отправителя для доставки одной
входящей копии на единственный access-сервер получателя.
- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее.
- `ServerHello` пока принимает заявленный `serverLogin` на доверии и не является криптографической аутентификацией.
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
+20 -93
View File
@@ -9,8 +9,10 @@
Важно:
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
- UI использует `SendMessagePair`, `DeleteMessage`, `DeleteConversation`
и `GetDirectMessages`;
- `ReceiveIncomingMessage` — внутренняя доставка одной входящей копии на
единственный access-сервер получателя.
- сервер поддерживает материализованный слой диалогов `dm_dialog_state`; `read receipt` обновляет серверный watermark и `unreadCount`, а не только локальный клиентский флаг.
- в `dm_dialog_state` сервер также хранит `last_message_blob_b64` для последнего контентного DM в base64, чтобы клиент мог отрисовать список чатов без дополнительного запроса.
@@ -69,9 +71,7 @@
}
```
## 3. `SendMessagePair` и `ReceiveOutcomingMessage`
`ReceiveOutcomingMessage` — алиас `SendMessagePair`.
## 3. `SendMessagePair`
### Назначение
@@ -114,7 +114,10 @@
}
```
Успешный `status=200` подтверждает локальное сохранение. До ответа клиенту сервер параллельно пробует оба актуальных сервера получателя. Поэтому `deliveryState` уже может быть `delivered`; если никто не ответил, возвращается `accepted`.
Успешный `status=200` подтверждает локальное сохранение. До ответа клиенту
сервер пробует единственный актуальный сервер получателя. Поэтому
`deliveryState` уже может быть `delivered`; если сервер получателя не
ответил, возвращается `accepted`.
Возможные `deliveryState`: `accepted`, `delivered`, `failed`.
@@ -132,7 +135,8 @@
### Назначение
Используется там, где нужно принять только incoming-вариант сообщения.
Используется сервером отправителя для доставки incoming-варианта сообщения на
единственный access-сервер получателя.
Принимаемые типы:
@@ -152,7 +156,9 @@
}
```
`sourceServerLogin` необязателен для совместимости. Пока межсерверная авторизация отложена, это поле считается доверенным. Пользовательская подпись signed-блока проверяется всегда.
`sourceServerLogin` пока передаётся для диагностики маршрута. Межсерверная
авторизация остаётся отдельным будущим этапом, но пользовательская подпись
signed-блока проверяется всегда. Официальный UI эту операцию не вызывает.
Успешный ответ содержит существующие `messageKey`, `baseKey` и счётчики realtime-доставки. Повтор уже сохранённой той же ревизии обрабатывается идемпотентно.
@@ -262,31 +268,11 @@
Поле `deliveryState` заполняется для исходящих элементов `type=2`. У входящих `type=1` оно отсутствует/равно `null`.
## 8. Межсерверные операции DM
## 8. Внутренняя межсерверная доставка DM
До отдельного этапа server-auth поля `sourceServerLogin` доверяются. Каждый `SHiNE_DM` всё равно заново проходит проверку формата и пользовательской подписи.
### 8.1. `ReceiveIncomingMessage`
### 8.1. `ReceiveOutcomingMessage`
Прежняя операция передачи полной пары второму access-серверу отправителя. Delivery-state в межсерверный запрос не входит.
```json
{
"op": "ReceiveOutcomingMessage",
"requestId": "dm-peer-001",
"payload": {
"incomingBlobB64": "BASE64_INCOMING",
"outgoingBlobB64": "BASE64_OUTGOING",
"sourceServerLogin": "server-a"
}
}
```
Ответ имеет прежний формат `SendMessagePair`. Принимающий сервер сохраняет пару идемпотентно и самостоятельно ставит локальную задачу доставки.
### 8.2. `ReceiveIncomingMessage`
Прежняя операция передачи одной входящей копии серверу получателя или второму серверу самого получателя.
Передаёт одну входящую копию на единственный access-сервер получателя.
```json
{
@@ -301,68 +287,9 @@
Успешный 2xx-ответ означает, что signed-блок проверен и находится в БД (новая или идемпотентно повторённая запись).
### 8.3. `DmSyncBatch`
Pull-синхронизация событий владельца с `synced=false`. `ackSyncIds` подтверждает версии событий, успешно сохранённые из предыдущего ответа.
```json
{
"op": "DmSyncBatch",
"requestId": "dm-sync-001",
"payload": {
"ownerLogin": "alice",
"afterStoredAtMs": 0,
"afterMessageKey": "",
"limit": 500,
"maxBytes": 3000000,
"ackSyncIds": []
}
}
```
Ответ возвращает страницу несинхронизированных событий и курсор внутри текущего цикла:
```json
{
"nextStoredAtMs": 1774700001123,
"nextMessageKey": "alice|bob|1774700000123|123456789|2",
"hasMore": false,
"items": [
{
"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"
}
}
```
Ответ содержит `messageKey`, `known` и `delivered`. `delivered=true` означает доставку хотя бы одному серверу получателя.
Периодический процесс использует одно WS-соединение с peer: сначала синхронизирует настройки, затем вызывает `DmSyncBatch` до завершения страниц и ACK. Существующий `MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги и возвращает `dmUpdated`; отдельной операции `MarkAllDmUnsynced` нет.
Основные ошибки межсерверных операций:
- `400 / EMPTY_OWNER_LOGIN`, `EMPTY_MESSAGE_KEY`;
- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER`;
- `500 / LOCAL_SERVER_NOT_CONFIGURED`.
Операции `ReceiveOutcomingMessage`, `DmSyncBatch` и
`GetDmDeliveryStatus` удалены вместе с репликацией между access-серверами
одного пользователя.
## 9. `AckSessionDelivery`
+36 -80
View File
@@ -1,101 +1,57 @@
# API пользовательских настроек
# User Settings API
Этот раздел описывает `user_settings` - отдельное хранилище пользовательских настроек, не связанное с `users_params` и не связанное с legacy DM-таблицами.
user_settings хранит технические настройки пользователя только на его
единственном действующем access-сервере. Межсерверной синхронизации настроек
нет. При смене access-сервера старые настройки автоматически не переносятся.
## 1. Назначение
## Подпись
`user_settings` хранит технические пользовательские настройки, которые должны синхронизироваться между максимум двумя access/sync-серверами пользователя.
UI формирует строку:
Основной текущий кейс:
SHiNe/UserSettings:<login>|<setting_type>|<setting_key>|<time_ms>|<value_text>|<value_num>
- `setting_type = 1` - курсор прочитанности канала;
- `setting_key = ownerBlockchainName/channelName`;
- `value_num = number of messages already seen in channel`;
- `value_text = ''`.
и подписывает её текущим Ed25519 clientKey пользователя. Сервер:
Если настройки нет, канал считается полностью прочитанным, то есть unread = `0`.
После первого открытия канала UI отправляет текущий курсор, чтобы зафиксировать baseline и дальше считать только новые сообщения.
1. находит пользователя;
2. проверяет совпадение client_key с актуальным ключом пользователя;
3. строго проверяет подпись;
4. при неверной подписи возвращает INVALID_SIGNATURE;
5. сохраняет запись только если time_ms новее локальной версии.
## 2. Структура записи
## Операции
- `login` - логин владельца настройки;
- `setting_type` - числовой код типа настройки;
- `setting_key` - строковый ключ настройки;
- `time_ms` - время установки значения в миллисекундах;
- `value_text` - строковое значение;
- `value_num` - числовое значение;
- `client_key` - публичный Ed25519 ключ клиента в Base64;
- `signature` - Ed25519 подпись preimage в Base64;
- `synced` - была ли настройка успешно доставлена на второй сервер.
### UpsertUserSetting
Уникальность: `(login, setting_type, setting_key)`.
Обновление: только если `time_ms` новее текущего значения.
Обязательные поля:
## 3. Формат подписи
- login;
- setting_type;
- setting_key;
- time_ms;
- client_key;
- signature.
Подписывается строка:
Значения:
`SHiNe/UserSettings:|login|setting_type|setting_key|time_ms|value_text|value_num`
- value_text — строковое значение, по умолчанию пустая строка;
- value_num — числовое значение, по умолчанию 0.
Где внутри полей используется экранирование `\` и `|`.
Поля sync_delivery и synced удалены.
Подпись создаётся клиентским `client_key`.
### GetUserSetting
## 4. Операции
Возвращает одну локальную настройку по login, setting_type и setting_key.
### `UpsertUserSetting`
### ListUserSettings
Записывает или обновляет настройку пользователя.
Возвращает все локальные настройки пользователя.
Если запрос пришёл от клиента, сервер:
## Удалённые операции
- сохраняет запись локально;
- пытается сразу отправить её на доступный sync-сервер;
- если отправка успешна, помечает запись как `synced=true`;
- если нет, оставляет `synced=false`.
После перехода на один access-сервер удалены:
Если запрос пришёл по синхронизации между серверами, используется `sync_delivery=true`, и повторной пересылки дальше не делается.
- UserSettingsSyncBatch;
- MarkAllUserSettingsUnsynced.
### `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 в списке каналов и в канале.
Также удалены таблица peer-cursor и периодический сервис синхронизации
настроек.
+106
View File
@@ -0,0 +1,106 @@
# Межсерверное соединение и `ServerHello`
Документ описывает транспортный слой постоянных WSS-соединений между серверами SHiNE.
Он не меняет форматы DM, блоков, настроек, ACK или расписания повторных попыток.
## 1. `ServerHello`
После установления исходящего WSS-соединения сервер первым запросом отправляет:
```json
{
"op": "ServerHello",
"requestId": "server-hello-001",
"payload": {
"serverLogin": "shineupme",
"protocolVersion": 1,
"capabilities": [
"dm-sync",
"settings-sync",
"block-sync",
"connection-pool"
]
}
}
```
Успешный ответ:
```json
{
"op": "ServerHello",
"requestId": "server-hello-001",
"status": 200,
"payload": {
"accepted": true,
"serverLogin": "server2",
"protocolVersion": 1,
"capabilities": [
"dm-sync",
"settings-sync",
"block-sync",
"connection-pool"
]
}
}
```
На текущем этапе `serverLogin` принимается на доверии. Подпись, challenge и
проверка корневого ключа сервера намеренно отложены. Поэтому `ServerHello`
фиксирует тип соединения и возможности peer, но пока не является
криптографической аутентификацией.
## 2. Пул соединений
- физическое соединение создаётся одно на `serverLogin`;
- логические операции DM, settings и blockchain используют один WSS;
- завершение `RemoteSyncSession` не закрывает физический сокет;
- известные peer берутся из `sync_servers` и первых действующих маршрутов
`user_access_servers_current`;
- список перечитывается каждые 30 секунд;
- при изменении URL соединение пересоздаётся;
- новые запросы не повторяются транспортом автоматически: действующие
domain-воркеры сохраняют прежние правила retry и идемпотентности.
## 3. Приоритеты
| Приоритет | Операции |
| --- | --- |
| `REALTIME` | доставка DM и signed deletes |
| `NORMAL` | прочие межсерверные запросы |
| `BULK` | blockchain heads, blocks, `AddBlock` backfill |
На одном peer одновременно исполняется один запрос. Приоритет применяется к
ожидающей очереди и не прерывает уже начатую операцию.
## 4. Ping, reconnect и таймауты
Значения по умолчанию:
| Параметр | Значение |
| --- | ---: |
| `server.pool.pingIdleSeconds` | `120` |
| `server.pool.pongTimeoutSeconds` | `15` |
| `server.pool.requestTimeoutSeconds` | `12` |
| `server.pool.connectTimeoutSeconds` | `15` |
| `server.pool.callerTimeoutSeconds` | `35` |
| `server.pool.maxQueuePerPeer` | `2000` |
Ping отправляется WebSocket control-frame только после периода отсутствия
полезного трафика. При потере соединения применяется reconnect с jitter и
ступенями до 60 секунд.
## 5. Диагностика
Внутренний snapshot пула хранит для каждого peer:
- состояние соединения;
- URL;
- времена connect/activity/ping/pong;
- число reconnect;
- размеры очередей по приоритетам;
- успешные, ошибочные и просроченные запросы;
- последнюю ошибку.
Агрегированное состояние периодически записывается в серверный лог. Отдельная
публичная операция метрик на этом этапе не добавляется.
+13
View File
@@ -1,5 +1,18 @@
# История изменений документации блокчейна
## 2026-08-25 19:45:18 +0400
- Базовый коммит-ориентир: `3a58519`.
- Добавлены runtime-агрегаты статистики:
- `user_stats_state` хранит `owned_public_channels_count`, `following_users_count`, `following_channels_count`, `close_friends_count`;
- `channel_stats_state` хранит `subscribers_count` для публичных каналов.
- Обновление статистики выполняется атомарно вместе с записью блока в той же SQL-транзакции `AddBlock`.
- После полного resync статистика пересобирается из текущих `connections_state` и `channel_names_state`, чтобы старые данные продолжали работать.
## 2026-08-25 19:59:00 +0400
- Базовый коммит-ориентир: `working tree`.
- Триггер `channel_names_state` для статистики сделан устойчивым к старым данным, где канал уже есть, а владельца ещё нет в `solana_user_pda_current`.
- Добавлена совместимая миграция `v15`, чтобы существующие базы перестали падать на старых каналах при старте сервера.
## 2026-08-12 13:00:00 +0400
- Базовый коммит-ориентир: `working tree`.
- Канонический текстовый формат служебных тегов сокращён:
+3 -1
View File
@@ -33,7 +33,7 @@
## Смежная документация
- [../ИТХ/README.md](../ИТХ/README.md) — ежедневное закрытие блокчейна (ИТХ): краткий обзор.
- [../ИТХ/Спецификация_ИТХ_v1.md](../ИТХ/Спецификация_ИТХ_v1.md) — точная спецификация чекпоинтов (Arweave/Solana/канал закрытий).
- [sync-between-servers.md](./sync-between-servers.md) — живая межсерверная синхронизация блоков и DM.
- [sync-between-servers.md](./sync-between-servers.md) — живая межсерверная синхронизация блокчейна и доставка DM на единственный сервер получателя.
## Важные ограничения MVP
- Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI.
@@ -42,4 +42,6 @@
## Обязательное сопровождение
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
- Обычный `AddBlock` сейчас пишет через `<blockchainName>.tmp_bch`, `<blockchainName>.write_check` и `<blockchainName>.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery.
- Для runtime-агрегатов статистики `user_stats_state` и `channel_stats_state` действует тот же принцип derived state: они обновляются вместе с `AddBlock` и полностью пересобираются при full resync.
- Если в старых данных есть канал владельца, которого ещё нет в `solana_user_pda_current`, сервер не падает: `channel_stats_state` всё равно обновляется, а `user_stats_state` создаётся только после появления пользователя в Solana PDA.
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
+30 -27
View File
@@ -1,4 +1,4 @@
# Синхронизация блоков и DM между серверами SHiNE
# Синхронизация блокчейнов и доставка DM между серверами SHiNE
Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE.
@@ -19,9 +19,9 @@
Каждый сервер регистрирует в своей Solana PDA список `sync_servers`
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
Важно: в текущей архитектуре у пользователя одновременно может быть не более
двух sync/access-серверов. Это ограничение считается обязательным для runtime-логики
`synced` и пользовательских курсоров.
`sync_servers` относится к серверному узлу и не является списком
access-серверов обычного пользователя. У пользователя действует только первый
`access_servers[0]`.
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
@@ -35,7 +35,7 @@
- Сервер-отправитель: сохраняет пару и ставит асинхронную delivery-задачу.
- Сервер-получатель: сохраняет входящий блок в `signed_messages`, затем доставляет его активным сессиям.
- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`.
- Репликация между двумя access-серверами пользователя работает через `dm_sync_outbox.synced`, без постоянного time-cursor.
- Между access-серверами одного пользователя DM не реплицируются.
- Полная актуальная схема: `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`.
### 3.2 Блоки пользовательского блокчейна
@@ -47,10 +47,8 @@
### 3.3 Пользовательские настройки
- Отдельная таблица `user_settings`.
- Синхронизируются технические настройки пользователя, включая курсор прочитанности каналов.
- Для текущего UI-кейса хранится `setting_type = 1` и `setting_key = ownerBlockchainName/channelName`.
- Синхронизация идёт с учётом `time_ms` и флага `synced`.
Пользовательские настройки хранятся локально на единственном access-сервере и
между серверами не синхронизируются.
## 4. Текущая реализованная схема
@@ -183,16 +181,20 @@ Full resync запускается только тогда, когда:
Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync.
## 5. Возможное развитие server-to-server транспорта
## 5. Реализованный постоянный server-to-server транспорт
Этот раздел не описывает текущую DM-доставку. DM уже использует короткие one-shot WebSocket-вызовы, indexed outbox и ACK. Ниже остаётся возможное развитие постоянного транспорта и server-auth.
Этот раздел не меняет текущую семантику DM, settings и blockchain. Он описывает
единый постоянный WSS-транспорт, через который выполняются уже существующие операции.
### 5.1 Межсерверное соединение
- Серверы устанавливают постоянное WebSocket-соединение друг с другом.
- Серверы устанавливают постоянное исходящее WebSocket-соединение друг с другом.
- Адрес партнёра определяется по `server_address` из его Solana PDA.
- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA).
- При разрыве — переподключение с экспоненциальным backoff.
- После подключения отправляется `ServerHello` с `serverLogin`, версией протокола и capabilities.
- На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена.
- При разрыве выполняется переподключение с jitter/backoff до 60 секунд.
- После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд.
- Один физический канал переиспользуют доставка DM и blockchain.
### 5.2 Доставка новых данных (push)
@@ -204,7 +206,7 @@ Full resync запускается только тогда, когда:
- При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов.
- Сервер с более полной историей досылает недостающее партнёру.
- DM time-cursor удалён: новый peer получает историю после сброса `dm_sync_outbox.synced=false`.
- DM-history backfill между access-серверами отсутствует.
### 5.4 Разрешение конфликтов
@@ -218,11 +220,11 @@ Full resync запускается только тогда, когда:
1. Клиент A отправляет пару блоков на свой сервер X.
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`.
3. До ответа клиенту X отправляет входящую копию на единственный
`access_servers[0]` пользователя B через `ReceiveIncomingMessage`.
4. Успешное сохранение на сервере B означает терминальный `delivered`.
5. При неудаче выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час.
6. После неудачной попытки через час ставится терминальный `failed`.
Изменения routing B учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке.
@@ -246,19 +248,20 @@ Full resync запускается только тогда, когда:
| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано |
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
| Межсерверный постоянный WebSocket-канал | Нужна реализация |
| Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано |
| Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` |
| Асинхронная доставка DM на единственный access-сервер получателя | ✅ Реализовано |
| Retry DM до 1 часа + UI-state | ✅ Реализовано |
| Репликация DM на второй access-сервер по `synced` | ✅ Реализовано |
| Read-only `GetDmDeliveryStatus` | ✅ Реализовано |
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
| Репликация DM и настроек между access-серверами | Не используется |
| Push блоков блокчейна партнёрам | ✅ Выполняется через постоянный WSS-пул |
| Periodic backfill отсутствующего хвоста | ✅ Реализовано |
| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync |
| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано |
| Маршрутизация DM через один/два `access_servers` | ✅ Реализовано |
| Маршрутизация DM только через `access_servers[0]` | ✅ Реализовано |
| Криптографическая server-to-server авторизация DM | Нужна реализация |
Текущая версия сервера умеет синхронизацию блокчейнов и DM. Постоянные server-to-server соединения не требуются для текущей one-shot WS-реализации; отдельной будущей задачей остаётся криптографическая авторизация DM-вызовов.
Текущая версия сервера использует постоянный WSS-пул для существующих
server-to-server JSON-операций. Отдельной будущей задачей остаётся
криптографическая авторизация server-to-server вызовов.
Следующие отдельные шаги после текущего этапа:
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.
+2 -1
View File
@@ -6,7 +6,8 @@
- `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.md`доставка на
единственный access-сервер, retry-воркер и UI-статусы
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<S:...>` вставок внутри plaintext DM после расшифровки
Исторический устаревший документ сохранён отдельно:
@@ -1,136 +1,84 @@
# Доставка и синхронизация личных сообщений
# Доставка личных сообщений
## 1. Главный принцип
## 1. Инвариант
DM считается доставленным, когда signed-входящую копию сохранил хотя бы один актуальный access-сервер получателя. Доставка на оба сервера не требуется: сервер получателя самостоятельно синхронизирует входящее сообщение со своим вторым сервером.
У каждого пользователя действует ровно один сервер доступа: первый элемент
access_servers[0] в его Solana PDA.
Клиентский `status=200` от `SendMessagePair` означает, что собственный сервер сохранил пару. Это отдельный факт от доставки получателю.
Остальные элементы старой PDA, если они существуют, не участвуют в
маршрутизации и не являются fallback. UI позволяет только заменить текущий
сервер другим.
Формат подписанного контейнера `SHiNE_DM` не меняется. Состояние доставки и флаг синхронизации — изменяемые серверные метаданные.
Межсерверной репликации личной переписки между серверами одного пользователя
нет. При смене сервера история автоматически не переносится.
## 2. Идентификаторы
## 2. Подписанные копии
- `baseKey` связывает входящую и исходящую копии одного логического сообщения;
- `incomingKey` идентифицирует копию получателя;
- `outgoingKey`/`messageKey` идентифицирует копию отправителя и используется для проверки доставки;
- дополнительный публичный `eventId` для доставки не создаётся;
- `syncId` используется только внутри `DmSyncBatch` как ACK конкретной ревизии, потому что редактирование сохраняет прежний `messageKey`.
Формат SHiNE_DM не меняется. Для сообщения клиент создаёт:
## 3. Состояния доставки
- входящую копию для получателя;
- исходящую копию для отправителя.
| Состояние | Смысл | Повторные попытки |
|---|---|---|
| `accepted` | пара сохранена сервером отправителя, но ни один сервер получателя ещё не подтвердил запись | да |
| `delivered` | хотя бы один сервер получателя подтвердил запись | нет |
| `failed` | последняя попытка через час завершилась без доставки | никогда |
Сервер отправителя принимает пару через SendMessagePair, атомарно сохраняет её
и создаёт изменяемое состояние доставки.
Состояние `delivered` терминальное. Сервер отправителя не пытается отдельно добиться второго ACK получателя.
## 3. Основной маршрут
## 4. Обычная отправка
1. UI отправителя вызывает SendMessagePair на своём access-сервере.
2. Сервер сохраняет исходящую копию отправителя и входящую копию, необходимую
для доставки и повторных попыток.
3. Сервер читает единственный маршрут получателя из
user_access_servers_current.
4. Если это другой физический сервер, он вызывает внутреннюю операцию
ReceiveIncomingMessage и передаёт только входящую копию.
5. Сервер получателя проверяет пользовательскую подпись, идемпотентно сохраняет
входящую копию и уведомляет активные сессии.
6. Успешное сохранение означает delivered.
1. Клиент отправляет `SendMessagePair` на один свой access-сервер.
2. Сервер проверяет формат, пользователей, подписи и согласованность пары.
3. Сервер атомарно сохраняет входящую и исходящую копии.
4. В том же request-процессе сервер читает до двух актуальных маршрутов получателя.
5. Оба вызова `ReceiveIncomingMessage` запускаются параллельно.
6. Если хотя бы один вызов успешен, устанавливается `delivered`.
7. После этой попытки сервер передаёт полную пару своему второму access-серверу старой операцией `ReceiveOutcomingMessage`.
8. `SendMessagePair` возвращает существующие ключи и единственное новое поле `deliveryState`.
ReceiveIncomingMessage не является синхронизацией двух серверов одного
пользователя. Это основная доставка между серверами разных пользователей.
Успешный повтор уже сохранённого signed-блока считается ACK. Все операции должны быть идемпотентными.
## 4. Повторные попытки
## 5. Расписание повторов
Попытки привязаны ко времени первоначального принятия:
Воркер запускается каждые 5 секунд и выбирает только due-строки по индексу. Он не сканирует всю таблицу сообщений.
- сразу;
- через 30 секунд;
- через 5 минут;
- через 25 минут;
- через 1 час.
Попытки привязаны к времени первоначального принятия:
Перед каждой попыткой заново читается первый актуальный access-сервер
получателя. После финальной неудачи состояние становится failed.
| Номер | Время от старта | Сначала спросить второй сервер отправителя |
|---:|---:|---|
| 1 | сразу | нет |
| 2 | 30 секунд | нет |
| 3 | 5 минут | да |
| 4 | 25 минут | да |
| 5 | 1 час | да |
Второй сервер отправителя не опрашивается, полная пара ему не передаётся.
Из-за шага воркера повтор может начаться на несколько секунд позже указанного времени. Первая попытка выполняется немедленно и от воркера не зависит.
## 5. Состояния
На 5-й, 25-й и 60-й минутах сервер сначала вызывает у второго сервера отправителя:
- accepted — пара сохранена сервером отправителя;
- delivered — входящая копия сохранена единственным сервером получателя;
- failed — финальная попытка завершилась неудачей;
- read-receipt — отдельное подписанное DM-событие.
```json
{
"op": "GetDmDeliveryStatus",
"payload": {
"messageKey": "alice|bob|1774700000123|123456789|2"
}
}
```
Подписанные DM-контейнеры остаются криптографическими фактами. Сетевое
deliveryState остаётся изменяемой серверной метаданной.
Ответ:
## 6. Удаления
```json
{
"messageKey": "alice|bob|1774700000123|123456789|2",
"known": true,
"delivered": true
}
```
DeleteMessage и DeleteConversation доставляют подписанный tombstone только на
единственные access-серверы обеих сторон. Повторное применение идемпотентно.
Операция read-only. Если peer отвечает `delivered=true`, локальный сервер устанавливает `delivered` и не обращается к серверам получателя. Ошибка или отсутствие операции на старом peer не блокирует собственную попытку.
## 7. Удалённая репликация
Перед каждой попыткой маршруты получателя заново читаются из `user_access_servers_current`. Изменение серверов учитывается только пока сообщение находится в `accepted`. После `delivered` или `failed` состояние больше не открывается.
После перехода на один сервер доступа удалены:
Если последняя проверка и попытка через час не дали ACK, устанавливается `failed`, `next_attempt_at_ms` очищается и сообщение больше никогда автоматически не отправляется.
- ReceiveOutcomingMessage;
- DmSyncBatch;
- GetDmDeliveryStatus;
- dm_sync_outbox;
- dm_sync_peer_state;
- ACK-флаги synced;
- периодический DM-backfill между access-серверами.
## 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 и не изменяет данные.
Блокчейн-синхронизация через server-PDA sync_servers остаётся отдельным
механизмом и продолжает работать.
+143 -619
View File
@@ -1,681 +1,205 @@
# Личные сообщения (DM) — спецификация v1
# Протокол личных сообщений SHiNE DM v1
## Статус документа
## 1. Статус документа
Этот файл — актуальная логическая спецификация DM-протокола SHiNE v1.
Это актуальная спецификация логики личных сообщений. Точный байтовый формат
контейнера описан в Формат_DM_v1.md, а расписание доставки — в
Доставка_и_синхронизация_DM.md.
Важно:
Формат SHiNE_DM не изменён переходом на один access-сервер.
- это актуальная реализованная спецификация;
- код и документы по DM должны изменяться синхронно;
- при любых будущих изменениях DM сначала обновляется эта спецификация и соседний документ формата.
## 2. Инварианты
Документ фиксирует:
- каждый DM-контейнер подписан clientKey автора;
- сервер проверяет формат, пользователей и подпись до сохранения;
- повторная доставка одной ревизии идемпотентна;
- более старая ревизия не заменяет новую;
- у каждого пользователя действует только access_servers[0];
- дополнительные элементы старой PDA игнорируются без fallback;
- DM и настройки не реплицируются между access-серверами одного пользователя;
- sync_servers серверного PDA используются только для синхронизации
пользовательских блокчейнов.
- модель DM и смысл типов сообщений;
- правила редактирования, перешифровки и удаления;
- роли существующих API-методов;
- правила межсерверной маршрутизации через `access_servers`;
- общее поведение сервера и БД.
## 3. Типы DM
Точный байтовый формат контейнера вынесен отдельно:
- type=1 — входящая копия сообщения для получателя;
- type=2 — исходящая копия сообщения для отправителя;
- type=3 — входящий read-receipt;
- type=4 — исходящая копия read-receipt;
- type=5 — удаление своего исходящего сообщения;
- type=6 — удаление входящего сообщения получателем;
- type=7 — удаление переписки отправителем;
- type=8 — удаление переписки получателем.
- `docs/Personal_Messages/Формат_DM_v1.md`
Входящая и исходящая копии являются частями криптографического формата, а не
серверной репликацией. Они остаются даже при единственном access-сервере.
Формат клиентских технических вставок внутри plaintext вынесен отдельно:
## 4. Создание сообщения
- `docs/Personal_Messages/Технические_вставки_DM_v1.md`
Клиент:
Изменяемое состояние доставки, retry-расписание и репликация между двумя access-серверами подробно описаны отдельно:
1. получает актуальные clientKey отправителя и получателя;
2. шифрует входящую копию на ключ получателя;
3. шифрует исходящую копию на ключ отправителя;
4. создаёт два связанных контейнера с общим baseKey;
5. подписывает оба контейнера clientKey отправителя;
6. вызывает SendMessagePair на своём access-сервере.
- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`
Сервер отправителя атомарно сохраняет пару. Исходящая копия доступна отправителю,
а входящая хранится для доставки и повторных попыток.
Устаревшая предыдущая версия сохранена отдельно:
## 5. Единственный сервер доступа
- `docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md`
Пользовательская PDA по-прежнему содержит массив access_servers для
совместимости формата. Действующим считается только первый элемент массива.
## 1. Основная модель
Routing-проекция user_access_servers_current содержит не более одной строки на
пользователя и строится строго из элемента с ord=1.
Личное сообщение в SHiNE хранится как две логические копии одного сообщения:
Если первый элемент отсутствует, указывает не на server-PDA или server_address
пуст, маршрут считается отсутствующим. Второй элемент автоматически не
используется.
- `type=1` — входящее сообщение для получателя;
- `type=2` — исходящая копия сообщения для отправителя.
UI позволяет:
Обе копии имеют общий логический идентификатор:
- увидеть текущий сервер;
- заменить его другим сервером;
- записать только один логин в access_servers.
- `baseKey = fromLogin|toLogin|timeMs|nonce`
Смена сервера не переносит сообщения и настройки. Новый сервер начинает с
пустой локальной истории.
Идентификатор конкретной копии:
## 6. Доставка сообщения
- `messageKey = baseKey|messageType`
### 6.1. SendMessagePair
Поля `timeMs` и `nonce` не меняются никогда.
Официальный UI отправляет пару только через SendMessagePair.
`nonce` обязателен, потому что одного `timeMs` недостаточно для гарантированной уникальности.
Сервер:
## 2. Ключи и шифрование
1. проверяет оба контейнера и их связь;
2. сохраняет пару;
3. создаёт delivery-state;
4. до ответа UI выполняет первую попытку доставки;
5. возвращает baseKey, incomingKey, outgoingKey и deliveryState.
### 2.1. Общий принцип
ReceiveOutcomingMessage удалён.
У пользователя в PDA публикуется один публичный `clientKey` в формате `Ed25519`.
### 6.2. ReceiveIncomingMessage
Из того же ключевого материала стандартным способом выводится ключ `X25519` для E2EE-шифрования DM.
Эта операция остаётся внутренней server-to-server операцией. Она нужна, когда
отправитель и получатель используют разные физические серверы.
Отдельный публичный `dmEncKey` в текущей архитектуре не хранится.
Сервер отправителя передаёт только incomingBlobB64 на единственный сервер
получателя. Принимающий сервер:
Подробности стандартного преобразования:
- разрешает получателя через свою routing-проекцию;
- заново проверяет пользовательскую подпись;
- применяет входящую копию идемпотентно;
- обновляет диалог и realtime-сессии;
- возвращает успешный ответ только после сохранения.
- `docs/Протоколы/Преобразование_ED25519_в_X25519.md`
ReceiveIncomingMessage не реплицирует историю на второй сервер пользователя.
### 2.2. Правило шифрования копий
## 7. Повторная доставка
- `type=1` шифруется на ключ получателя;
- `type=2` шифруется на ключ отправителя.
Состояния:
Следствия:
- accepted — пара сохранена сервером отправителя;
- delivered — incoming-копия сохранена сервером получателя;
- failed — закончилась финальная попытка;
- read — подтверждается отдельным type=3/4.
- входящую копию может прочитать только получатель;
- исходящую копию может прочитать только отправитель;
- сервер не должен расшифровывать DM;
- ciphertext у `type=1` и `type=2` обычно разный и не обязан совпадать побайтно.
Попытки выполняются сразу, через 30 секунд, 5 минут, 25 минут и 1 час.
Перед каждой попыткой первый маршрут получателя читается заново.
### 2.3. Что именно знает сервер о `body`
Peer отправителя не получает полную пару и не опрашивается о состоянии.
DmSyncBatch и GetDmDeliveryStatus удалены.
Для контентных DM (`type=1/2`) сервер в v1 должен рассматривать `body` как opaque blob:
## 8. Чтение и realtime
- сервер проверяет внешний контейнер `SHiNE_DM`;
- сервер проверяет подпись;
- сервер проверяет служебные поля envelope;
- сервер проверяет только то, что `bodyLen > 0` и не превышает допустимый лимит;
- сервер не обязан разбирать внутренний crypto-контейнер `body`;
- сервер не обязан знать конкретный алгоритм шифрования `body`.
GetDirectMessages возвращает историю страницами. UI использует стабильные
messageKey/baseKey для дедупликации и отображает более новую ревизию.
Это позволяет альтернативным клиентам использовать совместимый внешний DM-envelope при собственном формате зашифрованного payload внутри `body`.
dm_dialog_state хранит:
Официальный UI SHiNE при приёме такого сообщения:
- последнюю контентную копию;
- время и ключ последнего сообщения;
- unreadCount;
- watermark прочтения.
- пытается расшифровать знакомый формат `EncryptedBody_v1_0`;
- показывает текст сообщения при успешной расшифровке;
- если внутренний формат не поддерживается, показывает `Формат сообщения не поддерживается`;
- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`;
- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`.
WebSocket push ускоряет отображение, но после переподключения клиент запрашивает
историю у своего единственного access-сервера.
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<S:...>` в начале текста. Legacy-вставки `<SHiNE:...>` также продолжают поддерживаться при чтении.
Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope.
## 9. Подтверждения прочтения
### 2.4. Источник истины по пользователю
Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же
маршрут SendMessagePair → ReceiveIncomingMessage.
Для DM-проверки сервер использует:
Сервер хранит наибольший известный watermark, поэтому переставленные или
повторные receipts не уменьшают состояние прочтения.
- локальную runtime-проекцию пользователей как кэш;
- Solana PDA как источник истины по `clientKey` и `access_servers`.
## 10. Редактирование
Если нужного пользователя нет локально, сервер обязан попытаться lazy-import из Solana PDA:
Редактирование сохраняет логический messageKey исходной копии и увеличивает
revisionTimeMs. Сервер принимает только более новую валидную ревизию.
- в `GetUser`;
- при серверной верификации DM-подписи;
- перед межсерверной маршрутизацией через `access_servers`.
Если доставка исходного сообщения ещё не завершилась, следующая попытка
использует актуальную сохранённую incoming-копию.
## 3. Типы сообщений
## 11. Удаление
В версии v1 используются следующие `messageType`:
DeleteMessage принимает type=5/6. DeleteConversation принимает type=7/8.
- `1` — входящее сообщение;
- `2` — исходящая копия сообщения;
- `3` — входящее подтверждение прочтения;
- `4` — исходящая копия подтверждения прочтения;
- `5` — сообщение удалено отправителем;
- `6` — сообщение удалено получателем;
- `7` — переписка удалена отправителем;
- `8` — переписка удалена получателем.
Удаление является подписанным tombstone. Оно:
Типы `1/2` — это обычные контентные DM, в том числе их последующие ревизии.
- проверяется как обычный DM-контейнер;
- применяется идемпотентно;
- удаляет или блокирует соответствующий локальный контент;
- доставляется только на единственные access-серверы обеих сторон.
Типы `3/4` — служебные подтверждения прочтения.
Если сервер уже знает более новый tombstone, старая ревизия контента не
восстанавливает удалённую историю.
Типы `5/6/7/8` — служебные события удаления без зашифрованного тела.
## 12. Хранение
## 4. Базовые операции
Основные таблицы:
### 4.1. Создание нового сообщения
- signed_messages — подписанные DM-контейнеры;
- dm_dialog_state — материализованный список диалогов;
- dm_delivery_state — изменяемое состояние сетевой доставки;
- user_access_servers_current — единственный действующий маршрут пользователя.
Новое сообщение отправляется парой блоков через:
Удалены:
- `SendMessagePair`
- `ReceiveOutcomingMessage` как алиас
- dm_sync_outbox;
- dm_sync_peer_state;
- user_settings_sync_peer_state;
- synced-флаги репликации.
Пару создаёт автор сообщения.
## 13. Границы текущей версии
Пара содержит:
- межсерверная авторизация ServerHello пока не подписана;
- sourceServerLogin не является криптографическим доказательством сервера;
- пользовательская подпись каждого SHiNE_DM проверяется независимо от
транспорта;
- автоматической миграции истории при смене access-сервера нет;
- резервного fallback-сервера нет.
- одну входящую копию `type=1`;
- одну исходящую копию `type=2`;
- одинаковые `fromLogin`, `toLogin`, `timeMs`, `nonce`;
- одинаковый логический plaintext;
- разные ciphertext для разных владельцев копий.
## 14. Сохранённые механизмы
### 4.2. Редактирование сообщения
Переход на один access-сервер не затрагивает:
Редактирование общего текста делает автор сообщения.
- байтовый формат SHiNE_DM;
- шифрование входящей и исходящей копий;
- пользовательские подписи;
- deliveryState и retry-воркер;
- read-receipts;
- синхронизацию пользовательских блокчейнов через server-PDA sync_servers;
- общий постоянный WSS ServerConnectionPool.
При редактировании:
- `baseKey` остаётся тем же;
- `messageType` остаётся тем же;
- `revisionTimeMs` увеличивается;
- автор заново шифрует обе копии и снова отправляет пару через `SendMessagePair`.
Если `revisionTimeMs = 0`, это исходная версия.
Если `revisionTimeMs > 0`, это новая ревизия сообщения.
### 4.3. Перешифровка
Перешифровка нужна для будущего сценария полной или частичной перепаковки истории сообщений при сохранении тех же логических идентификаторов сообщений.
При перешифровке:
- `baseKey` остаётся прежним;
- `messageKey` остаётся прежним;
- plaintext может остаться тем же;
- ciphertext меняется;
- `reencryptedAtMs` получает ненулевое значение;
- `revisionTimeMs` может остаться прежним, в том числе нулевым, если менялось только шифрование без редактирования текста;
- сервер при выборе актуальной версии обязан учитывать обе метки времени.
Если сообщение уже удалено одним из типов `5/6`, его перешифровывать больше нельзя.
Если переписка уже удалена типом `7/8`, более старые сообщения этой пары тоже перешифровывать нельзя.
### 4.4. Приём входящей копии
Для server-to-server доставки одной входящей копии используется:
- `ReceiveIncomingMessage`
Этот метод должен принимать:
- новые входящие сообщения;
- входящие обновления/редактирования;
- будущие входящие перешифрованные копии.
## 5. Удаление одного сообщения
### 5.1. Общая логика
Удаление одного сообщения в v1 всегда глобальное у обеих сторон.
Локального удаления только у себя в этой версии протокола не вводится.
### 5.2. Типы удаления
- `type=5` — сообщение удалено отправителем;
- `type=6` — сообщение удалено получателем.
Удаляющее сообщение:
- не содержит зашифрованного тела;
- хранится в БД как tombstone;
- терминально закрывает это сообщение;
- не даёт больше принять никакую более позднюю содержательную версию этого же `messageKey`.
### 5.3. Метод удаления
Для удаления одного сообщения нужен отдельный метод, условно:
- `DeleteMessage`
Если сервер впервые получает валидное удаляющее сообщение:
- сохраняет tombstone в БД;
- удаляет или замещает прежнюю версию сообщения tombstone-записью;
- распространяет это же удаление на серверы доступа обеих сторон;
- не принимает в будущем попытки "оживить" это сообщение.
## 6. Удаление всей переписки
### 6.1. Отдельное служебное сообщение
Удаление всей переписки между двумя пользователями — это отдельное служебное сообщение без ciphertext.
Типы:
- `type=7` — переписка удалена отправителем;
- `type=8` — переписка удалена получателем.
### 6.2. Граница удаления
Границей удаления считается:
- `timeMs` самого служебного сообщения удаления переписки
Отдельное `deleteBeforeTimeMs` в этой версии не вводится.
### 6.3. Правило применения
Если сервер получает такое сообщение впервые:
- сохраняет его в БД как tombstone переписки;
- удаляет из БД все сообщения этой пары пользователей с `timeMs` меньше времени служебного сообщения;
- больше не принимает новые или повторно доставленные сообщения с `timeMs` раньше этой границы;
- распространяет это же сообщение удаления переписки на серверы доступа обеих сторон.
### 6.4. Поведение при позднем старом сообщении
Если после удаления переписки приходит старое сообщение, у которого:
- `timeMs < deleteConversationMessage.timeMs`
то сервер:
- не принимает это сообщение;
- распространяет уже известный tombstone удаления переписки на серверы доступа обеих сторон;
- ожидает, что вторая сторона обработает его как обычное уже известное удаление переписки.
### 6.5. Будущие новые сообщения
После удаления всей переписки новые сообщения между этими же пользователями разрешены, если:
- их `timeMs` больше времени служебного сообщения удаления переписки.
## 7. Серверы и маршрутизация
### 7.1. `access_servers`
Для обычного пользователя список серверов доставки и доступа задаётся через:
- `access_servers`
Это:
- сервера доступа пользователя;
- сервера relay;
- сервера, через которые пользователь получает личные сообщения и другие пользовательские операции.
### 7.2. `sync_servers`
`sync_servers` относятся не к обычной пользовательской маршрутизации DM, а к server-to-server партнёрству серверного узла.
Они используются для:
- синхронизации серверных данных;
- синхронизации пользовательских блокчейнов SHiNE;
- общей межсерверной координации.
`sync_servers` не являются списком пользовательских серверов доставки DM.
### 7.3. Два сервера у отправителя и получателя
Актуальный runtime исходит из ограничения:
- у одного пользователя не более двух `access_servers`;
- следовательно, у локального сервера есть не более одного peer для репликации данных пользователя.
Протокол поддерживает ситуацию, когда:
- у отправителя один или два `access_servers`;
- у получателя один или два `access_servers`;
- часть серверов у сторон совпадает;
- часть серверов уникальна.
Из этого следуют требования:
- все DM-операции должны быть идемпотентны;
- повторное получение уже известного события не должно ломать состояние;
- дубль tombstone должен быть безопасен;
- сервер не должен "оживлять" более старую версию сообщения после уже принятого tombstone.
## 8. Методы и их роли
### 8.1. Существующие методы, которые сохраняются
- `SendMessagePair`
- `ReceiveOutcomingMessage`
- `ReceiveIncomingMessage`
- `DeleteMessage`
- `DeleteConversation`
Их роли в v1:
- `SendMessagePair` — новая пара сообщений и редактирование старой пары автором;
- `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и входящего read-receipt;
- `ReceiveOutcomingMessage` — алиас `SendMessagePair`.
### 8.2. Межсерверная синхронизация
- `ReceiveOutcomingMessage` — прежняя полная пара второго access-сервера отправителя;
- `ReceiveIncomingMessage` — прежняя одиночная входящая копия;
- `DmSyncBatch` — pull событий с `synced=false` и ACK сохранённой предыдущей страницы;
- `GetDmDeliveryStatus` — единственная новая read-only проверка доставки по существующему `messageKey`.
Delivery-state между серверами не передаётся и не объединяется.
### 8.3. Серверный слой диалогов
Помимо хранения самих DM-сообщений сервер поддерживает материализованный слой состояния диалогов:
- отдельная запись на пару `owner_login` + `peer_login`;
- `relation_flag` со значениями `close_friend`, `contact`, `none`;
- `last_message_blob_b64` как последний контентный signed DM block в base64;
- `last_message_time_ms`;
- `unread_count`;
- `last_read_receipt_time_ms` как watermark последнего подтверждения прочтения.
Ключевые правила:
- `close_friend` всегда имеет приоритет над `contact`;
- если `read receipt` приходит не по порядку, сервер хранит наибольший watermark и не откатывает состояние назад;
- `unread_count` пересчитывается сервером по сообщениям диалога с учётом watermark и `read_at_ms`;
- старые исторические данные восстанавливаются из `signed_messages` при инициализации/миграции;
- UI не должен собирать inbox только из локального кеша, когда ему доступен серверный список диалогов.
## 9. Правила валидации и применения
### 9.1. Общее правило по ревизиям
Для одного и того же контентного сообщения сервер сравнивает пару:
- `revisionTimeMs`;
- `reencryptedAtMs`.
Правило:
- если новый `revisionTimeMs` больше сохранённого, сообщение применяется;
- если `revisionTimeMs` равен, но новый `reencryptedAtMs` больше сохранённого, сообщение применяется;
- если обе величины равны, сообщение не применяется;
- если новая пара (`revisionTimeMs`, `reencryptedAtMs`) меньше или равна сохранённой, сообщение не применяется.
Содержимое `body` при этом сравнении не участвует.
То есть если сервер уже видел ту же пару (`revisionTimeMs`, `reencryptedAtMs`), он считает, что такая версия у него уже есть.
### 9.2. Повторный tombstone
Повторный tombstone означает ситуацию, когда сервер повторно получает то же самое событие удаления:
- того же сообщения;
- или той же переписки.
Это может произойти из-за:
- повторной межсерверной доставки;
- нескольких `access_servers`;
- сетевых retry;
- дублирующей пересылки с разных маршрутов.
Правило:
- повторный tombstone должен быть полностью безопасен;
- если соответствующее удаление уже сохранено, сервер ничего не меняет и просто игнорирует повтор.
### 9.3. Сообщения старше границы удалённой переписки
Если для пары пользователей уже есть сохранённая граница удаления переписки, и приходит:
- обычное сообщение;
- удаление одного сообщения;
- повторное удаление переписки;
- любое другое DM-событие;
у которого `timeMs` меньше этой границы, сервер:
- ничего не меняет в БД;
- не восстанавливает старую историю;
- не применяет это событие повторно.
Если это старое контентное сообщение или входящая копия, сервер дополнительно перерассылает уже известный tombstone удаления переписки на `access_servers` обеих сторон, чтобы отстающие серверы сами подчистили историю.
Такие сообщения считаются частью уже удалённой истории.
### 9.4. Приоритет удаления переписки
Если сначала пришло удаление переписки, а потом удаление одного старого сообщения из этой переписки, сервер должен:
- проигнорировать это удаление одного сообщения;
- не создавать новых изменений поверх уже удалённой истории.
То же правило действует и для обычных сообщений, и для редактирований старых сообщений.
## 10. JSON API v1
### 10.1. `SendMessagePair`
Назначение:
- клиент отправляет новый DM;
- клиент отправляет редактирование старого DM;
- клиент отправляет парную новую ревизию типов `1/2`.
Request:
```json
{
"op": "SendMessagePair",
"requestId": "req-123",
"payload": {
"incomingBlobB64": "...",
"outgoingBlobB64": "..."
}
}
```
Правила:
- клиенту достаточно отправить пару на один любой доступный сервер;
- успешный `status=200` означает локальное сохранение;
- первая сетевая попытка выполняется до ответа клиенту, параллельно для двух серверов получателя;
- сервер после принятия сам отвечает за дальнейшую межсерверную доставку.
Ответ сохраняет прежние `baseKey`, `incomingKey`, `outgoingKey` и счётчики доставки в клиентские сессии. Единственное новое поле — `deliveryState`: `accepted`, `delivered` или `failed`.
### 10.2. `ReceiveIncomingMessage`
Назначение:
- приём одной входящей копии по схеме server-to-server;
- приём входящего редактирования;
- приём входящего read-receipt.
Request:
```json
{
"op": "ReceiveIncomingMessage",
"requestId": "req-456",
"payload": {
"incomingBlobB64": "...",
"sourceServerLogin": "server-a"
}
}
```
`sourceServerLogin` пока доверяется без отдельной межсерверной подписи. Сервер всё равно проверяет пользовательскую подпись самого `SHiNE_DM`. Успешный ответ сохраняет старые поля `messageKey`, `baseKey` и счётчики realtime-доставки.
### 10.3. `DeleteMessage`
Назначение:
- удалить одно сообщение у обеих сторон.
Request:
```json
{
"op": "DeleteMessage",
"requestId": "req-789",
"payload": {
"blobB64": "..."
}
}
```
Ожидается контейнер типа:
- `5`, если удаление инициировал отправитель;
- `6`, если удаление инициировал получатель.
UI-следствие для клиента:
- пользователь может удалить как своё исходящее сообщение, так и входящее;
- для удаления входящего UI должен отправлять вариант, где удаление инициировал получатель (`type=6`).
### 10.4. `DeleteConversation`
Назначение:
- удалить всю переписку до времени самого служебного сообщения.
Request:
```json
{
"op": "DeleteConversation",
"requestId": "req-790",
"payload": {
"blobB64": "..."
}
}
```
Ожидается контейнер типа:
- `7`, если удаление инициировал отправитель;
- `8`, если удаление инициировал получатель.
UI-следствие для клиента:
- действие `Очистить историю` должно отправлять служебное сообщение удаления переписки;
- для обычной клиентской кнопки очистки истории допускается вариант удаления, инициированный текущим получателем (`type=8`);
- после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории;
- отдельное UI-действие `Удалить чат` может дополнительно спросить, нужно ли вместе с удалением контакта также отправить `DeleteConversation`.
### 10.5. `GetDirectMessages`
Назначение:
- постранично отдавать историю одного личного диалога;
- не вываливать весь старый backlog автоматически в момент логина;
- отдавать клиенту только полезные chat-сообщения, без технических receipt/tombstone блоков.
Правила:
- начиная с 22 июля 2026 года старая история DM больше не должна автоматически пушиться клиенту сразу после входа;
- клиент сам запрашивает первую страницу истории у нужного собеседника;
- последующие страницы клиент запрашивает по курсору `beforeTimeMs` + `beforeMessageKey`;
- realtime-новые сообщения по-прежнему приходят отдельно через `SignedMessageArrived`.
Содержимое страницы:
- сервер возвращает только контентные DM типов `1/2`;
- read-receipt (`3/4`) и tombstone удаления (`5/6/7/8`) в историю страницы не включаются;
- сообщения идут страницами от новых к старым;
- лимит страницы считается по самим chat-сообщениям диалога.
Поле прочтения:
- для каждого контентного сообщения сервер может вернуть `readAtMs`;
- `readAtMs` означает точное время прочтения сообщения, если оно уже известно серверу;
- если точное время для старого сообщения неизвестно, но по более новым данным видно, что сообщение уже точно прочитано, UI может показывать его как прочитанное без точного времени;
- read-receipt при этом остаётся отдельным DM-событием синхронизации, но в обычную историю страницы не подмешивается.
Для исходящих элементов сервер также возвращает единственное поле `deliveryState`.
## 11. Межсерверная доставка
### 11.1. Клиентская сторона
Клиенту достаточно отправить сообщение на:
- любой один доступный сервер.
### 11.2. Серверная сторона
После локального принятия пара получает изменяемое состояние `accepted`. Сервер сразу вызывает до двух текущих маршрутов получателя параллельно. Успех хотя бы одного маршрута переводит сообщение в терминальное `delivered`; ждать второй маршрут не требуется.
После первой попытки полная пара передаётся единственному второму access-серверу отправителя через прежний `ReceiveOutcomingMessage`. Peer самостоятельно пробует актуальные маршруты получателя; delivery-state в запрос не входит.
Идемпотентность обязательна. ACK означает запись в БД; повтор уже известной ревизии считается успешным ACK.
### 11.3. Догоняющая синхронизация истории
Для восстановления пропущенных DM-событий между access-серверами используется pull-операция:
- `DmSyncBatch`
Второй сервер запрашивает outbox-события владельца с `synced=false`, сохраняет их и передаёт подтверждённые `syncId` в `ackSyncIds` следующего запроса. Источник ставит `synced=true` только после ACK. Каждый цикл начинается с начала списка; курсор нужен только для страниц текущего цикла.
Полная пара отправителя передаётся двумя blob. Входящая копия получателя и tombstone передаются одним blob.
Полная пара отправителя передаётся одним элементом с двумя blob в порядке incoming/outgoing. Входящая копия получателя и tombstone передаются одним blob.
При применении событий, полученных через `DmSyncBatch`, сервер:
- проверяет формат `SHiNE_DM`;
- проверяет подпись;
- применяет существующие правила ревизий, read-receipt и tombstone;
- не отправляет realtime/push-уведомления клиентам;
- не запускает повторный fan-out, чтобы не создавать циклы.
Синхронизация настроек и DM выполняется одним периодическим процессом и через один последовательный WS-сеанс с peer. Выборка DM использует частичный индекс по `synced=false`.
В текущей реализации межсерверная авторизация DM ещё не включена. Принимающий сервер проверяет, что сам является access-сервером `ownerLogin`, и всегда проверяет пользовательские подписи signed-блоков.
### 11.4. Ошибки доставки
Если ни один сервер получателя не подтвердил запись, попытки выполняются сразу, через 30 секунд, 5 минут, 25 минут и 1 час от первоначального принятия.
Перед попытками через 5 минут, 25 минут и час сервер read-only спрашивает peer отправителя через `GetDmDeliveryStatus(messageKey)`. Если peer уже доставил хотя бы на один сервер, сообщение считается доставленным.
После неудачной последней попытки устанавливается `failed`; дальнейших автоматических попыток и кнопки ручного повтора нет.
## 12. Хранение в БД
Основная таблица остаётся:
- `signed_messages`
В ней должны сохраняться:
- обычные контентные DM;
- read-receipt DM;
- tombstone одного сообщения;
- tombstone удаления переписки.
Для контентных сообщений в БД дополнительно должно поддерживаться серверное поле `read_at_ms`, если для этого сообщения уже было принято read-receipt событие.
Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы:
- защищать от повторного приёма старых версий;
- не терять факт удаления;
- корректно синхронизировать событие между серверами.
Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются.
Изменяемая сетевая часть хранится отдельно:
- `dm_delivery_state` — состояние и расписание доставки исходящей пары;
- `dm_sync_outbox` — событие владельца и единственный флаг ACK `synced`;
- частичные индексы содержат только due/unsynced строки.
Legacy-таблица `dm_sync_peer_state` после миграции v12 физически остаётся для безопасной установки ZIP-накладки, но новым DM-кодом не используется.
## 13. Что обязательно должно измениться в коде относительно v0.5
- сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`;
- сервер не должен трактовать `encryptedBody` как обычный UTF-8 текст;
- сервер не должен валидировать внутреннюю crypto-структуру `body` у контентных DM `type=1/2`;
- DM должны реально шифроваться end-to-end;
- удаление одного сообщения должно стать терминальным tombstone;
- удаление всей переписки должно стать отдельным служебным событием;
- межсерверная маршрутизация DM должна идти через `access_servers`;
- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM;
- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает;
- логика должна быть безопасна для одного или двух серверов у каждой стороны.
## 14. Что в v1 пока не входит
- вложения в DM;
- хранение отдельного `keyId` шифрования в DM;
- ротация `clientKey`;
- финальная конкретная UI-реализация массовой перешифровки;
- межсерверная авторизация DM-синхронизации и доставки.
## UI списка личных чатов — фильтры и отображение времени (2026-08-28)
- Заголовок «Чаты» является переключателем фильтра списка.
- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые».
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени.
+16 -1
View File
@@ -340,4 +340,19 @@ ReadReceiptBody_v1_0
В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`.
Межсерверные операции `ReceiveOutcomingMessage`, `ReceiveIncomingMessage` и `DmSyncBatch` не вводят новый байтовый формат DM. Они передают уже сохранённые raw-контейнеры `SHiNE_DM` в Base64. `ackSyncIds` подтверждает только факт сохранения синхронизированной ревизии; delivery-state между серверами не передаётся. Принимающий сервер заново проверяет подпись и применяет контейнер по его `messageType`.
`SendMessagePair` передаёт входящую и исходящую подписанные копии от UI на
сервер отправителя. Внутренняя операция `ReceiveIncomingMessage` передаёт
только входящую raw-копию в Base64 на единственный access-сервер получателя.
Обе операции используют существующий байтовый формат `SHiNE_DM`.
Межсерверные `ReceiveOutcomingMessage` и `DmSyncBatch` удалены.
Принимающий сервер заново проверяет пользовательскую подпись и применяет
контейнер по его `messageType`.
## 14. Превью в списке диалогов
Выбор последнего сообщения для клиентского превью не является частью байтового формата. Клиент сравнивает время сообщений обоих направлений и показывает текст самого нового входящего или исходящего сообщения. Локальная расшифрованная копия может временно иметь приоритет над более старым `lastMessageBlobB64` из серверной сводки.
Контейнер `SHiNE_DM`, подписи, `messageType`, порядок полей и правила шифрования этой UI-доработкой не изменяются.
## Примечание UI списка чатов (2026-08-28)
Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет.
@@ -20,8 +20,10 @@
- сервер запускает sync-модуль до продолжения собственного startup;
- server startup ждёт входа модуля в состояние `READY`;
- актуальный источник истины по пользователям для нового PostgreSQL runtime-слоя: `solana_user_pda_current`.
- для быстрого локального маршрутизационного lookup серверов доступа поддерживается вторичная проекция
`user_access_servers_current`, которая автоматически пересобирается из `solana_user_pda_current`.
- для быстрого локального lookup единственного сервера доступа поддерживается
вторичная проекция `user_access_servers_current`, которая автоматически
пересобирается только из первого элемента `access_servers[0]` в
`solana_user_pda_current`.
---
+7 -2
View File
@@ -291,8 +291,13 @@ AccessServersBlock
- блок может отсутствовать, если серверы доступа не заданы;
- список может обновляться при изменении маршрутизации пользователя;
- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay для конкретного пользователя. Через этот список клиентская и серверная логика SHiNE может маршрутизировать доставку личных сообщений, доступ к сессиям и другие пользовательские операции. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы;
- точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE.
- `access_servers` — массив логинов серверов доступа/relay. Формат массива
сохранён для совместимости, но текущая клиентская и серверная логика использует
только первый элемент `access_servers[0]`;
- остальные элементы игнорируются без fallback;
- официальный UI записывает ровно один сервер и позволяет только заменить его;
- Solana-программа не обязана проверять, что логин действительно зарегистрирован
как сервер.
## 14. SessionsBlock
@@ -74,7 +74,8 @@
- `server_key: Pubkey`
- `server_address: String`
- `sync_servers: Vec<String>`
- `access_servers: Vec<String>`
- `access_servers: Vec<String>` — формат остаётся массивом, но runtime
использует только первый элемент
- `trusted_count: u8`
## Главные PDA
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 48 KiB

+2 -2
View File
@@ -1,4 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="32" cy="24.5" r="10.5" stroke="#F2F5FB" stroke-width="3"/>
<path d="M15.5 48C17.8 38.9 24.2 34.5 32 34.5C39.8 34.5 46.2 38.9 48.5 48" stroke="#F2F5FB" stroke-width="3" stroke-linecap="round"/>
<circle cx="32" cy="23.5" r="10.5" stroke="#65B7FF" stroke-width="3.2"/>
<path d="M14.5 49C17.1 39.2 23.9 34.5 32 34.5C40.1 34.5 46.9 39.2 49.5 49" stroke="#65B7FF" stroke-width="3.2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 316 B

+2 -3
View File
@@ -1,5 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="32" cy="32" r="10.2" stroke="#F2F5FB" stroke-width="2.8"/>
<path d="M32 16.8L34.8 20.4L39.3 19.2L40.2 23.8L44.8 24.7L43.6 29.2L47.2 32L43.6 34.8L44.8 39.3L40.2 40.2L39.3 44.8L34.8 43.6L32 47.2L29.2 43.6L24.7 44.8L23.8 40.2L19.2 39.3L20.4 34.8L16.8 32L20.4 29.2L19.2 24.7L23.8 23.8L24.7 19.2L29.2 20.4L32 16.8Z" stroke="#F2F5FB" stroke-width="2.4" stroke-linejoin="round"/>
<circle cx="32" cy="32" r="3.5" fill="#F2F5FB"/>
<path d="M27.1 11.5h9.8l1.4 6.1c1.5.6 2.9 1.4 4.2 2.4l6-1.9 4.9 8.5-4.6 4.2c.1.8.2 1.6.2 2.4s-.1 1.6-.2 2.4l4.6 4.2-4.9 8.5-6-1.9c-1.3 1-2.7 1.8-4.2 2.4l-1.4 6.1h-9.8l-1.4-6.1c-1.5-.6-2.9-1.4-4.2-2.4l-6 1.9-4.9-8.5 4.6-4.2a16 16 0 0 1 0-4.8l-4.6-4.2 4.9-8.5 6 1.9c1.3-1 2.7-1.8 4.2-2.4l1.4-6.1Z" stroke="#65B7FF" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="32" cy="33.2" r="8.2" stroke="#65B7FF" stroke-width="3.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 543 B

After

Width:  |  Height:  |  Size: 558 B

+3 -4
View File
@@ -1,6 +1,5 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 24.5C13.5 20.9101 16.4101 18 20 18H41.5C45.0899 18 48 20.9101 48 24.5V26H23.5C21.0147 26 19 28.0147 19 30.5C19 32.9853 21.0147 35 23.5 35H48V39.5C48 43.0899 45.0899 46 41.5 46H20C16.4101 46 13.5 43.0899 13.5 39.5V24.5Z" stroke="#F2F5FB" stroke-width="3" stroke-linejoin="round"/>
<path d="M19 30.5C19 28.0147 21.0147 26 23.5 26H50.5V35H23.5C21.0147 35 19 32.9853 19 30.5Z" stroke="#F2F5FB" stroke-width="3" stroke-linejoin="round"/>
<circle cx="42.5" cy="30.5" r="2.4" fill="#F2F5FB"/>
<path d="M18.5 22H38" stroke="#F2F5FB" stroke-width="3" stroke-linecap="round" opacity="0.9"/>
<path d="M13 23.5C13 19.9 15.9 17 19.5 17H42C45.6 17 48.5 19.9 48.5 23.5V27H24C21.2 27 19 29.2 19 32C19 34.8 21.2 37 24 37H48.5V40.5C48.5 44.1 45.6 47 42 47H19.5C15.9 47 13 44.1 13 40.5V23.5Z" stroke="#65B7FF" stroke-width="3.2" stroke-linejoin="round"/>
<path d="M19 32C19 29.2 21.2 27 24 27H51V37H24C21.2 37 19 34.8 19 32Z" stroke="#65B7FF" stroke-width="3.2" stroke-linejoin="round"/>
<circle cx="42.5" cy="32" r="2.5" fill="#65B7FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 707 B

After

Width:  |  Height:  |  Size: 548 B

+22 -3
View File
@@ -12,7 +12,7 @@
<link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260822140000';
window.__SHINE_BUILD_HASH__ = '20260826145335';
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script>
<script>
@@ -229,9 +229,17 @@ window.__SHINE_BUILD_HASH__ = '20260822140000';
document.getElementById(menuId)?.remove();
} catch {}
};
const dismissError = () => {
removeMenu();
try {
document.getElementById('boot-error-overlay')?.remove();
setState(null);
} catch {}
};
const openMenu = () => {
try {
removeMenu();
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: 'boot-error' } }));
const report = buildReport();
const root = document.getElementById('modal-root') || document.body;
const shell = document.createElement('div');
@@ -246,7 +254,7 @@ window.__SHINE_BUILD_HASH__ = '20260822140000';
<button type="button" class="secondary-btn" data-action="copy">Скопировать текст ошибки</button>
<button type="button" class="secondary-btn" data-action="share">Отправить текст ошибки</button>
<button type="button" class="secondary-btn" data-action="send"${typeof window.__SHINE_SEND_CLIENT_ERROR_TO_SERVER__ === 'function' ? '' : ' disabled'}>Отправить в лог на сервере</button>
<button type="button" class="ghost-btn" data-action="close">Закрыть</button>
<button type="button" class="secondary-btn" data-action="dismiss">Закрыть</button>
</div>
</div>
`;
@@ -308,7 +316,18 @@ window.__SHINE_BUILD_HASH__ = '20260822140000';
close();
});
shell.querySelector('[data-action="close"]')?.addEventListener('click', close);
shell.querySelector('[data-action="dismiss"]')?.addEventListener('click', () => {
close();
try {
document.getElementById('boot-error-overlay')?.remove();
setState(null);
} catch {}
});
const onPeerDropdownOpen = (event) => {
if (event?.detail?.owner === 'boot-error') return;
close();
};
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen, { once: true });
dialog?.focus?.();
window.addEventListener('keydown', onKeyDown);
} catch (error) {
+35 -4
View File
@@ -93,6 +93,7 @@ import * as chatView from './pages/chat-view.js?v=202608221218';
import * as userProfileView from './pages/user-profile-view.js';
import * as channelsList from './pages/channels-list.js?v=202608221218';
import * as channelView from './pages/channel-view.js';
import * as channelAboutView from './pages/channel-about-view.js';
import * as channelThreadView from './pages/channel-thread-view.js';
import * as addChannelView from './pages/add-channel-view.js';
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
@@ -155,6 +156,7 @@ const routes = {
user: userProfileView,
'channels-list': channelsList,
'channel-view': channelView,
'channel-about-view': channelAboutView,
'channel-thread-view': channelThreadView,
'add-channel-view': addChannelView,
'add-personal-public-chat-view': addPersonalPublicChatView,
@@ -198,20 +200,45 @@ let orientationLockInFlight = false;
let currentChromeCleanup = null;
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
'messages-list',
'chat-view',
'channels-list',
'channel-view',
'channel-thread-view',
'notifications-view',
]);
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
...PRE_AUTH_PAGES.filter((pageId) => pageId !== 'entry-settings-view'),
'settings-view',
]);
const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
'access-servers-view',
'server-settings-view',
'arweave-uploads-view',
'developer-settings-view',
'trusted-device-login-settings-view',
'device-view',
'device-session-view',
'connect-device-view',
'device-pairing-view',
'device-qr-view',
'device-camera-view',
'show-keys-view',
'remote-addblock-session-view',
'app-log-view',
'pwa-diagnostics-view',
'solana-users-init-view',
'solana-rpc-check-view',
]);
const GUEST_ALLOWED_PAGES = new Set([
'start-view',
'entry-settings-view',
'language-view',
'network-view',
'channels-list',
'channel-view',
'channel-about-view',
'channel-thread-view',
'user',
'contact-search-view',
@@ -1172,7 +1199,8 @@ function renderApp() {
}
const page = routes[pageId] || routes['start-view'];
const showAppChrome = page.pageMeta?.showAppChrome !== false;
const showAppChrome = page.pageMeta?.showAppChrome !== false
&& !(pageId === 'language-view' && !state.session.isAuthorized);
if (typeof currentCleanup === 'function') {
currentCleanup();
currentCleanup = null;
@@ -1201,6 +1229,8 @@ function renderApp() {
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
screenEl.classList.toggle('filled-action-buttons', FILLED_ACTION_BUTTON_PAGE_IDS.has(pageId));
screenEl.classList.toggle('settings-bordered-actions', SETTINGS_BORDERED_ACTION_PAGE_IDS.has(pageId));
toolbarEl.innerHTML = '';
if (showAppChrome) {
@@ -1218,7 +1248,8 @@ function refreshToolbarOnly() {
const route = getRoute();
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
const page = routes[pageId] || routes['start-view'];
const showAppChrome = page.pageMeta?.showAppChrome !== false;
const showAppChrome = page.pageMeta?.showAppChrome !== false
&& !(pageId === 'language-view' && !state.session.isAuthorized);
toolbarEl.innerHTML = '';
if (showAppChrome) {
+7 -4
View File
@@ -8,9 +8,12 @@ function normalizeLogin(value) {
function pickSizeClass(size) {
const raw = String(size || '').trim().toLowerCase();
if (raw === 'large') return 'large';
if (raw === 'node') return 'node-dot';
if (raw === 'small') return '';
if (raw === 'xs' || raw === 'xsmall' || raw === 'tiny') return 'avatar-xs';
if (raw === 'sm' || raw === 'small') return 'avatar-sm';
if (raw === 'md' || raw === 'medium' || raw === '') return 'avatar-md';
if (raw === 'lg' || raw === 'big') return 'avatar-lg';
if (raw === 'xl' || raw === 'xlarge' || raw === 'large' || raw === 'very-large') return 'avatar-xl';
return raw || '';
}
@@ -28,7 +31,7 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
export function renderAvatar({
initials = '?',
avatar = null,
size = 'large',
size = 'xl',
className = '',
title = '',
alt = 'Аватар',
@@ -139,7 +142,7 @@ export function renderUserAvatar({
firstName = '',
lastName = '',
avatar = null,
size = 'large',
size = 'xl',
className = '',
title = '',
glow = false,
+139
View File
@@ -0,0 +1,139 @@
export function createDropdownMenu({
anchorEl,
items = [],
className = '',
minWidth = 210,
offset = 7,
align = 'right',
leftShift = 0,
onOpen = null,
onClose = null,
} = {}) {
let portal = null;
const close = () => {
if (!portal) return;
portal.remove();
portal = null;
anchorEl?.setAttribute('aria-expanded', 'false');
onClose?.();
};
const position = () => {
if (!portal || !anchorEl) return;
const rect = anchorEl.getBoundingClientRect();
const margin = 10;
const menuWidth = portal.offsetWidth || minWidth;
const baseLeft = align === 'left' ? rect.left : rect.right - menuWidth;
const desiredLeft = baseLeft - Number(leftShift || 0);
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, desiredLeft));
let top = rect.bottom + offset;
const menuHeight = portal.offsetHeight || 180;
if (top + menuHeight > window.innerHeight - margin) {
top = Math.max(margin, rect.top - menuHeight - offset);
}
portal.style.left = `${Math.round(left)}px`;
portal.style.top = `${Math.round(top)}px`;
};
const open = () => {
if (!anchorEl || portal) return;
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
const menu = document.createElement('div');
menu.className = `dm-head-menu dm-head-menu--portal shared-dropdown-menu ${className}`.trim();
menu.setAttribute('role', 'menu');
menu.style.minWidth = `${minWidth}px`;
items.forEach((item) => {
if (item?.divider) {
const divider = document.createElement('div');
divider.className = 'channel-menu-divider';
menu.append(divider);
return;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `dm-head-menu-item shared-dropdown-menu__item${item?.selected ? ' is-selected' : ''}${item?.danger ? ' destructive' : ''}`;
btn.setAttribute('role', 'menuitem');
if (item?.iconHtml) {
const icon = document.createElement('span');
icon.className = 'shared-dropdown-menu__icon';
icon.innerHTML = item.iconHtml;
btn.append(icon);
} else if (item?.iconSrc) {
const icon = document.createElement('img');
icon.src = item.iconSrc;
icon.alt = '';
icon.setAttribute('aria-hidden', 'true');
btn.append(icon);
}
const label = document.createElement('span');
label.textContent = String(item?.label || '');
btn.append(label);
btn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
close();
item?.action?.();
});
menu.append(btn);
});
menu.addEventListener('click', (event) => event.stopPropagation());
document.body.append(menu);
portal = menu;
anchorEl.setAttribute('aria-expanded', 'true');
onOpen?.();
position();
};
const toggle = () => {
if (portal) close();
else open();
};
const onAnchorClick = (event) => {
event.preventDefault();
event.stopPropagation();
toggle();
};
const onOutsideClick = (event) => {
if (!portal) return;
if (portal.contains(event.target) || anchorEl?.contains(event.target)) return;
close();
};
const onPeerDropdownOpen = (event) => {
if (!portal || event?.detail?.owner === anchorEl) return;
close();
};
const onKeydown = (event) => {
if (event.key !== 'Escape' || !portal) return;
close();
anchorEl?.focus();
};
anchorEl?.setAttribute('aria-haspopup', 'menu');
anchorEl?.setAttribute('aria-expanded', 'false');
anchorEl?.addEventListener('click', onAnchorClick);
document.addEventListener('click', onOutsideClick);
document.addEventListener('keydown', onKeydown);
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
window.addEventListener('resize', position, { passive: true });
window.addEventListener('scroll', position, { passive: true, capture: true });
return {
open,
close,
toggle,
position,
destroy() {
close();
anchorEl?.removeEventListener('click', onAnchorClick);
document.removeEventListener('click', onOutsideClick);
document.removeEventListener('keydown', onKeydown);
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
window.removeEventListener('resize', position);
window.removeEventListener('scroll', position, true);
},
};
}
+10
View File
@@ -0,0 +1,10 @@
export const SHINE_CONNECTIONS_LOGO_SRC = '/assets/SHiNE_connections_blue.svg?v=2026082601';
export function createShineConnectionsLogo({ className = '' } = {}) {
const img = document.createElement('img');
img.src = SHINE_CONNECTIONS_LOGO_SRC;
img.alt = '';
img.setAttribute('aria-hidden', 'true');
img.className = String(className || '').trim();
return img;
}

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