Compare commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
3e4583bbe9 | ||
|
|
b47440dd72 | ||
|
|
357a05f7ec | ||
|
|
f2fdb8195c | ||
|
|
8a0275d962 | ||
|
|
ef1fd9f579 | ||
|
|
f6b95d94bc | ||
|
|
2a65e6d359 | ||
|
|
83f87ba22c |
@@ -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`
|
||||
|
||||
## Деплой
|
||||
|
||||
@@ -32,6 +32,7 @@ public final class DatabaseInitializer {
|
||||
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";
|
||||
@@ -47,6 +48,7 @@ public final class DatabaseInitializer {
|
||||
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() {}
|
||||
|
||||
@@ -166,6 +168,10 @@ public final class DatabaseInitializer {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
@@ -416,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,
|
||||
@@ -824,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 (
|
||||
@@ -868,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,
|
||||
|
||||
@@ -27,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;
|
||||
|
||||
@@ -73,6 +78,12 @@ 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 можно было отправлять сообщения клиенту.
|
||||
@@ -197,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;
|
||||
@@ -209,6 +242,7 @@ public class ConnectionContext {
|
||||
sessionLoginNonceExpiresAtMs = 0;
|
||||
|
||||
authenticationStatus = AUTH_STATUS_NONE;
|
||||
userServerScope = USER_SERVER_SCOPE_NONE;
|
||||
wsSession = null;
|
||||
serverConnection = false;
|
||||
remoteServerLogin = null;
|
||||
@@ -222,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;
|
||||
@@ -163,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()),
|
||||
@@ -219,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()),
|
||||
@@ -255,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),
|
||||
@@ -311,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),
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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(remoteLogin, 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,12 +47,10 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
|
||||
SignedMessageEntry incomingEntry;
|
||||
SignedMessageEntry outgoingEntry;
|
||||
boolean fromPeer = !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 подтверждения");
|
||||
}
|
||||
@@ -96,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; }
|
||||
}
|
||||
}
|
||||
@@ -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,138 +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(
|
||||
routeLogin, 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.getServerLogin(),
|
||||
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.getServerLogin(), 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();
|
||||
@@ -280,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));
|
||||
}
|
||||
@@ -296,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,63 +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.getServerLogin(), 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.getServerLogin(), 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.getServerLogin(), 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);
|
||||
}
|
||||
@@ -73,12 +27,17 @@ 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;
|
||||
@@ -89,33 +48,29 @@ public final class DmFederationService {
|
||||
}
|
||||
}
|
||||
} 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() {
|
||||
@@ -124,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.getServerLogin(), 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) {}
|
||||
}
|
||||
@@ -8,8 +8,7 @@ import org.slf4j.LoggerFactory;
|
||||
import server.debug.DebugApiConfigurator;
|
||||
import server.sync.BlockchainResyncRecoveryOnStartup;
|
||||
import server.sync.PeriodicBlockchainSyncService;
|
||||
import server.sync.PeriodicDmSyncService;
|
||||
import server.sync.PeriodicUserSettingsSyncService;
|
||||
import server.sync.PeriodicDmDeliveryService;
|
||||
import server.sync.SolanaUsersSyncStartupService;
|
||||
import server.sync.SyncServersBootstrapService;
|
||||
import server.sync.ServerConnectionPool;
|
||||
@@ -109,8 +108,7 @@ public final class WsServer {
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(
|
||||
() -> ServerConnectionPool.getInstance().close(),
|
||||
"server-connection-pool-shutdown"));
|
||||
PeriodicDmSyncService.startOrLog();
|
||||
PeriodicUserSettingsSyncService.startOrLog();
|
||||
PeriodicDmDeliveryService.startOrLog();
|
||||
server.join();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,13 +34,10 @@ 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
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
client.version=1.7.0
|
||||
server.version=1.6.1
|
||||
client.version=1.7.6
|
||||
server.version=1.6.2
|
||||
|
||||
@@ -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,}'
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||
|
||||
@@ -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 для входа в существующую сессию |
|
||||
@@ -63,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 приглашения к звонку |
|
||||
@@ -78,7 +74,9 @@
|
||||
|
||||
## Важные замечания
|
||||
|
||||
- `ReceiveOutcomingMessage` зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`, и сохраняет прежний межсерверный payload.
|
||||
- UI отправляет полную подписанную пару только через `SendMessagePair`.
|
||||
- `ReceiveIncomingMessage` вызывается сервером отправителя для доставки одной
|
||||
входящей копии на единственный access-сервер получателя.
|
||||
- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее.
|
||||
- `ServerHello` пока принимает заявленный `serverLogin` на доверии и не является криптографической аутентификацией.
|
||||
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
|
||||
|
||||
@@ -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` трактуется как клиентский вызов, непустой - как peer-вызов.
|
||||
`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,72 +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` означает доставку хотя бы одному серверу получателя.
|
||||
|
||||
Периодический процесс использует один логический последовательный сеанс поверх
|
||||
постоянного WSS-пула: сначала синхронизирует настройки, затем вызывает
|
||||
`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`
|
||||
|
||||
|
||||
@@ -1,104 +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, но периодический процесс
|
||||
использует один логический последовательный сеанс поверх постоянного WSS-пула:
|
||||
сначала синхронизирует настройки, затем забирает `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 и периодический сервис синхронизации
|
||||
настроек.
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
- физическое соединение создаётся одно на `serverLogin`;
|
||||
- логические операции DM, settings и blockchain используют один WSS;
|
||||
- завершение `RemoteSyncSession` не закрывает физический сокет;
|
||||
- известные peer берутся из `sync_servers` и `user_access_servers_current`;
|
||||
- известные peer берутся из `sync_servers` и первых действующих маршрутов
|
||||
`user_access_servers_current`;
|
||||
- список перечитывается каждые 30 секунд;
|
||||
- при изменении URL соединение пересоздаётся;
|
||||
- новые запросы не повторяются транспортом автоматически: действующие
|
||||
@@ -65,8 +66,8 @@
|
||||
|
||||
| Приоритет | Операции |
|
||||
| --- | --- |
|
||||
| `REALTIME` | доставка DM, deletes, `GetDmDeliveryStatus` |
|
||||
| `NORMAL` | настройки, `DmSyncBatch` и access-data sync |
|
||||
| `REALTIME` | доставка DM и signed deletes |
|
||||
| `NORMAL` | прочие межсерверные запросы |
|
||||
| `BULK` | blockchain heads, blocks, `AddBlock` backfill |
|
||||
|
||||
На одном peer одновременно исполняется один запрос. Приоритет применяется к
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. Текущая реализованная схема
|
||||
|
||||
@@ -196,7 +194,7 @@ Full resync запускается только тогда, когда:
|
||||
- На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена.
|
||||
- При разрыве выполняется переподключение с jitter/backoff до 60 секунд.
|
||||
- После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд.
|
||||
- Один физический канал переиспользуют DM, настройки и blockchain.
|
||||
- Один физический канал переиспользуют доставка DM и blockchain.
|
||||
|
||||
### 5.2 Доставка новых данных (push)
|
||||
|
||||
@@ -208,7 +206,7 @@ Full resync запускается только тогда, когда:
|
||||
|
||||
- При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов.
|
||||
- Сервер с более полной историей досылает недостающее партнёру.
|
||||
- DM time-cursor удалён: новый peer получает историю после сброса `dm_sync_outbox.synced=false`.
|
||||
- DM-history backfill между access-серверами отсутствует.
|
||||
|
||||
### 5.4 Разрешение конфликтов
|
||||
|
||||
@@ -222,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 учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке.
|
||||
|
||||
@@ -251,15 +249,14 @@ Full resync запускается только тогда, когда:
|
||||
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
|
||||
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
|
||||
| Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` |
|
||||
| Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано |
|
||||
| Асинхронная доставка DM на единственный access-сервер получателя | ✅ Реализовано |
|
||||
| Retry DM до 1 часа + UI-state | ✅ Реализовано |
|
||||
| Репликация DM на второй access-сервер по `synced` | ✅ Реализовано |
|
||||
| Read-only `GetDmDeliveryStatus` | ✅ Реализовано |
|
||||
| Репликация 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 | Нужна реализация |
|
||||
|
||||
Текущая версия сервера использует постоянный WSS-пул для существующих
|
||||
|
||||
@@ -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,138 +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, локально сразу считается синхронизированным, чтобы не образовалась петля.
|
||||
|
||||
`sourceServerLogin` является единственным признаком межсерверного вызова для этих операций: если поле пустое, запрос считается клиентским и его запись не должна сразу переводиться в `synced=true`.
|
||||
|
||||
Синхронизация настроек и 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 остаётся отдельным
|
||||
механизмом и продолжает работать.
|
||||
|
||||
@@ -1,684 +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`. Для `ReceiveOutcomingMessage` и `SendMessagePair` пустой `sourceServerLogin` означает клиентский вызов, непустой - peer-вызов. Успешный ответ сохраняет старые поля `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 выполняется одним периодическим процессом и через
|
||||
один логический последовательный сеанс поверх постоянного WSS-пула. Закрытие
|
||||
этого логического сеанса не закрывает физическое соединение с 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` без времени.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 48 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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) {
|
||||
|
||||
@@ -200,17 +200,41 @@ 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',
|
||||
@@ -1175,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;
|
||||
@@ -1204,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) {
|
||||
@@ -1221,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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export const SHINE_CONNECTIONS_LOGO_SRC = '/assets/SHiNE_connections_blue.svg';
|
||||
export const SHINE_CONNECTIONS_LOGO_SRC = '/assets/SHiNE_connections_blue.svg?v=2026082601';
|
||||
|
||||
export function createShineConnectionsLogo({ className = '' } = {}) {
|
||||
const img = document.createElement('img');
|
||||
|
||||
@@ -83,6 +83,10 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
</span>
|
||||
</span>
|
||||
`;
|
||||
} else if (isNetwork) {
|
||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||
btn.setAttribute('aria-label', item.label);
|
||||
btn.title = item.label;
|
||||
} else {
|
||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { resolveShineServerByServerLogin } from '../services/shine-server-resolv
|
||||
import { readShineUserPda, updateShineUserPdaOnSolana } from '../services/shine-user-pda-service.js';
|
||||
import { getTopupSiteUrl } from '../services/solana-wallet-service.js';
|
||||
|
||||
export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' };
|
||||
const MAX_ACCESS_SERVERS = 2;
|
||||
export const pageMeta = { id: 'access-servers-view', title: 'Сервер доступа' };
|
||||
const MAX_ACCESS_SERVERS = 1;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
@@ -281,9 +281,10 @@ export function render({ navigate }) {
|
||||
introCard.innerHTML = `
|
||||
<p class="field-label">Где хранятся личные данные</p>
|
||||
<p class="meta-muted">
|
||||
Серверы доступа хранят зашифрованную личную переписку пользователя и участвуют в звонках.
|
||||
Единственный сервер доступа хранит зашифрованную личную переписку пользователя и участвует в звонках.
|
||||
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
|
||||
а доступ пользователя и приватная переписка хранятся только на этих серверах.
|
||||
а настройки и приватная переписка хранятся только на выбранном сервере.
|
||||
При смене сервера прежняя переписка и настройки автоматически не переносятся.
|
||||
</p>
|
||||
`;
|
||||
|
||||
@@ -291,7 +292,7 @@ export function render({ navigate }) {
|
||||
listCard.className = 'card stack';
|
||||
const listTitle = document.createElement('p');
|
||||
listTitle.className = 'field-label';
|
||||
listTitle.textContent = 'Текущий список серверов доступа';
|
||||
listTitle.textContent = 'Текущий сервер доступа';
|
||||
const listHint = document.createElement('p');
|
||||
listHint.className = 'meta-muted';
|
||||
listHint.textContent = sessionLogin
|
||||
@@ -308,10 +309,10 @@ export function render({ navigate }) {
|
||||
addCard.className = 'card stack';
|
||||
const addTitle = document.createElement('p');
|
||||
addTitle.className = 'field-label';
|
||||
addTitle.textContent = 'Добавить сервер доступа';
|
||||
addTitle.textContent = 'Сменить сервер доступа';
|
||||
const addHint = document.createElement('p');
|
||||
addHint.className = 'meta-muted';
|
||||
addHint.textContent = 'Можно использовать максимум два сервера доступа. Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.';
|
||||
addHint.textContent = 'Введите логин нового сервера или несколько первых букв, затем выберите сервер из подсказок. Новый сервер заменит текущий.';
|
||||
const addInput = document.createElement('input');
|
||||
addInput.className = 'input';
|
||||
addInput.type = 'text';
|
||||
@@ -326,13 +327,12 @@ export function render({ navigate }) {
|
||||
const addButton = document.createElement('button');
|
||||
addButton.className = 'primary-btn';
|
||||
addButton.type = 'button';
|
||||
addButton.textContent = 'Добавить сервер';
|
||||
addButton.textContent = 'Сменить сервер';
|
||||
addButton.disabled = true;
|
||||
addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton);
|
||||
|
||||
const refreshAddButton = () => {
|
||||
addButton.disabled = operationBusy
|
||||
|| currentAccessServers.length >= MAX_ACCESS_SERVERS
|
||||
|| (!selectedCandidate && !normalizeLogin(addInput.value));
|
||||
};
|
||||
|
||||
@@ -352,9 +352,7 @@ export function render({ navigate }) {
|
||||
addInput.value = candidate.login;
|
||||
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
|
||||
} else {
|
||||
addStatus.textContent = currentAccessServers.length >= MAX_ACCESS_SERVERS
|
||||
? 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.'
|
||||
: 'Для изменения списка понадобится подпись root key.';
|
||||
addStatus.textContent = 'Для смены сервера понадобится подпись root key.';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -368,69 +366,15 @@ export function render({ navigate }) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentAccessServers.forEach((server, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'text-btn';
|
||||
button.type = 'button';
|
||||
button.disabled = operationBusy;
|
||||
button.innerHTML = `
|
||||
currentAccessServers.forEach((server) => {
|
||||
const row = document.createElement('div');
|
||||
row.innerHTML = `
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>@${escapeHtml(server.login)}</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(server.url || 'URL не указан')}</span>
|
||||
</span>
|
||||
`;
|
||||
button.addEventListener('click', () => {
|
||||
if (operationBusy) return;
|
||||
const isLast = currentAccessServers.length <= 1;
|
||||
const performDisable = async () => {
|
||||
const nextList = currentAccessServers
|
||||
.map((item) => item.login)
|
||||
.filter((login) => login !== server.login);
|
||||
try {
|
||||
await updateAccessServers(nextList, {
|
||||
statusTarget: listStatus,
|
||||
successText: `Сервер доступа @${server.login} отключён.`,
|
||||
inFlightText: `Обновляем PDA и отключаем сервер @${server.login}...`,
|
||||
});
|
||||
} catch {
|
||||
// Сообщение уже показано в статусе.
|
||||
}
|
||||
};
|
||||
|
||||
const askFinal = (noteText = '') => {
|
||||
confirmModal?.open({
|
||||
title: isLast ? 'Последний сервер доступа' : 'Отключить сервер доступа?',
|
||||
text: isLast
|
||||
? 'Это последний сервер доступа пользователя. Если его отключить, личная переписка пользователя на серверах доступа будет удалена.'
|
||||
: `Хотите изменить запись в блокчейне Solana и отключить сервер доступа @${server.login}?`,
|
||||
note: noteText,
|
||||
confirmLabel: isLast ? 'Понимаю' : 'Да',
|
||||
cancelLabel: isLast ? 'Отмена' : 'Нет',
|
||||
onConfirm: performDisable,
|
||||
});
|
||||
};
|
||||
|
||||
if (isLast) {
|
||||
confirmModal?.open({
|
||||
title: 'Отключить последний сервер?',
|
||||
text: `У пользователя остался только один сервер доступа: @${server.login}.`,
|
||||
note: 'После отключения последнего сервера доступа личная переписка пользователя на серверах доступа будет удалена.',
|
||||
confirmLabel: 'Продолжить',
|
||||
cancelLabel: 'Отмена',
|
||||
onConfirm: () => askFinal('Это повторное предупреждение перед записью нового списка серверов в Solana PDA.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
askFinal();
|
||||
});
|
||||
listBody.append(button);
|
||||
if (index < currentAccessServers.length - 1) {
|
||||
const divider = document.createElement('div');
|
||||
divider.style.height = '1px';
|
||||
divider.style.background = 'rgba(255,255,255,0.08)';
|
||||
listBody.append(divider);
|
||||
}
|
||||
listBody.append(row);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -465,16 +409,13 @@ export function render({ navigate }) {
|
||||
renderServerList();
|
||||
refreshAddButton();
|
||||
listStatus.textContent = rows.length
|
||||
? `Найдено серверов доступа: ${rows.length}`
|
||||
? 'Сервер доступа загружен из PDA.'
|
||||
: 'В PDA пользователя пока нет серверов доступа.';
|
||||
if (rows.length >= MAX_ACCESS_SERVERS) {
|
||||
addStatus.textContent = 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.';
|
||||
}
|
||||
} catch (error) {
|
||||
currentAccessServers = [];
|
||||
renderServerList();
|
||||
refreshAddButton();
|
||||
listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.';
|
||||
listStatus.textContent = error?.message || 'Не удалось прочитать сервер доступа.';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -559,7 +500,7 @@ export function render({ navigate }) {
|
||||
|
||||
const passwordResult = await passwordModal?.open({
|
||||
title: 'Нужен пароль для обновления серверов доступа',
|
||||
text: 'Чтобы изменить список серверов доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
text: 'Чтобы изменить сервер доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
note: savedClient
|
||||
? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.'
|
||||
: 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.',
|
||||
@@ -604,7 +545,7 @@ export function render({ navigate }) {
|
||||
|
||||
const normalizedList = uniqueLogins(nextLogins);
|
||||
setOperationBusy(true);
|
||||
target.textContent = String(inFlightText || 'Обновляем список серверов доступа...');
|
||||
target.textContent = String(inFlightText || 'Обновляем сервер доступа...');
|
||||
let signingMaterial = null;
|
||||
try {
|
||||
const currentPda = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
|
||||
@@ -626,7 +567,7 @@ export function render({ navigate }) {
|
||||
if (isInsufficientFundsForRentError(error)) {
|
||||
showTopupRequiredStatus(target, signingMaterial?.clientAddress);
|
||||
} else {
|
||||
target.textContent = error?.message || 'Не удалось обновить список серверов доступа.';
|
||||
target.textContent = error?.message || 'Не удалось обновить сервер доступа.';
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -662,29 +603,25 @@ export function render({ navigate }) {
|
||||
return;
|
||||
}
|
||||
if (currentAccessServers.some((item) => item.login === login)) {
|
||||
addStatus.textContent = `Сервер @${login} уже есть в списке.`;
|
||||
return;
|
||||
}
|
||||
if (currentAccessServers.length >= MAX_ACCESS_SERVERS) {
|
||||
addStatus.textContent = 'Нельзя добавить больше двух серверов доступа. Сначала отключите один из текущих.';
|
||||
addStatus.textContent = `Сервер @${login} уже выбран.`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
||||
confirmModal?.open({
|
||||
title: 'Добавить сервер доступа?',
|
||||
text: `Вы хотите добавить сервер доступа @${resolved.serverLogin}?`,
|
||||
title: 'Сменить сервер доступа?',
|
||||
text: `Заменить текущий сервер доступа на @${resolved.serverLogin}?`,
|
||||
note: resolved.httpBase
|
||||
? `Адрес сервера: ${resolved.httpBase}\nИзменение будет записано в Solana user PDA.`
|
||||
: 'Изменение будет записано в Solana user PDA.',
|
||||
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.`
|
||||
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.',
|
||||
onConfirm: async () => {
|
||||
const nextList = uniqueLogins([...currentAccessServers.map((item) => item.login), resolved.serverLogin]);
|
||||
const nextList = [resolved.serverLogin];
|
||||
try {
|
||||
await updateAccessServers(nextList, {
|
||||
statusTarget: addStatus,
|
||||
successText: `Сервер доступа @${resolved.serverLogin} добавлен.`,
|
||||
inFlightText: `Обновляем PDA и добавляем сервер @${resolved.serverLogin}...`,
|
||||
successText: `Сервер доступа заменён на @${resolved.serverLogin}.`,
|
||||
inFlightText: `Обновляем PDA и меняем сервер на @${resolved.serverLogin}...`,
|
||||
});
|
||||
} catch {
|
||||
// Сообщение уже показано в статусе.
|
||||
@@ -698,7 +635,7 @@ export function render({ navigate }) {
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Серверы доступа',
|
||||
title: 'Сервер доступа',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
introCard,
|
||||
|
||||
@@ -44,12 +44,12 @@ function renderAvatarPreview(slot, avatar, title) {
|
||||
const wrap = renderAvatar({
|
||||
initials: label.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar,
|
||||
size: 'small',
|
||||
size: 'xl',
|
||||
className: 'channel-profile-avatar',
|
||||
title: label,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
wrap.style.setProperty('--channel-avatar-size', '104px');
|
||||
wrap.style.setProperty('--channel-avatar-size', '96px');
|
||||
slot.append(wrap);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +76,9 @@ export function render({ navigate }) {
|
||||
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
||||
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
|
||||
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
||||
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
|
||||
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
|
||||
<button class="text-btn" type="button" data-action="help">Справка</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="upload-menu">Добавить файл</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="clear">Очистить историю</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="help">Справка</button>
|
||||
</div>
|
||||
`;
|
||||
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
||||
|
||||
@@ -62,7 +62,7 @@ function createThreadAvatar(login) {
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
title,
|
||||
});
|
||||
@@ -77,7 +77,7 @@ function createThreadAvatar(login) {
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
title,
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ function createMessageAvatar(login) {
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
title,
|
||||
});
|
||||
@@ -101,7 +101,7 @@ function createMessageAvatar(login) {
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
size: 'sm',
|
||||
className: 'channel-message-avatar',
|
||||
title,
|
||||
});
|
||||
@@ -577,13 +577,13 @@ function getStatusActionOptionsForTarget(targetMsgSubType) {
|
||||
}
|
||||
}
|
||||
|
||||
function createChannelAvatarElement(channel, size = 72) {
|
||||
function createChannelAvatarElement(channel, size = 96) {
|
||||
const txId = String(channel?.avaAr || '').trim();
|
||||
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
|
||||
const wrap = renderAvatar({
|
||||
initials: title.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: txId ? { ar: txId } : null,
|
||||
size: 'small',
|
||||
size: 'xl',
|
||||
className: 'channel-profile-avatar',
|
||||
title,
|
||||
alt: 'Аватар канала',
|
||||
@@ -626,7 +626,7 @@ function openChannelMetaDetailsModal({
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.querySelector('#about-channel-avatar-slot')?.append(createChannelAvatarElement(channel, 86));
|
||||
root.querySelector('#about-channel-avatar-slot')?.append(createChannelAvatarElement(channel, 96));
|
||||
|
||||
root.querySelector('#about-channel-close')?.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
|
||||
@@ -15,6 +15,7 @@ import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
import { makeShineChannelShortRoute } from '../services/shine-routes.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
@@ -31,6 +32,19 @@ const CHANNELS_VIEW_ALL = 'all';
|
||||
const CHANNELS_VIEW_OWNED = 'owned';
|
||||
const CHANNELS_VIEW_FOLLOWING = 'following';
|
||||
|
||||
function channelMenuIcon(name) {
|
||||
const paths = {
|
||||
search: '<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>',
|
||||
add: '<path d="M12 5v14M5 12h14"/>',
|
||||
all: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><rect x="14" y="14" width="6" height="6" rx="1"/>',
|
||||
mine: '<circle cx="12" cy="8" r="4"/><path d="M5 21a7 7 0 0 1 14 0"/>',
|
||||
following: '<path d="M12 21s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 11c0 5.6-7 10-7 10Z"/>',
|
||||
subscribe: '<path d="M12 3a6 6 0 0 0-6 6c0 7-3 7-3 9h18c0-2-3-2-3-9a6 6 0 0 0-6-6Z"/><path d="M10 21h4"/>',
|
||||
notifications: '<path d="M12 3a6 6 0 0 0-6 6c0 7-3 7-3 9h18c0-2-3-2-3-9a6 6 0 0 0-6-6Z"/><path d="M10 21h4"/>',
|
||||
};
|
||||
return `<svg class="channel-menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ''}</svg>`;
|
||||
}
|
||||
|
||||
function cleanChannelMessagePreview(text) {
|
||||
const parsed = parseMessageAttachments(text);
|
||||
const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
|
||||
@@ -94,7 +108,7 @@ function createChannelAvatar(channel = {}) {
|
||||
return renderAvatar({
|
||||
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
|
||||
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
|
||||
size: 'small',
|
||||
size: 'lg',
|
||||
title: channel.displayTitle || channel.title || channel.name || 'Канал',
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
@@ -952,6 +966,7 @@ function openTopChannelsMenu({
|
||||
onFindChannel,
|
||||
}) {
|
||||
closeTopChannelsMenu(listState);
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || !anchorEl) return;
|
||||
|
||||
@@ -961,7 +976,9 @@ function openTopChannelsMenu({
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||
|
||||
const estimatedHeight = 250;
|
||||
let top = rect.bottom + 8;
|
||||
const titleAnchor = document.querySelector('.channels-filter-title');
|
||||
const titleRect = titleAnchor?.getBoundingClientRect?.();
|
||||
let top = (titleRect?.bottom || rect.bottom) + 7;
|
||||
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||
}
|
||||
@@ -977,12 +994,8 @@ function openTopChannelsMenu({
|
||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||
|
||||
const items = [
|
||||
{ label: 'Поиск', action: () => onFindChannel?.() },
|
||||
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
||||
{ divider: true },
|
||||
{ label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
||||
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||
{ label: 'Найти канал', icon: 'search', action: () => onFindChannel?.() },
|
||||
{ label: 'Новый канал', icon: 'add', action: () => navigate('add-channel-view') },
|
||||
];
|
||||
|
||||
items.forEach((item) => {
|
||||
@@ -998,7 +1011,7 @@ function openTopChannelsMenu({
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'channel-menu-item';
|
||||
btn.textContent = item.label;
|
||||
btn.innerHTML = `${channelMenuIcon(item.icon)}<span>${item.label}</span>`;
|
||||
btn.addEventListener('click', () => {
|
||||
closeTopChannelsMenu(listState);
|
||||
item.action?.();
|
||||
@@ -1012,13 +1025,19 @@ function openTopChannelsMenu({
|
||||
const onOverlayClick = (event) => {
|
||||
if (event.target === overlay) closeTopChannelsMenu(listState);
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (event?.detail?.owner === anchorEl) return;
|
||||
closeTopChannelsMenu(listState);
|
||||
};
|
||||
const onWindowResize = () => closeTopChannelsMenu(listState);
|
||||
|
||||
overlay.addEventListener('click', onOverlayClick);
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
listState.topMenuCleanup = () => {
|
||||
overlay.removeEventListener('click', onOverlayClick);
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1054,8 +1073,11 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi
|
||||
actionBtn.type = 'button';
|
||||
actionBtn.className = `channel-menu-item ${channel.isSubscribed ? 'destructive' : ''}`.trim();
|
||||
|
||||
const actionLabel = document.createElement('span');
|
||||
actionBtn.append(document.createRange().createContextualFragment(channelMenuIcon('subscribe')), actionLabel);
|
||||
|
||||
if (canToggleSubscription) {
|
||||
actionBtn.textContent = channel.pending
|
||||
actionLabel.textContent = channel.pending
|
||||
? 'Выполняется...'
|
||||
: channel.isSubscribed
|
||||
? 'Отписаться'
|
||||
@@ -1075,7 +1097,7 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi
|
||||
|
||||
channel.pending = true;
|
||||
actionBtn.disabled = true;
|
||||
actionBtn.textContent = 'Выполняется...';
|
||||
actionLabel.textContent = 'Выполняется...';
|
||||
|
||||
const nextSubscribed = !channel.isSubscribed;
|
||||
try {
|
||||
@@ -1097,13 +1119,13 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi
|
||||
} catch (error) {
|
||||
channel.pending = false;
|
||||
actionBtn.disabled = false;
|
||||
actionBtn.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться';
|
||||
actionLabel.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться';
|
||||
showToast(toUserMessage(error, 'Не удалось изменить подписку.'), { kind: 'error' });
|
||||
rerenderList();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
actionBtn.textContent = 'Собственный канал';
|
||||
actionLabel.textContent = 'Собственный канал';
|
||||
actionBtn.disabled = true;
|
||||
}
|
||||
|
||||
@@ -1111,7 +1133,8 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi
|
||||
toggleWrap.className = 'channel-menu-toggle';
|
||||
|
||||
const toggleLabel = document.createElement('span');
|
||||
toggleLabel.textContent = 'Уведомления';
|
||||
toggleLabel.className = 'channel-menu-toggle-label';
|
||||
toggleLabel.innerHTML = `${channelMenuIcon('notifications')}<span>Уведомления</span>`;
|
||||
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
@@ -1174,16 +1197,24 @@ function renderChannelMain(channel) {
|
||||
main.append(desc);
|
||||
}
|
||||
|
||||
const previewLine = document.createElement('div');
|
||||
previewLine.className = 'channel-row-preview-line';
|
||||
|
||||
const preview = document.createElement('p');
|
||||
preview.className = 'channel-row-message';
|
||||
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'channel-row-time';
|
||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
||||
previewLine.append(preview, time);
|
||||
|
||||
const meta = document.createElement('p');
|
||||
meta.className = 'channel-row-owner channel-counter-meta';
|
||||
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
||||
|
||||
main.prepend(title, technical);
|
||||
main.append(preview, meta);
|
||||
main.append(previewLine, meta);
|
||||
return main;
|
||||
}
|
||||
|
||||
@@ -1220,10 +1251,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'channel-row-controls';
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'channel-row-time';
|
||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
||||
|
||||
const count = document.createElement('span');
|
||||
count.className = 'unread channel-row-count';
|
||||
const unreadCount = Number(channel.unreadCount || 0);
|
||||
@@ -1231,7 +1258,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||
count.classList.toggle('is-empty', unreadCount <= 0);
|
||||
|
||||
controls.append(time, count);
|
||||
controls.append(count);
|
||||
|
||||
row.append(avatar, main, controls);
|
||||
row.addEventListener('click', () => {
|
||||
@@ -1343,8 +1370,21 @@ export function render({ navigate, route, chrome }) {
|
||||
const topBarLeft = document.createElement('div');
|
||||
topBarLeft.className = 'channels-top-left';
|
||||
|
||||
const topTitle = document.createElement('strong');
|
||||
topTitle.className = 'channels-top-title';
|
||||
const topTitle = document.createElement('button');
|
||||
topTitle.type = 'button';
|
||||
topTitle.className = 'channels-top-title channels-filter-title';
|
||||
|
||||
const channelsFilterMenu = createDropdownMenu({
|
||||
anchorEl: topTitle,
|
||||
align: 'left',
|
||||
leftShift: 72,
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Все каналы', iconHtml: channelMenuIcon('all'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
||||
{ label: 'Мои каналы', iconHtml: channelMenuIcon('mine'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||
{ label: 'Подписки', iconHtml: channelMenuIcon('following'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||
],
|
||||
});
|
||||
|
||||
const topBarRight = document.createElement('div');
|
||||
topBarRight.className = 'channels-top-right';
|
||||
@@ -1411,6 +1451,7 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.cleanup = () => {
|
||||
closeChannelMenu(listState);
|
||||
closeTopChannelsMenu(listState);
|
||||
channelsFilterMenu.destroy();
|
||||
appScreen?.classList.remove('channels-scroll-clean');
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import {
|
||||
addAppLogEntry,
|
||||
@@ -36,6 +35,72 @@ import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-route
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
|
||||
function menuIconSvg(name) {
|
||||
const paths = {
|
||||
reply: '<path d="M9 7 4 12l5 5"/><path d="M5 12h8a6 6 0 0 1 6 6"/>',
|
||||
copy: '<rect x="8" y="8" width="10" height="10" rx="2"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"/>',
|
||||
read: '<path d="M5 9v6h4l5 4V5L9 9H5Z"/><path d="M17 9a4 4 0 0 1 0 6"/>',
|
||||
edit: '<path d="m4 20 4.4-1 9.9-9.9a2.1 2.1 0 0 0-3-3L5.4 16 4 20Z"/><path d="m13.8 7.2 3 3"/>',
|
||||
delete: '<path d="M4 7h16"/><path d="M9 7V4h6v3"/><path d="m7 7 1 13h8l1-13"/>',
|
||||
call: '<path d="M7.6 10.8a15 15 0 0 0 5.6 5.6l1.9-1.9a1 1 0 0 1 1-.2c1.1.4 2.3.6 3.5.6a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1C10.6 21 3 13.4 3 4a1 1 0 0 1 1-1h3.4a1 1 0 0 1 1 1c0 1.2.2 2.4.6 3.5a1 1 0 0 1-.3 1l-1.1 1.3Z"/>',
|
||||
video: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3"/>',
|
||||
clear: '<path d="M4 7h16"/><path d="M9 7V4h6v3"/><path d="m7 7 1 13h8l1-13"/>',
|
||||
};
|
||||
return `<svg class="dm-menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ''}</svg>`;
|
||||
}
|
||||
|
||||
function openUserIdentityMenu({ anchorEl, login, navigate }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root || !anchorEl) return;
|
||||
const cleanLogin = String(login || '').trim();
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer dm-user-menu-layer" id="chat-user-menu-layer">
|
||||
<div class="dm-head-menu dm-head-menu--portal dm-user-identity-menu" role="menu">
|
||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="connections">
|
||||
<img class="dm-menu-image-icon" src="/assets/SHiNE_connections_blue.svg" alt="" aria-hidden="true" />
|
||||
<span>Связи</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="profile">
|
||||
<img class="dm-menu-image-icon" src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
<span>Профиль</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const layer = root.querySelector('#chat-user-menu-layer');
|
||||
const menu = root.querySelector('.dm-user-identity-menu');
|
||||
const close = () => {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
root.innerHTML = '';
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key === 'Escape') close();
|
||||
};
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!menu) return;
|
||||
const width = menu.offsetWidth || 190;
|
||||
const left = Math.max(10, Math.min(window.innerWidth - width - 10, rect.left));
|
||||
menu.style.left = `${Math.round(left)}px`;
|
||||
menu.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||
});
|
||||
|
||||
layer?.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === layer) close();
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
root.querySelector('[data-user-action="connections"]')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate(makeProfileLinksRoute(cleanLogin));
|
||||
});
|
||||
root.querySelector('[data-user-action="profile"]')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate(makeProfileRoute(cleanLogin));
|
||||
});
|
||||
}
|
||||
|
||||
function createChatHeaderParts(login, navigate) {
|
||||
const cleanLogin = String(login || '').trim() || 'unknown';
|
||||
|
||||
@@ -43,7 +108,7 @@ function createChatHeaderParts(login, navigate) {
|
||||
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
|
||||
const initialAvatar = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
size: 'small',
|
||||
size: 'md',
|
||||
className: 'chat-header-avatar',
|
||||
title: cleanLogin,
|
||||
});
|
||||
@@ -52,12 +117,13 @@ function createChatHeaderParts(login, navigate) {
|
||||
const avatarButton = document.createElement('button');
|
||||
avatarButton.type = 'button';
|
||||
avatarButton.className = 'chat-header-avatar-btn';
|
||||
avatarButton.title = `Профиль ${cleanLogin}`;
|
||||
avatarButton.setAttribute('aria-label', `Открыть профиль ${cleanLogin}`);
|
||||
avatarButton.title = `Меню ${cleanLogin}`;
|
||||
avatarButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
|
||||
avatarButton.append(avatarSlot);
|
||||
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'chat-header-login';
|
||||
const loginEl = document.createElement('button');
|
||||
loginEl.type = 'button';
|
||||
loginEl.className = 'chat-header-login chat-header-login-btn';
|
||||
loginEl.setAttribute('role', 'heading');
|
||||
loginEl.setAttribute('aria-level', '1');
|
||||
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
|
||||
@@ -76,7 +142,7 @@ function createChatHeaderParts(login, navigate) {
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
size: 'md',
|
||||
className: 'chat-header-avatar',
|
||||
title: cleanLogin,
|
||||
});
|
||||
@@ -84,23 +150,14 @@ function createChatHeaderParts(login, navigate) {
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const connectionsButton = document.createElement('button');
|
||||
connectionsButton.type = 'button';
|
||||
connectionsButton.className = 'icon-btn chat-header-icon-btn chat-header-connections-btn';
|
||||
connectionsButton.title = `Связи ${cleanLogin}`;
|
||||
connectionsButton.setAttribute('aria-label', `Открыть связи ${cleanLogin}`);
|
||||
connectionsButton.append(createShineConnectionsLogo({ className: 'chat-header-connections-logo' }));
|
||||
connectionsButton.addEventListener('click', () => {
|
||||
const openMenu = (event) => {
|
||||
if (!cleanLogin || cleanLogin === 'unknown') return;
|
||||
navigate(makeProfileLinksRoute(cleanLogin));
|
||||
});
|
||||
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate });
|
||||
};
|
||||
avatarButton.addEventListener('click', openMenu);
|
||||
loginEl.addEventListener('click', openMenu);
|
||||
|
||||
avatarButton.addEventListener('click', () => {
|
||||
if (!cleanLogin || cleanLogin === 'unknown') return;
|
||||
navigate(makeProfileRoute(cleanLogin));
|
||||
});
|
||||
|
||||
return { centerNode: loginEl, avatarButton, connectionsButton };
|
||||
return { centerNode: loginEl, avatarButton };
|
||||
}
|
||||
|
||||
function truncatePreviewText(value, maxLen = 72) {
|
||||
@@ -232,11 +289,11 @@ function openMessageActionsMenu({
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
|
||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||
${canReply ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-reply">Ответить</button>' : ''}
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Скопировать как текст</button>
|
||||
${showReadAloud ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>' : ''}
|
||||
${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''}
|
||||
${canDelete ? '<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">Удалить</button>' : ''}
|
||||
${canReply ? `<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-reply">${menuIconSvg('reply')}<span>Ответить</span></button>` : ''}
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">${menuIconSvg('copy')}<span>Скопировать как текст</span></button>
|
||||
${showReadAloud ? `<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">${menuIconSvg('read')}<span>Прочесть</span></button>` : ''}
|
||||
${canEdit ? `<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">${menuIconSvg('edit')}<span>Изменить</span></button>` : ''}
|
||||
${canDelete ? `<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">${menuIconSvg('delete')}<span>Удалить</span></button>` : ''}
|
||||
${String(infoText || '').trim() ? `<div class="meta-muted">${String(infoText || '').trim()}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,10 +385,10 @@ function openChatActionsMenu({
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
|
||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Звонок</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">Видеозвонок</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
|
||||
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">${menuIconSvg('call')}<span>Звонок</span></button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">${menuIconSvg('video')}<span>Видеозвонок</span></button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">${menuIconSvg('clear')}<span>Очистить историю</span></button>
|
||||
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">${menuIconSvg('delete')}<span>Удалить чат</span></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1083,7 +1140,7 @@ export function render({ navigate, route, chrome }) {
|
||||
],
|
||||
});
|
||||
const chatHeaderLeft = chatHeader.querySelector('.header-left');
|
||||
chatHeaderLeft?.append(chatHeaderParts.connectionsButton, chatHeaderParts.avatarButton);
|
||||
chatHeaderLeft?.append(chatHeaderParts.avatarButton);
|
||||
chrome?.setTopbar(chatHeader);
|
||||
|
||||
if (!isKnownContact) {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function render({ navigate }) {
|
||||
state.deviceConnect.blockchain = blockchainToggle.checked;
|
||||
});
|
||||
deviceToggle.addEventListener('change', () => {
|
||||
state.deviceConnect.device = true;
|
||||
state.deviceConnect.client = true;
|
||||
deviceToggle.checked = true;
|
||||
});
|
||||
|
||||
@@ -100,22 +100,22 @@ export function render({ navigate }) {
|
||||
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
||||
const hasRoot = Boolean(savedKeys.rootKey);
|
||||
const hasBlockchain = Boolean(savedKeys.blockchainKey);
|
||||
const hasDevice = Boolean(savedKeys.clientKey);
|
||||
const hasClient = Boolean(savedKeys.clientKey);
|
||||
|
||||
rootToggle.disabled = !hasRoot;
|
||||
blockchainToggle.disabled = !hasBlockchain;
|
||||
deviceToggle.disabled = true;
|
||||
state.deviceConnect.root = hasRoot && rootToggle.checked;
|
||||
state.deviceConnect.blockchain = hasBlockchain && blockchainToggle.checked;
|
||||
state.deviceConnect.device = hasDevice;
|
||||
state.deviceConnect.client = hasClient;
|
||||
rootToggle.checked = state.deviceConnect.root;
|
||||
blockchainToggle.checked = state.deviceConnect.blockchain;
|
||||
deviceToggle.checked = hasDevice;
|
||||
openQrBtn.disabled = !hasDevice;
|
||||
openPairBtn.disabled = !hasDevice;
|
||||
deviceToggle.checked = hasClient;
|
||||
openQrBtn.disabled = !hasClient;
|
||||
openPairBtn.disabled = !hasClient;
|
||||
|
||||
const available = [
|
||||
hasDevice ? 'device' : '',
|
||||
hasClient ? 'client' : '',
|
||||
hasBlockchain ? 'blockchain' : '',
|
||||
hasRoot ? 'root' : '',
|
||||
].filter(Boolean);
|
||||
@@ -128,7 +128,7 @@ export function render({ navigate }) {
|
||||
deviceToggle.checked = false;
|
||||
state.deviceConnect.root = false;
|
||||
state.deviceConnect.blockchain = false;
|
||||
state.deviceConnect.device = false;
|
||||
state.deviceConnect.client = false;
|
||||
openQrBtn.disabled = true;
|
||||
openPairBtn.disabled = true;
|
||||
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
|
||||
|
||||
@@ -34,7 +34,7 @@ function createSearchAvatar(login) {
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
size: 'md',
|
||||
className: 'avatar',
|
||||
title,
|
||||
});
|
||||
@@ -49,7 +49,7 @@ function createSearchAvatar(login) {
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
size: 'md',
|
||||
className: 'avatar',
|
||||
title,
|
||||
});
|
||||
|
||||
@@ -260,15 +260,15 @@ export function render({ navigate }) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack settings-developer-card';
|
||||
card.innerHTML = `
|
||||
<button class="text-btn" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
|
||||
<button class="text-btn" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
||||
<button class="text-btn" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
||||
<button class="text-btn" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
|
||||
<button class="text-btn" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button>
|
||||
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
|
||||
<button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
||||
<button class="text-btn" type="button" id="settings-pwa-install">Как установить PWA</button>
|
||||
<button class="text-btn" type="button" id="settings-upload-avatar">Загрузить аватар</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-app-log">Лог приложения</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-install">Как установить PWA</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-upload-avatar">Загрузить аватар</button>
|
||||
`;
|
||||
|
||||
const appLogBtn = card.querySelector('#settings-app-log');
|
||||
|
||||
@@ -36,22 +36,20 @@ function pairingSessionKindLabel(sessionType) {
|
||||
return Number(sessionType || 0) === SESSION_TYPE_WALLET ? 'Wallet session' : 'Client session';
|
||||
}
|
||||
|
||||
function buildTransferKeys(savedKeys, { withExtras = false }) {
|
||||
function buildTransferKeys(savedKeys, selection = {}) {
|
||||
const keys = {
|
||||
clientKey: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
|
||||
clientKey: String(savedKeys?.clientKey || '').trim(),
|
||||
blockchainKey: '',
|
||||
rootKey: '',
|
||||
};
|
||||
if (!keys.clientKey) {
|
||||
throw new Error('На этом устройстве нет сохранённого client key для передачи.');
|
||||
}
|
||||
if (withExtras) {
|
||||
if (state.deviceConnect.blockchain && savedKeys?.blockchainKey) {
|
||||
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
|
||||
}
|
||||
if (state.deviceConnect.root && savedKeys?.rootKey) {
|
||||
keys.rootKey = String(savedKeys.rootKey || '').trim();
|
||||
}
|
||||
if (selection.blockchain && savedKeys?.blockchainKey) {
|
||||
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
|
||||
}
|
||||
if (selection.root && savedKeys?.rootKey) {
|
||||
keys.rootKey = String(savedKeys.rootKey || '').trim();
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -73,10 +71,9 @@ function requestCardHtml(request) {
|
||||
<span class="meta-muted">Истекает: ${expiresText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="flex-wrap:wrap;">
|
||||
<button class="ghost-btn" type="button" data-action="approve-device">${sessionOnly ? 'Подключить wallet-session' : 'Подключить без доп. ключей'}</button>
|
||||
${sessionOnly ? '' : '<button class="primary-btn" type="button" data-action="approve-full">Подключить и передать ключи</button>'}
|
||||
<button class="text-btn" type="button" data-action="reject">Отклонить</button>
|
||||
<div class="row pairing-request-actions" style="flex-wrap:wrap;">
|
||||
<button class="primary-btn pairing-approve-btn" type="button" data-action="approve-device">Подключить</button>
|
||||
<button class="text-btn pairing-reject-btn" type="button" data-action="reject">Отклонить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -179,6 +176,7 @@ export function render({ navigate }) {
|
||||
let settingsBusy = false;
|
||||
let pairingPasswordConfigured = false;
|
||||
let dialogMode = '';
|
||||
let pendingTransferRequest = null;
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
@@ -187,6 +185,38 @@ export function render({ navigate }) {
|
||||
}),
|
||||
);
|
||||
|
||||
const transferDialog = document.createElement('div');
|
||||
transferDialog.className = 'pairing-transfer-dialog';
|
||||
transferDialog.hidden = true;
|
||||
transferDialog.innerHTML = `
|
||||
<div class="pairing-transfer-dialog__backdrop" data-transfer-action="cancel"></div>
|
||||
<section class="card stack pairing-transfer-dialog__card" role="dialog" aria-modal="true" aria-labelledby="pairing-transfer-title">
|
||||
<div class="stack" style="gap:5px;">
|
||||
<h2 class="login-panel-title" id="pairing-transfer-title">Подключить и передать ключи</h2>
|
||||
<p class="meta-muted">Client key передаётся всегда. Дополнительные ключи можно передать только если они есть на этом устройстве.</p>
|
||||
</div>
|
||||
<div class="pairing-transfer-key-list">
|
||||
<label class="pairing-transfer-key is-required">
|
||||
<input type="checkbox" checked disabled />
|
||||
<span><strong>Client key</strong><small>Обязателен для клиентского устройства</small></span>
|
||||
</label>
|
||||
<label class="pairing-transfer-key" data-transfer-key="root">
|
||||
<input type="checkbox" id="pairing-transfer-root" checked />
|
||||
<span><strong>Root key</strong><small>Передать на новое устройство</small></span>
|
||||
</label>
|
||||
<label class="pairing-transfer-key" data-transfer-key="blockchain">
|
||||
<input type="checkbox" id="pairing-transfer-blockchain" checked />
|
||||
<span><strong>Blockchain key</strong><small>Передать на новое устройство</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row pairing-transfer-dialog__actions">
|
||||
<button class="ghost-btn" type="button" data-transfer-action="cancel">Отмена</button>
|
||||
<button class="primary-btn pairing-approve-btn" type="button" data-transfer-action="confirm">Подключить</button>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
screen.append(transferDialog);
|
||||
|
||||
const settingsCard = document.createElement('div');
|
||||
settingsCard.className = 'card stack';
|
||||
const passwordIcons = makePasswordToggleIcons();
|
||||
@@ -432,11 +462,11 @@ export function render({ navigate }) {
|
||||
const loadSavedKeys = async () => {
|
||||
savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
||||
const available = [];
|
||||
if (savedKeys?.clientKey || savedKeys?.clientKey) available.push('client');
|
||||
if (savedKeys?.blockchainKey && state.deviceConnect.blockchain) available.push('blockchain');
|
||||
if (savedKeys?.rootKey && state.deviceConnect.root) available.push('root');
|
||||
keySummaryEl.textContent = available.length
|
||||
? `При расширенном подключении будут переданы: ${available.join(', ')}.`
|
||||
if (savedKeys?.clientKey) available.push('client');
|
||||
if (savedKeys?.blockchainKey) available.push('blockchain');
|
||||
if (savedKeys?.rootKey) available.push('root');
|
||||
keySummaryEl.textContent = available.length > 1
|
||||
? `Доступны для подключения: ${available.join(', ')}.`
|
||||
: 'На этом устройстве доступен только client key.';
|
||||
};
|
||||
|
||||
@@ -460,10 +490,10 @@ export function render({ navigate }) {
|
||||
refreshBtn.disabled = flag;
|
||||
};
|
||||
|
||||
const approveRequest = async (request, mode) => {
|
||||
const withExtras = mode === 'with-extras';
|
||||
const approveRequest = async (request, selection = {}) => {
|
||||
const withExtras = !!selection.root || !!selection.blockchain;
|
||||
let payload;
|
||||
if (!withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
|
||||
if (Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
|
||||
const delegatedSession = await authService.createDelegatedSessionWithClientKey({
|
||||
login: state.session.login,
|
||||
clientPrivPkcs8: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
|
||||
@@ -477,7 +507,7 @@ export function render({ navigate }) {
|
||||
session: delegatedSession,
|
||||
});
|
||||
} else {
|
||||
const keys = buildTransferKeys(savedKeys, { withExtras });
|
||||
const keys = buildTransferKeys(savedKeys, selection);
|
||||
payload = buildSecretsPayload({
|
||||
login: state.session.login,
|
||||
keys,
|
||||
@@ -486,7 +516,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
|
||||
await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload));
|
||||
const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
|
||||
const sessionOnly = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
|
||||
showToast(
|
||||
withExtras
|
||||
? 'Ключи переданы на новое устройство'
|
||||
@@ -505,6 +535,53 @@ export function render({ navigate }) {
|
||||
await reloadRequests({ silent: true });
|
||||
};
|
||||
|
||||
const closeTransferDialog = () => {
|
||||
pendingTransferRequest = null;
|
||||
transferDialog.hidden = true;
|
||||
};
|
||||
|
||||
const openTransferDialog = (request) => {
|
||||
pendingTransferRequest = request;
|
||||
const rootRow = transferDialog.querySelector('[data-transfer-key="root"]');
|
||||
const blockchainRow = transferDialog.querySelector('[data-transfer-key="blockchain"]');
|
||||
const rootInput = transferDialog.querySelector('#pairing-transfer-root');
|
||||
const blockchainInput = transferDialog.querySelector('#pairing-transfer-blockchain');
|
||||
const hasRoot = !!String(savedKeys?.rootKey || '').trim();
|
||||
const hasBlockchain = !!String(savedKeys?.blockchainKey || '').trim();
|
||||
rootRow.hidden = !hasRoot;
|
||||
blockchainRow.hidden = !hasBlockchain;
|
||||
rootInput.checked = hasRoot;
|
||||
blockchainInput.checked = hasBlockchain;
|
||||
transferDialog.hidden = false;
|
||||
};
|
||||
|
||||
transferDialog.addEventListener('click', async (event) => {
|
||||
const target = event.target instanceof Element ? event.target.closest('[data-transfer-action]') : null;
|
||||
const action = String(target?.dataset?.transferAction || '');
|
||||
if (!action) return;
|
||||
if (action === 'cancel') {
|
||||
closeTransferDialog();
|
||||
return;
|
||||
}
|
||||
if (action !== 'confirm' || !pendingTransferRequest) return;
|
||||
const request = pendingTransferRequest;
|
||||
const confirmBtn = transferDialog.querySelector('[data-transfer-action="confirm"]');
|
||||
confirmBtn.disabled = true;
|
||||
try {
|
||||
await approveRequest(request, {
|
||||
root: !!transferDialog.querySelector('#pairing-transfer-root')?.checked,
|
||||
blockchain: !!transferDialog.querySelector('#pairing-transfer-blockchain')?.checked,
|
||||
});
|
||||
closeTransferDialog();
|
||||
} catch (error) {
|
||||
const message = toUserMessage(error, 'Не удалось подключить устройство.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message, 'error');
|
||||
} finally {
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
passwordDialog.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
@@ -609,9 +686,14 @@ export function render({ navigate }) {
|
||||
buttons.forEach((btn) => { btn.disabled = true; });
|
||||
try {
|
||||
if (action === 'approve-device') {
|
||||
await approveRequest(request, 'device-only');
|
||||
} else if (action === 'approve-full') {
|
||||
await approveRequest(request, 'with-extras');
|
||||
const isWallet = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
|
||||
const hasExtras = !!String(savedKeys?.rootKey || '').trim() || !!String(savedKeys?.blockchainKey || '').trim();
|
||||
if (!isWallet && hasExtras) {
|
||||
openTransferDialog(request);
|
||||
buttons.forEach((btn) => { btn.disabled = false; });
|
||||
return;
|
||||
}
|
||||
await approveRequest(request, {});
|
||||
} else if (action === 'reject') {
|
||||
await runPairingOpWithSessionRestore(() => authService.rejectTrustedDeviceLogin(pairingId, 'rejected_by_user'));
|
||||
showToast('Заявка отклонена', { kind: 'error' });
|
||||
|
||||
@@ -68,7 +68,7 @@ export function render({ navigate, route }) {
|
||||
`;
|
||||
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.className = 'text-btn';
|
||||
actionBtn.className = 'shine-btn shine-btn--settings';
|
||||
actionBtn.type = 'button';
|
||||
actionBtn.textContent = 'Завершить сеанс';
|
||||
|
||||
|
||||
@@ -54,11 +54,13 @@ export function render({ navigate }) {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card stack';
|
||||
actions.innerHTML = `
|
||||
<button class="primary-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||
<button class="ghost-btn" type="button" id="connect-device-btn">Подключить устройство</button>
|
||||
<button class="text-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="pair-by-code-btn">Подключить по коду</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="connect-device-btn">Другие способы подключения</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
||||
`;
|
||||
|
||||
actions.querySelector('#pair-by-code-btn').addEventListener('click', () => navigate('device-pairing-view'));
|
||||
actions.querySelector('#connect-device-btn').addEventListener('click', () => navigate('connect-device-view'));
|
||||
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
||||
|
||||
@@ -109,7 +111,7 @@ export function render({ navigate }) {
|
||||
currentMenu.append(createSessionItem(current, true));
|
||||
|
||||
const endCurrentSessionBtn = document.createElement('button');
|
||||
endCurrentSessionBtn.className = 'text-btn';
|
||||
endCurrentSessionBtn.className = 'shine-btn shine-btn--settings settings-bordered-btn';
|
||||
endCurrentSessionBtn.type = 'button';
|
||||
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
||||
endCurrentSessionBtn.addEventListener('click', async () => {
|
||||
|
||||
@@ -33,24 +33,6 @@ export function render({ navigate }) {
|
||||
const body = document.createElement('div');
|
||||
body.className = 'card stack';
|
||||
|
||||
const languageLabel = document.createElement('label');
|
||||
languageLabel.className = 'stack';
|
||||
languageLabel.innerHTML = `<span class="field-label">Язык</span>`;
|
||||
|
||||
const languageSelect = document.createElement('select');
|
||||
languageSelect.className = 'select';
|
||||
languageSelect.innerHTML = `
|
||||
<option value="ru">Русский</option>
|
||||
<option value="en">English</option>
|
||||
`;
|
||||
languageSelect.value = draft.language;
|
||||
languageSelect.addEventListener('change', () => {
|
||||
draft.language = languageSelect.value;
|
||||
});
|
||||
languageLabel.append(languageSelect);
|
||||
|
||||
body.append(languageLabel);
|
||||
|
||||
SERVER_FIELDS.forEach((field) => {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'stack';
|
||||
@@ -68,7 +50,7 @@ export function render({ navigate }) {
|
||||
controls.className = 'row wrap-row';
|
||||
|
||||
const checkButton = document.createElement('button');
|
||||
checkButton.className = 'ghost-btn server-check-btn';
|
||||
checkButton.className = 'shine-btn shine-btn--settings server-check-btn';
|
||||
checkButton.type = 'button';
|
||||
checkButton.textContent = 'Проверить';
|
||||
|
||||
@@ -150,7 +132,7 @@ export function render({ navigate }) {
|
||||
|
||||
if (isLocalDemoAvailable()) {
|
||||
const localDemoButton = document.createElement('button');
|
||||
localDemoButton.className = 'ghost-btn preauth-local-demo-btn';
|
||||
localDemoButton.className = 'shine-btn shine-btn--settings preauth-local-demo-btn';
|
||||
localDemoButton.type = 'button';
|
||||
localDemoButton.textContent = 'Открыть локальный тестовый режим';
|
||||
localDemoButton.addEventListener('click', () => {
|
||||
@@ -161,7 +143,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
const serverUiButton = document.createElement('button');
|
||||
serverUiButton.className = 'ghost-btn';
|
||||
serverUiButton.className = 'shine-btn shine-btn--settings';
|
||||
serverUiButton.type = 'button';
|
||||
serverUiButton.textContent = 'Настроить свой сервер';
|
||||
serverUiButton.addEventListener('click', () => {
|
||||
@@ -170,13 +152,13 @@ export function render({ navigate }) {
|
||||
});
|
||||
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.className = 'ghost-btn';
|
||||
cancelButton.className = 'shine-btn shine-btn--settings';
|
||||
cancelButton.type = 'button';
|
||||
cancelButton.textContent = 'Отмена';
|
||||
cancelButton.addEventListener('click', () => navigate('start-view'));
|
||||
|
||||
const saveButton = document.createElement('button');
|
||||
saveButton.className = 'primary-btn';
|
||||
saveButton.className = 'shine-btn shine-btn--primary';
|
||||
saveButton.type = 'button';
|
||||
saveButton.textContent = 'Сохранить';
|
||||
saveButton.addEventListener('click', async () => {
|
||||
|
||||
@@ -1,43 +1,70 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { state } from '../state.js';
|
||||
import { saveEntryLanguage, state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'language-view', title: 'Язык' };
|
||||
|
||||
function resolveReturnPage() {
|
||||
const stored = String(sessionStorage.getItem('shine-language-return-page') || '').trim();
|
||||
return stored === 'start-view' ? 'start-view' : 'settings-view';
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
screen.className = 'stack language-screen';
|
||||
const returnPage = resolveReturnPage();
|
||||
let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Язык',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
title: 'Язык / Language',
|
||||
leftAction: { label: '←', onClick: () => {
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
} },
|
||||
}),
|
||||
);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.className = 'card stack language-choice-card';
|
||||
card.innerHTML = `
|
||||
<label class="checkbox-row"><input type="radio" name="language" value="ru" ${state.entrySettings.language === 'ru' ? 'checked' : ''} /> Русский</label>
|
||||
<label class="checkbox-row"><input type="radio" name="language" value="en" ${state.entrySettings.language === 'en' ? 'checked' : ''} /> English</label>
|
||||
<p class="meta-muted language-choice-hint">Выберите язык интерфейса</p>
|
||||
<div class="language-segmented-control" role="radiogroup" aria-label="Язык интерфейса">
|
||||
<button class="language-segment${pendingLanguage === 'ru' ? ' is-selected' : ''}" type="button" data-language="ru" role="radio" aria-checked="${pendingLanguage === 'ru'}">Русский</button>
|
||||
<button class="language-segment${pendingLanguage === 'en' ? ' is-selected' : ''}" type="button" data-language="en" role="radio" aria-checked="${pendingLanguage === 'en'}">English</button>
|
||||
<span class="language-segmented-thumb" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="language-choice-actions">
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="cancel">Отмена</button>
|
||||
<button class="shine-btn shine-btn--primary" type="button" data-action="ok">OK</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
actions.innerHTML = `
|
||||
<button class="primary-btn" type="button" id="language-ok">ОК</button>
|
||||
<button class="ghost-btn" type="button" id="language-cancel">Отмена</button>
|
||||
`;
|
||||
const syncSelection = () => {
|
||||
card.dataset.language = pendingLanguage;
|
||||
card.querySelectorAll('[data-language]').forEach((item) => {
|
||||
const selected = item.dataset.language === pendingLanguage;
|
||||
item.classList.toggle('is-selected', selected);
|
||||
item.setAttribute('aria-checked', String(selected));
|
||||
});
|
||||
};
|
||||
syncSelection();
|
||||
|
||||
actions.querySelector('#language-ok').addEventListener('click', () => {
|
||||
const selected = card.querySelector('input[name="language"]:checked');
|
||||
if (selected) {
|
||||
state.entrySettings.language = selected.value;
|
||||
}
|
||||
navigate('settings-view');
|
||||
card.querySelectorAll('[data-language]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
pendingLanguage = button.dataset.language === 'en' ? 'en' : 'ru';
|
||||
syncSelection();
|
||||
});
|
||||
});
|
||||
card.querySelector('[data-action="cancel"]')?.addEventListener('click', () => {
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
});
|
||||
card.querySelector('[data-action="ok"]')?.addEventListener('click', () => {
|
||||
saveEntryLanguage(pendingLanguage);
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
});
|
||||
|
||||
actions.querySelector('#language-cancel').addEventListener('click', () => navigate('settings-view'));
|
||||
|
||||
screen.append(card, actions);
|
||||
screen.append(card);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
@@ -60,7 +59,7 @@ function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expi
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack auth-screen auth-screen--lower';
|
||||
screen.className = 'stack auth-screen auth-screen--other-device';
|
||||
let pollTimer = 0;
|
||||
let countdownTimer = 0;
|
||||
let activePairingId = '';
|
||||
@@ -70,34 +69,29 @@ export function render({ navigate }) {
|
||||
|
||||
clearAuthMessages();
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Войти через другое устройство',
|
||||
leftAction: { label: '←', onClick: () => navigate('login-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'login-panel login-panel--wide stack';
|
||||
panel.innerHTML = '<h1 class="login-panel-title">Войти через другое устройство</h1>';
|
||||
panel.innerHTML = `
|
||||
<button class="login-panel-inline-back" type="button" id="login-other-device-back" aria-label="Назад">
|
||||
<span aria-hidden="true">←</span>
|
||||
<span>Войти через другое устройство</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
panel.querySelector('#login-other-device-back')?.addEventListener('click', () => { void cancelActivePairingAndBack(); });
|
||||
|
||||
const formCard = document.createElement('div');
|
||||
formCard.className = 'card stack';
|
||||
formCard.className = 'card stack login-device-preparation';
|
||||
formCard.innerHTML = `
|
||||
<label class="stack">
|
||||
<span class="field-label">Введите логин</span>
|
||||
<input class="input" id="pair-login" type="text" autocomplete="username" placeholder="" value="" />
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" id="pair-use-password" />
|
||||
использовать доп. пароль
|
||||
</label>
|
||||
<label class="stack">
|
||||
<span class="field-label">Пароль подключения</span>
|
||||
<p class="auth-copy" id="pair-login-label"></p>
|
||||
<input id="pair-login" type="hidden" value="" />
|
||||
<input id="pair-use-password" type="checkbox" hidden />
|
||||
<label class="stack" id="pair-password-wrap" style="display:none;">
|
||||
<span class="field-label">Дополнительный пароль подключения</span>
|
||||
<input class="input" id="pair-password" type="password" autocomplete="current-password" placeholder="Пароль, заданный на другом устройстве" />
|
||||
</label>
|
||||
<button class="primary-btn" type="button" id="pair-start-btn">Получить код</button>
|
||||
<p class="meta-muted" id="pair-mode-hint">Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.</p>
|
||||
<button class="primary-btn" type="button" id="pair-start-btn" style="display:none;">Получить код</button>
|
||||
<p class="meta-muted" id="pair-mode-hint">Создаём код для входа…</p>
|
||||
`;
|
||||
|
||||
const status = document.createElement('p');
|
||||
@@ -110,10 +104,14 @@ export function render({ navigate }) {
|
||||
resultWrap.innerHTML = codeCardHtml();
|
||||
|
||||
const loginInput = formCard.querySelector('#pair-login');
|
||||
const loginLabelEl = formCard.querySelector('#pair-login-label');
|
||||
const usePasswordInput = formCard.querySelector('#pair-use-password');
|
||||
const passwordInput = formCard.querySelector('#pair-password');
|
||||
const startBtn = formCard.querySelector('#pair-start-btn');
|
||||
const modeHintEl = formCard.querySelector('#pair-mode-hint');
|
||||
|
||||
loginInput.value = String(state.loginDraft.login || '').trim();
|
||||
loginLabelEl.textContent = loginInput.value ? `Вход для @${loginInput.value}` : '';
|
||||
const shortCodeEl = resultWrap.querySelector('#pairing-short-code');
|
||||
const statusHintEl = resultWrap.querySelector('#pairing-status-hint');
|
||||
const onlineHintEl = resultWrap.querySelector('#pairing-online-hint');
|
||||
@@ -127,12 +125,11 @@ export function render({ navigate }) {
|
||||
const syncPasswordUi = () => {
|
||||
const usePassword = !!usePasswordInput.checked;
|
||||
passwordInput.parentElement.style.display = usePassword ? '' : 'none';
|
||||
startBtn.style.display = usePassword ? '' : 'none';
|
||||
modeHintEl.textContent = usePassword
|
||||
? 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети. Если на доверённом устройстве включён доп. пароль, введите его.'
|
||||
: 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.';
|
||||
if (!usePassword) {
|
||||
passwordInput.value = '';
|
||||
}
|
||||
? 'На доверённом устройстве для входа по коду включён дополнительный пароль.'
|
||||
: 'Код можно подтвердить на уже подключённом устройстве. Если оно сейчас не в сети, заявка будет ждать до истечения срока.';
|
||||
if (!usePassword) passwordInput.value = '';
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
@@ -157,6 +154,8 @@ export function render({ navigate }) {
|
||||
activePairingId = '';
|
||||
activeExpiresAtMs = 0;
|
||||
startBtn.disabled = false;
|
||||
startBtn.style.display = '';
|
||||
startBtn.textContent = 'Получить новый код';
|
||||
cancelBtn.style.display = 'none';
|
||||
resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl);
|
||||
setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error');
|
||||
@@ -183,7 +182,7 @@ export function render({ navigate }) {
|
||||
|
||||
const finalizeAuthorizedLogin = async (keys, login) => {
|
||||
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
||||
await terminateCurrentSession({ closeServerSession: true });
|
||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||
await clearStoredMessages().catch(() => {});
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
@@ -216,7 +215,7 @@ export function render({ navigate }) {
|
||||
sessionType: Number(payloadSession?.sessionType || 50) || 50,
|
||||
};
|
||||
|
||||
await terminateCurrentSession({ closeServerSession: true });
|
||||
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||
await clearStoredMessages().catch(() => {});
|
||||
clearBrowserClientData();
|
||||
await clearClientAuthData().catch(() => {});
|
||||
@@ -310,14 +309,16 @@ export function render({ navigate }) {
|
||||
setAuthBusy(true);
|
||||
setAuthError('');
|
||||
setAuthInfo('');
|
||||
setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info');
|
||||
setStatus(status, 'Создаём код для входа…', 'info');
|
||||
clearActivePairing();
|
||||
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const user = await authService.getUser(login);
|
||||
if (!user?.exists) {
|
||||
throw new Error('Пользователь не найден.');
|
||||
const resolved = await authService.resolveLoginForAuth(login);
|
||||
if (String(resolved?.resolution || '').toUpperCase() !== 'LOCAL') {
|
||||
state.loginDraft.login = String(resolved?.login || login).trim();
|
||||
navigate('login-view');
|
||||
return;
|
||||
}
|
||||
|
||||
requesterMaterial = await createRequesterPairingMaterial();
|
||||
@@ -337,61 +338,68 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || '');
|
||||
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.';
|
||||
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить по коду.';
|
||||
onlineHintEl.textContent = payload?.trustedSessionOnline
|
||||
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.'
|
||||
: 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.';
|
||||
? 'Доверенное устройство сейчас в сети и может сразу принять заявку.'
|
||||
: 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.';
|
||||
resultWrap.style.display = '';
|
||||
cancelBtn.style.display = '';
|
||||
startCountdown(payload?.expiresAtMs);
|
||||
state.loginDraft.login = login;
|
||||
setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info');
|
||||
schedulePoll();
|
||||
} catch (error) {
|
||||
startBtn.disabled = false;
|
||||
const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message, 'error');
|
||||
if (String(error?.code || '').toUpperCase() === 'PAIRING_PASSWORD_INVALID' && !usePassword) {
|
||||
usePasswordInput.checked = true;
|
||||
syncPasswordUi();
|
||||
modeHintEl.textContent = 'Для этого аккаунта включён дополнительный пароль подключения. Введите его, чтобы получить код.';
|
||||
setStatus(status, 'Введите дополнительный пароль подключения.', 'info');
|
||||
window.setTimeout(() => passwordInput.focus(), 0);
|
||||
} else {
|
||||
startBtn.style.display = '';
|
||||
startBtn.textContent = 'Повторить';
|
||||
const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message, 'error');
|
||||
}
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', async () => {
|
||||
if (!activePairingId || !requesterMaterial?.sessionKey) {
|
||||
clearActivePairing();
|
||||
startBtn.disabled = false;
|
||||
cancelBtn.style.display = 'none';
|
||||
return;
|
||||
const cancelActivePairingAndBack = async () => {
|
||||
const pairingId = activePairingId;
|
||||
const requesterSessionKey = requesterMaterial?.sessionKey;
|
||||
isDisposed = true;
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
if (pairingId && requesterSessionKey) {
|
||||
try {
|
||||
await authService.cancelTrustedDeviceLogin(pairingId, requesterSessionKey);
|
||||
} catch {
|
||||
// Навигацию назад не блокируем из-за ошибки отмены уже созданной заявки.
|
||||
}
|
||||
}
|
||||
cancelBtn.disabled = true;
|
||||
try {
|
||||
await authService.cancelTrustedDeviceLogin(activePairingId, requesterMaterial.sessionKey);
|
||||
clearActivePairing();
|
||||
startBtn.disabled = false;
|
||||
setStatus(status, 'Ожидание подключения отменено.', 'info');
|
||||
} catch (error) {
|
||||
const message = toUserMessage(error, 'Не удалось отменить ожидание подключения.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message, 'error');
|
||||
} finally {
|
||||
cancelBtn.disabled = false;
|
||||
cancelBtn.style.display = activePairingId ? '' : 'none';
|
||||
}
|
||||
});
|
||||
navigate('login-view');
|
||||
};
|
||||
|
||||
screen.cleanup = () => {
|
||||
isDisposed = true;
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
if (activePairingId && requesterMaterial?.sessionKey) {
|
||||
void authService.cancelTrustedDeviceLogin(activePairingId, requesterMaterial.sessionKey).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const resultActions = document.createElement('div');
|
||||
resultActions.className = 'row';
|
||||
resultActions.append(cancelBtn);
|
||||
resultWrap.append(resultActions);
|
||||
|
||||
panel.append(formCard, status, resultWrap);
|
||||
screen.append(panel);
|
||||
|
||||
if (!String(loginInput.value || '').trim()) {
|
||||
window.setTimeout(() => navigate('login-view'), 0);
|
||||
} else {
|
||||
// После проверки логина на предыдущем экране код создаётся сразу.
|
||||
window.setTimeout(() => startBtn.click(), 0);
|
||||
}
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -7,256 +7,177 @@ import {
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
composePasswordFromWords,
|
||||
emptyPasswordWords,
|
||||
normalizePasswordWords,
|
||||
PASSWORD_MAX_LENGTH,
|
||||
PASSWORD_WORDS_COUNT,
|
||||
} from '../services/password-words.js';
|
||||
import { emptyPasswordWords, PASSWORD_MAX_LENGTH } from '../services/password-words.js';
|
||||
|
||||
function createWordsLayout({ words, onInput }) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'registration-words-block';
|
||||
export const pageMeta = { id: 'login-password-view', title: 'Введите пароль', showAppChrome: false };
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'registration-words-grid';
|
||||
|
||||
const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'registration-word-row';
|
||||
|
||||
const number = document.createElement('span');
|
||||
number.className = 'registration-word-number';
|
||||
number.textContent = `${index + 1}.`;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'input registration-word-input';
|
||||
input.type = 'text';
|
||||
input.autocomplete = 'off';
|
||||
input.autocapitalize = 'off';
|
||||
input.spellcheck = false;
|
||||
input.maxLength = 32;
|
||||
input.value = words[index];
|
||||
input.addEventListener('input', () => onInput(index, input.value));
|
||||
|
||||
row.append(number, input);
|
||||
grid.append(row);
|
||||
return input;
|
||||
});
|
||||
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'meta-muted';
|
||||
hint.textContent =
|
||||
'Можно вводить любые слова на любых языках. Можно заполнить не все 12 полей. В конце они просто склеиваются в один пароль длиной до 256 символов.';
|
||||
|
||||
const preview = document.createElement('p');
|
||||
preview.className = 'status-line';
|
||||
|
||||
section.append(grid, hint);
|
||||
return { section, inputs, preview };
|
||||
function setStatus(statusEl, message) {
|
||||
statusEl.textContent = message;
|
||||
statusEl.style.display = message ? '' : 'none';
|
||||
}
|
||||
|
||||
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
|
||||
function createSecretOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'secret-generation-overlay';
|
||||
overlay.style.display = 'none';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'secret-generation-card stack';
|
||||
|
||||
const spinner = document.createElement('div');
|
||||
spinner.className = 'secret-generation-spinner';
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'secret-generation-title';
|
||||
title.textContent = 'Генерируем секрет…';
|
||||
|
||||
const progress = document.createElement('div');
|
||||
progress.className = 'secret-generation-progress';
|
||||
progress.textContent = '';
|
||||
|
||||
card.append(spinner, title, progress);
|
||||
overlay.append(card);
|
||||
return { overlay, progress };
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
screen.className = 'stack auth-screen auth-screen--lower';
|
||||
|
||||
clearAuthMessages();
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'card stack';
|
||||
const login = String(state.loginDraft.login || '').trim();
|
||||
if (!login) {
|
||||
window.setTimeout(() => navigate('login-view'), 0);
|
||||
}
|
||||
|
||||
let passwordMode = String(state.loginDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
|
||||
let passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'login-panel stack';
|
||||
|
||||
const loginInput = document.createElement('input');
|
||||
loginInput.className = 'input';
|
||||
loginInput.type = 'text';
|
||||
loginInput.autocomplete = 'off';
|
||||
loginInput.autocapitalize = 'off';
|
||||
loginInput.spellcheck = false;
|
||||
loginInput.value = state.loginDraft.login;
|
||||
loginInput.placeholder = 'Введите логин';
|
||||
const title = document.createElement('h1');
|
||||
title.className = 'login-panel-title';
|
||||
title.textContent = 'Введите пароль';
|
||||
|
||||
const passwordField = document.createElement('label');
|
||||
passwordField.className = 'stack';
|
||||
|
||||
const passwordInput = document.createElement('input');
|
||||
passwordInput.className = 'input';
|
||||
passwordInput.type = 'password';
|
||||
passwordInput.name = 'shine-login-password';
|
||||
passwordInput.autocomplete = 'new-password';
|
||||
passwordInput.autocomplete = 'current-password';
|
||||
passwordInput.autocapitalize = 'off';
|
||||
passwordInput.spellcheck = false;
|
||||
passwordInput.maxLength = PASSWORD_MAX_LENGTH;
|
||||
passwordInput.value = passwordMode === 'single' ? state.loginDraft.password : '';
|
||||
passwordInput.placeholder = 'Введите пароль';
|
||||
passwordInput.placeholder = 'Пароль';
|
||||
passwordInput.value = '';
|
||||
|
||||
const {
|
||||
section: wordsSection,
|
||||
inputs: wordInputs,
|
||||
preview: wordsPreview,
|
||||
} = createWordsLayout({
|
||||
words: passwordWords,
|
||||
onInput: (index, value) => {
|
||||
passwordWords[index] = value;
|
||||
syncDraftState();
|
||||
updateWordsPreview();
|
||||
},
|
||||
});
|
||||
|
||||
const passwordModeToggle = document.createElement('label');
|
||||
passwordModeToggle.className = 'registration-toggle';
|
||||
|
||||
const passwordModeCheckbox = document.createElement('input');
|
||||
passwordModeCheckbox.type = 'checkbox';
|
||||
passwordModeCheckbox.checked = passwordMode === 'words';
|
||||
|
||||
const passwordModeLabel = document.createElement('span');
|
||||
passwordModeLabel.textContent = 'Представить пароль в виде 12 слов';
|
||||
|
||||
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
|
||||
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'meta-muted';
|
||||
hint.textContent = 'Введите логин. На следующем шаге сохраните ключи на устройстве.';
|
||||
passwordField.append(passwordInput);
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.style.display = 'none';
|
||||
|
||||
let passwordField = null;
|
||||
const passwordLengthText = document.createElement('p');
|
||||
passwordLengthText.className = 'status-line';
|
||||
|
||||
function getCurrentPassword() {
|
||||
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
|
||||
}
|
||||
|
||||
function syncDraftState() {
|
||||
state.loginDraft.login = loginInput.value.trim();
|
||||
state.loginDraft.passwordMode = passwordMode;
|
||||
state.loginDraft.passwordWords = normalizePasswordWords(passwordWords);
|
||||
state.loginDraft.password = getCurrentPassword();
|
||||
}
|
||||
|
||||
function updateWordsPreview() {
|
||||
const password = getCurrentPassword();
|
||||
const text = `Итоговая длина пароля: ${password.length} символов.`;
|
||||
wordsPreview.textContent = text;
|
||||
passwordLengthText.textContent = text;
|
||||
}
|
||||
|
||||
function updatePasswordModeVisibility() {
|
||||
const wordsMode = passwordMode === 'words';
|
||||
wordsSection.style.display = wordsMode ? 'grid' : 'none';
|
||||
if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid';
|
||||
passwordInput.style.display = wordsMode ? 'none' : '';
|
||||
updateWordsPreview();
|
||||
}
|
||||
|
||||
form.innerHTML = `
|
||||
<label class="stack"><span class="field-label">Логин</span></label>
|
||||
<label class="stack"><span class="field-label">Пароль</span></label>
|
||||
`;
|
||||
form.children[0].append(loginInput);
|
||||
passwordField = form.children[1];
|
||||
passwordField.append(passwordInput);
|
||||
form.append(passwordModeToggle, wordsSection, passwordLengthText, hint, status);
|
||||
updatePasswordModeVisibility();
|
||||
syncDraftState();
|
||||
|
||||
loginInput.addEventListener('input', syncDraftState);
|
||||
passwordInput.addEventListener('input', () => {
|
||||
syncDraftState();
|
||||
updateWordsPreview();
|
||||
});
|
||||
|
||||
passwordModeCheckbox.addEventListener('change', () => {
|
||||
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
|
||||
if (nextMode === passwordMode) return;
|
||||
if (nextMode === 'words') {
|
||||
passwordWords = emptyPasswordWords();
|
||||
wordInputs.forEach((input) => {
|
||||
input.value = '';
|
||||
});
|
||||
passwordInput.value = '';
|
||||
} else {
|
||||
passwordInput.value = composePasswordFromWords(passwordWords);
|
||||
}
|
||||
passwordMode = nextMode;
|
||||
updatePasswordModeVisibility();
|
||||
updateWordsPreview();
|
||||
syncDraftState();
|
||||
});
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
|
||||
const backButton = document.createElement('button');
|
||||
backButton.className = 'ghost-btn';
|
||||
backButton.type = 'button';
|
||||
backButton.textContent = 'Назад';
|
||||
backButton.addEventListener('click', () => navigate('start-view'));
|
||||
|
||||
const enterButton = document.createElement('button');
|
||||
enterButton.className = 'primary-btn';
|
||||
enterButton.type = 'button';
|
||||
enterButton.textContent = 'Войти';
|
||||
enterButton.addEventListener('click', async () => {
|
||||
status.style.display = 'none';
|
||||
syncDraftState();
|
||||
|
||||
if (!state.loginDraft.login) {
|
||||
status.textContent = 'Введите логин.';
|
||||
status.style.display = '';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
actions.append(enterButton);
|
||||
|
||||
const { overlay, progress } = createSecretOverlay();
|
||||
|
||||
const submit = async () => {
|
||||
const currentLogin = String(state.loginDraft.login || '').trim();
|
||||
const password = String(passwordInput.value || '');
|
||||
setStatus(status, '');
|
||||
|
||||
if (!currentLogin) {
|
||||
navigate('login-view');
|
||||
return;
|
||||
}
|
||||
if (state.loginDraft.password.length > PASSWORD_MAX_LENGTH) {
|
||||
status.textContent = `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`;
|
||||
status.style.display = '';
|
||||
if (password.length > PASSWORD_MAX_LENGTH) {
|
||||
setStatus(status, `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`);
|
||||
return;
|
||||
}
|
||||
|
||||
state.loginDraft.password = password;
|
||||
state.loginDraft.passwordMode = 'single';
|
||||
state.loginDraft.passwordWords = emptyPasswordWords();
|
||||
|
||||
setAuthBusy(true);
|
||||
setAuthError('');
|
||||
enterButton.disabled = true;
|
||||
enterButton.textContent = 'Входим...';
|
||||
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const result = await authService.createSessionForExistingUser(state.loginDraft.login, state.loginDraft.password);
|
||||
|
||||
// Повторная проверка защищает UI от смены access server между первым экраном и входом.
|
||||
const resolved = await authService.resolveLoginForAuth(currentLogin);
|
||||
if (String(resolved?.resolution || '').toUpperCase() !== 'LOCAL') {
|
||||
state.loginDraft.login = String(resolved?.login || currentLogin).trim();
|
||||
navigate('login-view');
|
||||
return;
|
||||
}
|
||||
|
||||
overlay.style.display = 'grid';
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(currentLogin, password, {
|
||||
onProgress: ({ percent }) => {
|
||||
const value = Math.max(0, Math.min(100, Number(percent) || 0));
|
||||
progress.textContent = value > 0 && value < 100 ? `${Math.round(value)}%` : '';
|
||||
},
|
||||
});
|
||||
overlay.style.display = 'none';
|
||||
|
||||
enterButton.textContent = 'Проверяем пароль…';
|
||||
const result = await authService.createAuthSession(currentLogin, keyBundle);
|
||||
|
||||
// Существующую дальнейшую логику после успешного пароля сохраняем без изменений.
|
||||
state.registrationDraft.flowType = 'login';
|
||||
state.registrationDraft.login = result.login;
|
||||
state.registrationDraft.password = state.loginDraft.password;
|
||||
state.registrationDraft.passwordMode = state.loginDraft.passwordMode;
|
||||
state.registrationDraft.passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
|
||||
state.registrationDraft.password = password;
|
||||
state.registrationDraft.passwordMode = 'single';
|
||||
state.registrationDraft.passwordWords = emptyPasswordWords();
|
||||
state.registrationDraft.sessionId = result.sessionId;
|
||||
state.registrationDraft.storagePwd = result.storagePwd;
|
||||
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
||||
state.registrationDraft.pendingKeyBundle = keyBundle;
|
||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||
navigate('registration-keys-view');
|
||||
} catch (error) {
|
||||
overlay.style.display = 'none';
|
||||
const message = toUserMessage(error, 'Не удалось выполнить вход.');
|
||||
setAuthError(message);
|
||||
status.textContent = message;
|
||||
status.style.display = '';
|
||||
setStatus(status, message);
|
||||
passwordInput.select();
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
enterButton.disabled = false;
|
||||
enterButton.textContent = 'Войти';
|
||||
}
|
||||
};
|
||||
|
||||
enterButton.addEventListener('click', submit);
|
||||
passwordInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
});
|
||||
|
||||
actions.append(backButton, enterButton);
|
||||
panel.append(title, passwordField, status, actions);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Войти по логину',
|
||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||
title: '',
|
||||
leftAction: { label: '←', onClick: () => navigate('login-view') },
|
||||
}),
|
||||
form,
|
||||
actions,
|
||||
panel,
|
||||
overlay,
|
||||
);
|
||||
|
||||
window.setTimeout(() => passwordInput.focus(), 0);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,156 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import {
|
||||
authService,
|
||||
clearAuthMessages,
|
||||
setAuthBusy,
|
||||
setAuthError,
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { buildShineHttpUrlFromAddress } from '../services/shine-server-resolver.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'login-view', title: 'Войти', showAppChrome: false };
|
||||
|
||||
function setStatus(statusEl, message, kind = 'error') {
|
||||
statusEl.classList.toggle('is-unavailable', kind === 'error');
|
||||
statusEl.classList.toggle('is-available', kind !== 'error');
|
||||
statusEl.textContent = message;
|
||||
statusEl.style.display = message ? '' : 'none';
|
||||
}
|
||||
|
||||
function setRemoteServer(remoteWrap, serverLoginEl, serverLinkEl, payload) {
|
||||
const serverLogin = String(payload?.accessServerLogin || '').trim();
|
||||
const serverUrl = String(payload?.accessServerUrl || '').trim();
|
||||
serverLoginEl.textContent = serverLogin ? `@${serverLogin}` : 'другой сервер доступа';
|
||||
serverLinkEl.textContent = serverUrl || 'Открыть сервер';
|
||||
serverLinkEl.href = buildShineHttpUrlFromAddress(serverUrl);
|
||||
remoteWrap.style.display = '';
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack auth-screen auth-screen--lower login-choice-screen';
|
||||
|
||||
const loginButton = document.createElement('button');
|
||||
loginButton.className = 'ghost-btn';
|
||||
loginButton.type = 'button';
|
||||
loginButton.textContent = 'Войти по паролю';
|
||||
loginButton.addEventListener('click', () => navigate('login-password-view'));
|
||||
|
||||
const otherDeviceButton = document.createElement('button');
|
||||
otherDeviceButton.className = 'ghost-btn';
|
||||
otherDeviceButton.type = 'button';
|
||||
otherDeviceButton.textContent = 'Войти через другое устройство';
|
||||
otherDeviceButton.addEventListener('click', () => navigate('login-other-device-view'));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-actions login-actions-wide';
|
||||
actions.append(loginButton, otherDeviceButton);
|
||||
clearAuthMessages();
|
||||
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'login-panel stack';
|
||||
panel.innerHTML = '<h1 class="login-panel-title">Войти</h1>';
|
||||
panel.append(actions);
|
||||
|
||||
const title = document.createElement('h1');
|
||||
title.className = 'login-panel-title';
|
||||
title.textContent = 'Введите логин';
|
||||
|
||||
const loginField = document.createElement('label');
|
||||
loginField.className = 'stack';
|
||||
|
||||
const loginInput = document.createElement('input');
|
||||
loginInput.className = 'input';
|
||||
loginInput.type = 'text';
|
||||
loginInput.autocomplete = 'username';
|
||||
loginInput.autocapitalize = 'off';
|
||||
loginInput.spellcheck = false;
|
||||
loginInput.placeholder = 'Логин';
|
||||
loginInput.value = String(state.loginDraft.login || '');
|
||||
|
||||
loginField.append(loginInput);
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.style.display = 'none';
|
||||
|
||||
const remoteWrap = document.createElement('div');
|
||||
remoteWrap.className = 'login-remote-server stack';
|
||||
remoteWrap.style.display = 'none';
|
||||
|
||||
const remoteText = document.createElement('p');
|
||||
remoteText.className = 'auth-copy';
|
||||
const serverLoginEl = document.createElement('strong');
|
||||
const textBefore = document.createTextNode('Этот пользователь SHiNE зарегистрирован на другом сервере доступа: ');
|
||||
remoteText.append(textBefore, serverLoginEl, document.createTextNode('. Для входа перейдите на его сервер.'));
|
||||
|
||||
const serverLinkEl = document.createElement('a');
|
||||
serverLinkEl.className = 'primary-btn login-server-link';
|
||||
serverLinkEl.target = '_self';
|
||||
serverLinkEl.rel = 'noopener';
|
||||
|
||||
remoteWrap.append(remoteText, serverLinkEl);
|
||||
|
||||
const passwordButton = document.createElement('button');
|
||||
passwordButton.className = 'primary-btn';
|
||||
passwordButton.type = 'button';
|
||||
passwordButton.textContent = 'Войти по паролю';
|
||||
|
||||
const deviceButton = document.createElement('button');
|
||||
deviceButton.className = 'ghost-btn';
|
||||
deviceButton.type = 'button';
|
||||
deviceButton.textContent = 'Войти через другое устройство';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-actions login-actions-wide';
|
||||
actions.append(passwordButton, deviceButton);
|
||||
|
||||
const resolveAndContinue = async (targetPage) => {
|
||||
const login = String(loginInput.value || '').trim();
|
||||
setStatus(status, '');
|
||||
remoteWrap.style.display = 'none';
|
||||
if (!login) {
|
||||
setStatus(status, 'Введите логин.');
|
||||
loginInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
passwordButton.disabled = true;
|
||||
deviceButton.disabled = true;
|
||||
setAuthBusy(true);
|
||||
setAuthError('');
|
||||
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const resolved = await authService.resolveLoginForAuth(login);
|
||||
const resolution = String(resolved?.resolution || '').trim().toUpperCase();
|
||||
|
||||
if (resolution === 'NOT_FOUND') {
|
||||
setStatus(status, 'Пользователь с таким логином не зарегистрирован в SHiNE.');
|
||||
return;
|
||||
}
|
||||
if (resolution === 'NO_ACCESS_SERVER') {
|
||||
setStatus(status, 'Пользователь зарегистрирован в SHiNE, но для него не найден действующий сервер доступа.');
|
||||
return;
|
||||
}
|
||||
if (resolution === 'REMOTE') {
|
||||
state.loginDraft.login = String(resolved?.login || login).trim();
|
||||
setRemoteServer(remoteWrap, serverLoginEl, serverLinkEl, resolved);
|
||||
return;
|
||||
}
|
||||
if (resolution !== 'LOCAL') {
|
||||
setStatus(status, 'Сервер вернул неизвестный статус проверки логина.');
|
||||
return;
|
||||
}
|
||||
|
||||
state.loginDraft.login = String(resolved?.login || login).trim();
|
||||
state.loginDraft.password = '';
|
||||
navigate(targetPage);
|
||||
} catch (error) {
|
||||
const message = toUserMessage(error, 'Не удалось проверить логин.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message);
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
passwordButton.disabled = false;
|
||||
deviceButton.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
passwordButton.addEventListener('click', () => resolveAndContinue('login-password-view'));
|
||||
deviceButton.addEventListener('click', () => resolveAndContinue('login-other-device-view'));
|
||||
loginInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void resolveAndContinue('login-password-view');
|
||||
}
|
||||
});
|
||||
|
||||
panel.append(title, loginField, status, remoteWrap, actions);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
@@ -35,5 +160,6 @@ export function render({ navigate }) {
|
||||
panel,
|
||||
);
|
||||
|
||||
window.setTimeout(() => loginInput.focus(), 0);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
authService,
|
||||
getChatMessages,
|
||||
isSessionInvalidError,
|
||||
normalizeDmChatId,
|
||||
setContacts,
|
||||
@@ -8,9 +9,11 @@ import {
|
||||
} from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
import { formatRelativeTime } from '../services/channels-ux.js';
|
||||
|
||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||
const PREVIEW_MAX_LEN = 200;
|
||||
@@ -52,7 +55,7 @@ function createDmAvatar(login, { className = '' } = {}) {
|
||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||
const avatarEl = renderUserAvatar({
|
||||
login: cleanLogin || 'unknown',
|
||||
size: 'small',
|
||||
size: 'lg',
|
||||
title,
|
||||
className,
|
||||
});
|
||||
@@ -67,7 +70,7 @@ function createDmAvatar(login, { className = '' } = {}) {
|
||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null,
|
||||
size: 'small',
|
||||
size: 'lg',
|
||||
title,
|
||||
className,
|
||||
});
|
||||
@@ -106,6 +109,8 @@ function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
|
||||
}
|
||||
|
||||
async function resolveDialogPreview(dialog) {
|
||||
const localText = clipPreviewText(String(dialog?.lastMessageText || '').trim());
|
||||
if (localText) return localText;
|
||||
const blobB64 = String(dialog?.lastMessageBlobB64 || '').trim();
|
||||
if (!blobB64) return 'Диалог пока пуст.';
|
||||
|
||||
@@ -145,15 +150,31 @@ async function resolveDialogPreview(dialog) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
function resolveLocalMessageTimeMs(message) {
|
||||
const keys = [message?.baseKey, message?.messageKey];
|
||||
for (const key of keys) {
|
||||
const parts = String(key || '').split('|');
|
||||
const timeMs = Number(parts[2] || 0);
|
||||
if (parts.length >= 4 && Number.isFinite(timeMs) && timeMs > 0) return timeMs;
|
||||
}
|
||||
const tempParts = String(message?.tempId || '').split('-');
|
||||
const tempTimeMs = Number(tempParts[1] || 0);
|
||||
if (tempParts[0] === 'tmp' && Number.isFinite(tempTimeMs) && tempTimeMs > 0) return tempTimeMs;
|
||||
const createdAtMs = Number(message?.createdAtMs || message?.ts || 0);
|
||||
return Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : 0;
|
||||
}
|
||||
|
||||
function latestLocalDialogMessage(peerLogin) {
|
||||
const messages = getChatMessages(peerLogin);
|
||||
const latest = [...messages].sort((a, b) => resolveLocalMessageTimeMs(b) - resolveLocalMessageTimeMs(a))[0];
|
||||
if (!latest) return null;
|
||||
const parsed = parseDmTechBlocks(String(latest?.text || ''));
|
||||
const text = clipPreviewText(String(parsed.displayText || parsed.visibleText || '').trim()) || 'Сообщение';
|
||||
return { text, timeMs: resolveLocalMessageTimeMs(latest) };
|
||||
}
|
||||
|
||||
function formatChatRowTime(ts) {
|
||||
const value = Number(ts || 0);
|
||||
if (!Number.isFinite(value) || value <= 0) return '';
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
return formatRelativeTime(ts);
|
||||
}
|
||||
|
||||
function compareChatRows(a, b) {
|
||||
@@ -174,7 +195,7 @@ export function render({ navigate, chrome }) {
|
||||
<div class="dm-head-brand">
|
||||
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
|
||||
</div>
|
||||
<h1 class="dm-head-title">Чаты</h1>
|
||||
<button type="button" class="dm-head-title dm-head-filter-title" id="dm-chat-filter-title">Чаты</button>
|
||||
<div class="dm-head-menu-wrap">
|
||||
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
|
||||
<div class="dm-head-menu" role="menu" hidden>
|
||||
@@ -194,6 +215,28 @@ export function render({ navigate, chrome }) {
|
||||
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||
menuButton?.append(createOverflowDots());
|
||||
|
||||
let currentChatFilter = 'all';
|
||||
const filterTitle = head.querySelector('#dm-chat-filter-title');
|
||||
const filterLabels = {
|
||||
all: 'Чаты',
|
||||
close_friend: 'Близкие друзья',
|
||||
contact: 'Контакты',
|
||||
none: 'Новые',
|
||||
};
|
||||
let reloadForFilter = () => {};
|
||||
const chatFilterMenu = createDropdownMenu({
|
||||
anchorEl: filterTitle,
|
||||
align: 'left',
|
||||
leftShift: 72,
|
||||
minWidth: 225,
|
||||
items: [
|
||||
{ label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } },
|
||||
{ label: 'Близкие друзья', action: () => { currentChatFilter = 'close_friend'; filterTitle.textContent = filterLabels.close_friend; reloadForFilter(); } },
|
||||
{ label: 'Контакты', action: () => { currentChatFilter = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } },
|
||||
{ label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } },
|
||||
],
|
||||
});
|
||||
|
||||
const menuWrap = head.querySelector('.dm-head-menu-wrap');
|
||||
const menuTemplate = head.querySelector('.dm-head-menu');
|
||||
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
||||
@@ -217,11 +260,13 @@ export function render({ navigate, chrome }) {
|
||||
const menuWidth = menuPortal.offsetWidth || 206;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
menuPortal.style.left = `${Math.round(left)}px`;
|
||||
menuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||
const titleRect = filterTitle?.getBoundingClientRect?.();
|
||||
menuPortal.style.top = `${Math.round((titleRect?.bottom || rect.bottom) + 7)}px`;
|
||||
};
|
||||
|
||||
const openHeadMenu = () => {
|
||||
if (!menuButton || menuPortal) return;
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: menuButton } }));
|
||||
const portal = document.createElement('div');
|
||||
portal.className = 'dm-head-menu dm-head-menu--portal';
|
||||
portal.setAttribute('role', 'menu');
|
||||
@@ -262,6 +307,10 @@ export function render({ navigate, chrome }) {
|
||||
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
|
||||
closeHeadMenu();
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (!menuPortal || event?.detail?.owner === menuButton) return;
|
||||
closeHeadMenu();
|
||||
};
|
||||
const onMenuKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !menuPortal) return;
|
||||
closeHeadMenu();
|
||||
@@ -270,6 +319,7 @@ export function render({ navigate, chrome }) {
|
||||
const onMenuViewportChange = () => positionHeadMenu();
|
||||
document.addEventListener('click', onOutsideClick);
|
||||
document.addEventListener('keydown', onMenuKeydown);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.addEventListener('resize', onMenuViewportChange, { passive: true });
|
||||
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
|
||||
|
||||
@@ -350,24 +400,33 @@ function renderRow(item) {
|
||||
unreadCount: Number(dialog?.unreadCount || 0),
|
||||
hasDialog: Boolean(dialog?.hasDialog),
|
||||
};
|
||||
const localLatest = latestLocalDialogMessage(peerLogin);
|
||||
if (localLatest && localLatest.timeMs >= next.lastMessageTimeMs) {
|
||||
next.lastMessageText = localLatest.text;
|
||||
next.lastMessageTimeMs = localLatest.timeMs;
|
||||
}
|
||||
const current = byPeer.get(key);
|
||||
if (!current) {
|
||||
byPeer.set(key, next);
|
||||
return;
|
||||
}
|
||||
const currentRank = relationOrder(current.relationFlag);
|
||||
const nextRank = relationOrder(relationFlag);
|
||||
if (nextRank < currentRank || (nextRank === currentRank && next.lastMessageTimeMs > current.lastMessageTimeMs)) {
|
||||
byPeer.set(key, next);
|
||||
}
|
||||
const newest = next.lastMessageTimeMs > current.lastMessageTimeMs ? next : current;
|
||||
newest.relationFlag = relationOrder(relationFlag) < relationOrder(current.relationFlag)
|
||||
? relationFlag
|
||||
: current.relationFlag;
|
||||
newest.unreadCount = Math.max(Number(current.unreadCount || 0), Number(next.unreadCount || 0));
|
||||
newest.hasDialog = Boolean(current.hasDialog || next.hasDialog);
|
||||
byPeer.set(key, newest);
|
||||
});
|
||||
|
||||
const rows = Array.from(byPeer.values()).sort((a, b) => {
|
||||
const orderA = relationOrder(a.relationFlag);
|
||||
const orderB = relationOrder(b.relationFlag);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return compareChatRows(a, b);
|
||||
});
|
||||
const rows = Array.from(byPeer.values())
|
||||
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
|
||||
.sort((a, b) => {
|
||||
const orderA = relationOrder(a.relationFlag);
|
||||
const orderB = relationOrder(b.relationFlag);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return compareChatRows(a, b);
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
@@ -425,14 +484,18 @@ function renderRow(item) {
|
||||
}
|
||||
}
|
||||
|
||||
reloadForFilter = () => { void loadList(); };
|
||||
|
||||
chrome?.setTopbar(head);
|
||||
screen.append(list);
|
||||
loadList();
|
||||
|
||||
screen.cleanup = () => {
|
||||
closeHeadMenu();
|
||||
chatFilterMenu.destroy();
|
||||
document.removeEventListener('click', onOutsideClick);
|
||||
document.removeEventListener('keydown', onMenuKeydown);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.removeEventListener('resize', onMenuViewportChange);
|
||||
window.removeEventListener('scroll', onMenuViewportChange, true);
|
||||
};
|
||||
|
||||
@@ -165,7 +165,7 @@ function renderIdentity(item) {
|
||||
firstName,
|
||||
lastName,
|
||||
avatar,
|
||||
size: 'small',
|
||||
size: 'md',
|
||||
className: 'notification-avatar',
|
||||
}));
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ export function render({ navigate, chrome }) {
|
||||
avatar: currentAvatar?.txId
|
||||
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
|
||||
: null,
|
||||
size: 'large',
|
||||
size: 'xl',
|
||||
className: 'profile-avatar',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { buildIdentityLines } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
@@ -110,84 +111,19 @@ export function render({ navigate, chrome }) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||
profileMenuButton?.append(createOverflowDots());
|
||||
let profileMenuPortal = null;
|
||||
|
||||
const closeProfileMenu = () => {
|
||||
profileMenuPortal?.remove();
|
||||
profileMenuPortal = null;
|
||||
profileMenuButton?.setAttribute('aria-expanded', 'false');
|
||||
profileMenuWrap?.classList.remove('is-open');
|
||||
};
|
||||
|
||||
const positionProfileMenu = () => {
|
||||
if (!profileMenuPortal || !profileMenuButton) return;
|
||||
const rect = profileMenuButton.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = profileMenuPortal.offsetWidth || 224;
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||
profileMenuPortal.style.left = `${Math.round(left)}px`;
|
||||
profileMenuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
|
||||
};
|
||||
|
||||
const openProfileMenu = () => {
|
||||
if (!profileMenuButton || profileMenuPortal) return;
|
||||
const portal = document.createElement('div');
|
||||
portal.className = 'dm-head-menu dm-head-menu--portal profile-head-menu';
|
||||
portal.setAttribute('role', 'menu');
|
||||
portal.innerHTML = `
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="profile">
|
||||
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
|
||||
<span>Редактировать профиль</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="wallet">
|
||||
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true" />
|
||||
<span>Кошелёк</span>
|
||||
</button>
|
||||
<button type="button" class="dm-head-menu-item profile-head-menu-item" role="menuitem" data-top-action="settings">
|
||||
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true" />
|
||||
<span>Настройки</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const goTo = (route) => {
|
||||
closeProfileMenu();
|
||||
navigate(route);
|
||||
};
|
||||
portal.querySelector('[data-top-action="profile"]')?.addEventListener('click', () => goTo('profile-edit-view'));
|
||||
portal.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => goTo('wallet-view'));
|
||||
portal.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => goTo('settings-view'));
|
||||
portal.addEventListener('click', (event) => event.stopPropagation());
|
||||
|
||||
document.body.append(portal);
|
||||
profileMenuPortal = portal;
|
||||
profileMenuButton.setAttribute('aria-expanded', 'true');
|
||||
profileMenuWrap?.classList.add('is-open');
|
||||
positionProfileMenu();
|
||||
};
|
||||
|
||||
profileMenuButton?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (profileMenuPortal) closeProfileMenu();
|
||||
else openProfileMenu();
|
||||
const profileMenu = createDropdownMenu({
|
||||
anchorEl: profileMenuButton,
|
||||
className: 'profile-head-menu',
|
||||
minWidth: 230,
|
||||
items: [
|
||||
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
|
||||
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
|
||||
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
|
||||
],
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!profileMenuPortal) return;
|
||||
if (profileMenuPortal.contains(event.target) || profileMenuButton?.contains(event.target)) return;
|
||||
closeProfileMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape' || !profileMenuPortal) return;
|
||||
closeProfileMenu();
|
||||
profileMenuButton?.focus();
|
||||
});
|
||||
window.addEventListener('resize', positionProfileMenu, { passive: true });
|
||||
window.addEventListener('scroll', positionProfileMenu, { passive: true, capture: true });
|
||||
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
const card = document.createElement('div');
|
||||
@@ -252,7 +188,7 @@ export function render({ navigate, chrome }) {
|
||||
avatar: currentAvatar?.txId
|
||||
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
|
||||
: null,
|
||||
size: 'large',
|
||||
size: 'xl',
|
||||
className: 'profile-avatar',
|
||||
}));
|
||||
}
|
||||
@@ -382,5 +318,6 @@ export function render({ navigate, chrome }) {
|
||||
updateAvatarUi();
|
||||
refreshProfileSnapshot();
|
||||
|
||||
screen.cleanup = () => profileMenu.destroy();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,16 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'connect-device',
|
||||
shortTitle: 'Подключить по коду',
|
||||
title: 'Как подключить другое устройство?',
|
||||
paragraphs: [
|
||||
'На новом устройстве выберите вход через другое устройство и получите код подключения.',
|
||||
'На уже авторизованном устройстве откройте: Профиль → Настройки → Устройства → Подключить по коду. Введите или выберите соответствующий код и подтвердите подключение.',
|
||||
'Client key передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны root key и/или blockchain key, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hardware-device',
|
||||
shortTitle: 'ESP32',
|
||||
|
||||