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`);
|
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
|
||||||
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
|
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
|
||||||
с которыми синхронизируются блоки и DM;
|
с которыми синхронизируются пользовательские блокчейны;
|
||||||
- **корневой ключ** сервера (`root_key`).
|
- **корневой ключ** сервера (`root_key`).
|
||||||
|
|
||||||
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
|
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
|
||||||
@@ -49,7 +49,9 @@ shine-UI/server-ui.html
|
|||||||
|
|
||||||
## Синхронизация с партнёрскими серверами
|
## Синхронизация с партнёрскими серверами
|
||||||
|
|
||||||
Сервер должен синхронизировать блоки блокчейна и DM с серверами-партнёрами из `sync_servers`.
|
Сервер должен синхронизировать блоки пользовательских блокчейнов с
|
||||||
|
серверами-партнёрами из `sync_servers`. DM между партнёрами не реплицируются:
|
||||||
|
они доставляются только на первый access-сервер получателя.
|
||||||
Детали: `docs/Blockchain/sync-between-servers.md`
|
Детали: `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_13 = 13;
|
||||||
public static final int SCHEMA_VERSION_14 = 14;
|
public static final int SCHEMA_VERSION_14 = 14;
|
||||||
public static final int SCHEMA_VERSION_15 = 15;
|
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_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.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";
|
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_V13_RESOURCE = "postgres/migration_v13.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V14_RESOURCE = "postgres/migration_v14.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_V15_RESOURCE = "postgres/migration_v15.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V16_RESOURCE = "postgres/migration_v16.sql";
|
||||||
|
|
||||||
private DatabaseInitializer() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -166,6 +168,10 @@ public final class DatabaseInitializer {
|
|||||||
runSqlScript(conn, POSTGRES_MIGRATION_V15_RESOURCE);
|
runSqlScript(conn, POSTGRES_MIGRATION_V15_RESOURCE);
|
||||||
currentVersion = SCHEMA_VERSION_15;
|
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 {
|
public List<UserAccessServerRouteEntry> listByUserLogin(Connection c, String userLogin) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT user_login, server_login, server_url, server_client_key
|
SELECT user_login, server_login, server_url, server_client_key
|
||||||
|
|||||||
@@ -12,15 +12,11 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UserSettingsDAO — хранение пользовательских настроек.
|
* Локальное хранение подписанных пользовательских настроек.
|
||||||
*
|
* Запись уникальна по login + setting_type + setting_key и обновляется только
|
||||||
* Правило:
|
* более новым time_ms. Межсерверной репликации настроек нет.
|
||||||
* - уникальность: login + setting_type + setting_key
|
|
||||||
* - запись обновляется только если time_ms новее
|
|
||||||
* - synced=true означает, что значение уже дошло до второго сервера
|
|
||||||
*/
|
*/
|
||||||
public final class UserSettingsDAO {
|
public final class UserSettingsDAO {
|
||||||
|
|
||||||
private static volatile UserSettingsDAO instance;
|
private static volatile UserSettingsDAO instance;
|
||||||
private final DbController db = DbController.getInstance();
|
private final DbController db = DbController.getInstance();
|
||||||
|
|
||||||
@@ -39,16 +35,15 @@ public final class UserSettingsDAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO user_settings (
|
INSERT INTO user_settings (
|
||||||
login, setting_type, setting_key, time_ms,
|
login, setting_type, setting_key, time_ms,
|
||||||
value_text, value_num, client_key, signature, synced
|
value_text, value_num, client_key, signature
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (login, setting_type, setting_key)
|
ON CONFLICT (login, setting_type, setting_key)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
time_ms = EXCLUDED.time_ms,
|
time_ms = EXCLUDED.time_ms,
|
||||||
value_text = EXCLUDED.value_text,
|
value_text = EXCLUDED.value_text,
|
||||||
value_num = EXCLUDED.value_num,
|
value_num = EXCLUDED.value_num,
|
||||||
client_key = EXCLUDED.client_key,
|
client_key = EXCLUDED.client_key,
|
||||||
signature = EXCLUDED.signature,
|
signature = EXCLUDED.signature
|
||||||
synced = EXCLUDED.synced
|
|
||||||
WHERE user_settings.time_ms < EXCLUDED.time_ms
|
WHERE user_settings.time_ms < EXCLUDED.time_ms
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
@@ -58,27 +53,20 @@ public final class UserSettingsDAO {
|
|||||||
ps.setLong(4, e.getTimeMs());
|
ps.setLong(4, e.getTimeMs());
|
||||||
ps.setString(5, e.getValueText() == null ? "" : e.getValueText());
|
ps.setString(5, e.getValueText() == null ? "" : e.getValueText());
|
||||||
ps.setLong(6, e.getValueNum());
|
ps.setLong(6, e.getValueNum());
|
||||||
|
|
||||||
if (e.getClientKey() == null || e.getClientKey().isBlank()) ps.setNull(7, Types.VARCHAR);
|
if (e.getClientKey() == null || e.getClientKey().isBlank()) ps.setNull(7, Types.VARCHAR);
|
||||||
else ps.setString(7, e.getClientKey());
|
else ps.setString(7, e.getClientKey());
|
||||||
|
|
||||||
if (e.getSignature() == null || e.getSignature().isBlank()) ps.setNull(8, Types.VARCHAR);
|
if (e.getSignature() == null || e.getSignature().isBlank()) ps.setNull(8, Types.VARCHAR);
|
||||||
else ps.setString(8, e.getSignature());
|
else ps.setString(8, e.getSignature());
|
||||||
|
|
||||||
ps.setBoolean(9, e.isSynced());
|
|
||||||
return ps.executeUpdate();
|
return ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public int upsertIfNewer(UserSettingEntry e) throws SQLException {
|
public UserSettingEntry getByLoginTypeKey(
|
||||||
try (Connection c = db.getConnection()) {
|
Connection c, String login, int settingType, String settingKey
|
||||||
return upsertIfNewer(c, e);
|
) throws SQLException {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public UserSettingEntry getByLoginTypeKey(Connection c, String login, int settingType, String settingKey) throws SQLException {
|
|
||||||
String sql = """
|
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
|
FROM user_settings
|
||||||
WHERE LOWER(login) = LOWER(?) AND setting_type = ? AND setting_key = ?
|
WHERE LOWER(login) = LOWER(?) AND setting_type = ? AND setting_key = ?
|
||||||
LIMIT 1
|
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 {
|
public List<UserSettingEntry> getByLogin(Connection c, String login) throws SQLException {
|
||||||
String sql = """
|
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
|
FROM user_settings
|
||||||
WHERE LOWER(login) = LOWER(?)
|
WHERE LOWER(login) = LOWER(?)
|
||||||
ORDER BY time_ms DESC, setting_type ASC, setting_key ASC
|
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 {
|
private static UserSettingEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
UserSettingEntry e = new UserSettingEntry();
|
UserSettingEntry e = new UserSettingEntry();
|
||||||
e.setLogin(rs.getString("login"));
|
e.setLogin(rs.getString("login"));
|
||||||
@@ -216,16 +113,12 @@ public final class UserSettingsDAO {
|
|||||||
e.setTimeMs(rs.getLong("time_ms"));
|
e.setTimeMs(rs.getLong("time_ms"));
|
||||||
e.setValueText(rs.getString("value_text"));
|
e.setValueText(rs.getString("value_text"));
|
||||||
e.setValueNum(rs.getLong("value_num"));
|
e.setValueNum(rs.getLong("value_num"));
|
||||||
|
|
||||||
String clientKey = rs.getString("client_key");
|
String clientKey = rs.getString("client_key");
|
||||||
if (rs.wasNull()) clientKey = null;
|
if (rs.wasNull()) clientKey = null;
|
||||||
e.setClientKey(clientKey);
|
e.setClientKey(clientKey);
|
||||||
|
|
||||||
String signature = rs.getString("signature");
|
String signature = rs.getString("signature");
|
||||||
if (rs.wasNull()) signature = null;
|
if (rs.wasNull()) signature = null;
|
||||||
e.setSignature(signature);
|
e.setSignature(signature);
|
||||||
|
|
||||||
e.setSynced(rs.getBoolean("synced"));
|
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,6 @@
|
|||||||
package shine.db.entities;
|
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 {
|
public class UserSettingEntry {
|
||||||
private String login;
|
private String login;
|
||||||
private int settingType;
|
private int settingType;
|
||||||
@@ -23,11 +10,19 @@ public class UserSettingEntry {
|
|||||||
private long valueNum;
|
private long valueNum;
|
||||||
private String clientKey;
|
private String clientKey;
|
||||||
private String signature;
|
private String signature;
|
||||||
private boolean synced;
|
|
||||||
|
|
||||||
public UserSettingEntry() {}
|
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.login = login;
|
||||||
this.settingType = settingType;
|
this.settingType = settingType;
|
||||||
this.settingKey = settingKey;
|
this.settingKey = settingKey;
|
||||||
@@ -36,33 +31,22 @@ public class UserSettingEntry {
|
|||||||
this.valueNum = valueNum;
|
this.valueNum = valueNum;
|
||||||
this.clientKey = clientKey;
|
this.clientKey = clientKey;
|
||||||
this.signature = signature;
|
this.signature = signature;
|
||||||
this.synced = synced;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = login; }
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
|
||||||
public int getSettingType() { return settingType; }
|
public int getSettingType() { return settingType; }
|
||||||
public void setSettingType(int settingType) { this.settingType = settingType; }
|
public void setSettingType(int settingType) { this.settingType = settingType; }
|
||||||
|
|
||||||
public String getSettingKey() { return settingKey; }
|
public String getSettingKey() { return settingKey; }
|
||||||
public void setSettingKey(String settingKey) { this.settingKey = settingKey; }
|
public void setSettingKey(String settingKey) { this.settingKey = settingKey; }
|
||||||
|
|
||||||
public long getTimeMs() { return timeMs; }
|
public long getTimeMs() { return timeMs; }
|
||||||
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||||
|
|
||||||
public String getValueText() { return valueText; }
|
public String getValueText() { return valueText; }
|
||||||
public void setValueText(String valueText) { this.valueText = valueText; }
|
public void setValueText(String valueText) { this.valueText = valueText; }
|
||||||
|
|
||||||
public long getValueNum() { return valueNum; }
|
public long getValueNum() { return valueNum; }
|
||||||
public void setValueNum(long valueNum) { this.valueNum = valueNum; }
|
public void setValueNum(long valueNum) { this.valueNum = valueNum; }
|
||||||
|
|
||||||
public String getClientKey() { return clientKey; }
|
public String getClientKey() { return clientKey; }
|
||||||
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
|
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
|
||||||
|
|
||||||
public String getSignature() { return signature; }
|
public String getSignature() { return signature; }
|
||||||
public void setSignature(String signature) { this.signature = 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)
|
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
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
schema_version = EXCLUDED.schema_version,
|
schema_version = EXCLUDED.schema_version,
|
||||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
@@ -177,7 +177,7 @@ BEGIN
|
|||||||
ELSE u.access_servers_json::jsonb
|
ELSE u.access_servers_json::jsonb
|
||||||
END
|
END
|
||||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
WHERE ord <= 2
|
WHERE ord = 1
|
||||||
) AS access_server
|
) AS access_server
|
||||||
JOIN solana_user_pda_current s
|
JOIN solana_user_pda_current s
|
||||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||||
@@ -220,7 +220,7 @@ BEGIN
|
|||||||
ELSE u.access_servers_json::jsonb
|
ELSE u.access_servers_json::jsonb
|
||||||
END
|
END
|
||||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
WHERE ord <= 2
|
WHERE ord = 1
|
||||||
) AS access_server
|
) AS access_server
|
||||||
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||||
LOOP
|
LOOP
|
||||||
@@ -416,35 +416,15 @@ CREATE TABLE IF NOT EXISTS user_settings (
|
|||||||
value_num BIGINT NOT NULL DEFAULT 0,
|
value_num BIGINT NOT NULL DEFAULT 0,
|
||||||
client_key TEXT NOT NULL DEFAULT '',
|
client_key TEXT NOT NULL DEFAULT '',
|
||||||
signature TEXT NOT NULL DEFAULT '',
|
signature TEXT NOT NULL DEFAULT '',
|
||||||
synced BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
UNIQUE (login, setting_type, setting_key)
|
UNIQUE (login, setting_type, setting_key)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_settings_login
|
CREATE INDEX IF NOT EXISTS idx_user_settings_login
|
||||||
ON 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
|
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
|
||||||
ON user_settings(setting_type, setting_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 (
|
CREATE TABLE IF NOT EXISTS ip_geo_cache (
|
||||||
ip TEXT PRIMARY KEY,
|
ip TEXT PRIMARY KEY,
|
||||||
geo TEXT,
|
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
|
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);
|
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.
|
-- неизменяемыми; эта таблица описывает только сетевую доставку пары 1/2 или 3/4.
|
||||||
CREATE TABLE IF NOT EXISTS dm_delivery_state (
|
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
|
CREATE INDEX IF NOT EXISTS idx_dm_delivery_state_base
|
||||||
ON dm_delivery_state(base_key, from_login);
|
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 (
|
CREATE TABLE IF NOT EXISTS message_views_state (
|
||||||
viewer_login TEXT NOT NULL,
|
viewer_login TEXT NOT NULL,
|
||||||
to_bch_name 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_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge)
|
||||||
public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь
|
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)
|
// Полный пользователь из runtime БД (current users / solana_user_pda_current)
|
||||||
private CurrentUserEntry currentUserEntry;
|
private CurrentUserEntry currentUserEntry;
|
||||||
|
|
||||||
@@ -73,6 +78,12 @@ public class ConnectionContext {
|
|||||||
*/
|
*/
|
||||||
private int authenticationStatus = AUTH_STATUS_NONE;
|
private int authenticationStatus = AUTH_STATUS_NONE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LOCAL — этот сервер является первым access server пользователя.
|
||||||
|
* REMOTE — пользователь криптографически авторизован, но его access server другой.
|
||||||
|
*/
|
||||||
|
private int userServerScope = USER_SERVER_SCOPE_NONE;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebSocket-сессия Jetty для данного подключения.
|
* WebSocket-сессия Jetty для данного подключения.
|
||||||
* Нужна, чтобы через ConnectionContext можно было отправлять сообщения клиенту.
|
* Нужна, чтобы через ConnectionContext можно было отправлять сообщения клиенту.
|
||||||
@@ -197,6 +208,28 @@ public class ConnectionContext {
|
|||||||
return authenticationStatus == AUTH_STATUS_NONE;
|
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() {
|
public void reset() {
|
||||||
currentUserEntry = null;
|
currentUserEntry = null;
|
||||||
activeSessionEntry = null;
|
activeSessionEntry = null;
|
||||||
@@ -209,6 +242,7 @@ public class ConnectionContext {
|
|||||||
sessionLoginNonceExpiresAtMs = 0;
|
sessionLoginNonceExpiresAtMs = 0;
|
||||||
|
|
||||||
authenticationStatus = AUTH_STATUS_NONE;
|
authenticationStatus = AUTH_STATUS_NONE;
|
||||||
|
userServerScope = USER_SERVER_SCOPE_NONE;
|
||||||
wsSession = null;
|
wsSession = null;
|
||||||
serverConnection = false;
|
serverConnection = false;
|
||||||
remoteServerLogin = null;
|
remoteServerLogin = null;
|
||||||
@@ -222,6 +256,7 @@ public class ConnectionContext {
|
|||||||
"login='" + getLogin() + '\'' +
|
"login='" + getLogin() + '\'' +
|
||||||
", sessionId=" + sessionId +
|
", sessionId=" + sessionId +
|
||||||
", authenticationStatus=" + authenticationStatus +
|
", 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.JsonMessageHandler;
|
||||||
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler;
|
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_CloseActiveSession_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler;
|
import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_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 ---
|
// --- auth entities ---
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request;
|
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_CloseActiveSession_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_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;
|
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_CallSignalToSession_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_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_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_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_SendSignal_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_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_CallSignalToSession_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_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_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_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_SendSignal_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_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()),
|
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
|
||||||
|
|
||||||
// --- auth ---
|
// --- auth ---
|
||||||
|
Map.entry("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()),
|
||||||
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
|
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
|
||||||
Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()),
|
Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()),
|
||||||
Map.entry("CloseActiveSession", new Net_CloseActiveSession_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("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||||
Map.entry("SendTestWebPush", new Net_SendTestWebPush_Handler()),
|
Map.entry("SendTestWebPush", new Net_SendTestWebPush_Handler()),
|
||||||
Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
|
Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
|
||||||
Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()),
|
|
||||||
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
||||||
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
||||||
Map.entry("DeleteConversation", new Net_DeleteConversation_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("GetDirectMessages", new Net_GetDirectMessages_Handler()),
|
||||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||||
@@ -255,6 +245,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
|
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
|
||||||
|
|
||||||
// --- auth ---
|
// --- auth ---
|
||||||
|
Map.entry("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class),
|
||||||
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
|
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
|
||||||
Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class),
|
Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class),
|
||||||
Map.entry("CloseActiveSession", Net_CloseActiveSession_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("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||||
Map.entry("SendTestWebPush", Net_SendTestWebPush_Request.class),
|
Map.entry("SendTestWebPush", Net_SendTestWebPush_Request.class),
|
||||||
Map.entry("SendMessagePair", Net_SendMessagePair_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("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
||||||
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
||||||
Map.entry("DeleteConversation", Net_DeleteConversation_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("GetDirectMessages", Net_GetDirectMessages_Request.class),
|
||||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_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));
|
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) Вызываем хэндлер
|
// 5) Вызываем хэндлер
|
||||||
Net_Response response;
|
Net_Response response;
|
||||||
try {
|
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.setCurrentUser(solanaUserEntry);
|
||||||
|
ctx.setUserServerScope(AuthAccessScopeSupport.connectionScopeForLogin(solanaUserEntry.getLogin()));
|
||||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS);
|
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS);
|
||||||
|
|
||||||
byte[] buf = new byte[32];
|
byte[] buf = new byte[32];
|
||||||
|
|||||||
@@ -414,10 +414,15 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
|||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Между AuthChallenge и CreateAuthSession routing мог измениться,
|
||||||
|
// поэтому окончательный LOCAL/REMOTE scope вычисляем повторно.
|
||||||
|
int userServerScope = AuthAccessScopeSupport.connectionScopeForLogin(canonicalLogin);
|
||||||
|
|
||||||
// --- обновляем контекст ---
|
// --- обновляем контекст ---
|
||||||
ctx.setActiveSession(activeSessionEntry);
|
ctx.setActiveSession(activeSessionEntry);
|
||||||
ctx.setSessionId(sessionId);
|
ctx.setSessionId(sessionId);
|
||||||
ctx.setAuthNonce(null);
|
ctx.setAuthNonce(null);
|
||||||
|
ctx.setUserServerScope(userServerScope);
|
||||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||||
|
|
||||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||||
@@ -428,6 +433,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
|||||||
resp.setRequestId(req.getRequestId());
|
resp.setRequestId(req.getRequestId());
|
||||||
resp.setStatus(WireCodes.Status.OK);
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
resp.setSessionId(sessionId);
|
resp.setSessionId(sessionId);
|
||||||
|
resp.setConnectionScope(AuthAccessScopeSupport.connectionScopeName(userServerScope));
|
||||||
return resp;
|
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.setClientPlatform(clientPlatform);
|
||||||
session.setUserLanguage(userLanguage);
|
session.setUserLanguage(userLanguage);
|
||||||
|
|
||||||
|
int userServerScope = AuthAccessScopeSupport.connectionScopeForLogin(user.getLogin());
|
||||||
|
|
||||||
// ctx
|
// ctx
|
||||||
ctx.setActiveSession(session);
|
ctx.setActiveSession(session);
|
||||||
ctx.setCurrentUser(user);
|
ctx.setCurrentUser(user);
|
||||||
ctx.setSessionId(sessionId);
|
ctx.setSessionId(sessionId);
|
||||||
|
ctx.setUserServerScope(userServerScope);
|
||||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||||
|
|
||||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||||
@@ -306,6 +309,7 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
|||||||
resp.setRequestId(req.getRequestId());
|
resp.setRequestId(req.getRequestId());
|
||||||
resp.setStatus(WireCodes.Status.OK);
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
resp.setStoragePwd(session.getStoragePwd());
|
resp.setStoragePwd(session.getStoragePwd());
|
||||||
|
resp.setConnectionScope(AuthAccessScopeSupport.connectionScopeName(userServerScope));
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
String canonicalLogin = user.getLogin();
|
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);
|
EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin);
|
||||||
boolean enabled = settings == null || settings.isEnabled();
|
boolean enabled = settings == null || settings.isEnabled();
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
@@ -100,14 +110,6 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
|
|||||||
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
|
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
|
||||||
int ttlSeconds = EspPairingSupport.DEFAULT_TTL_SECONDS;
|
int ttlSeconds = EspPairingSupport.DEFAULT_TTL_SECONDS;
|
||||||
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
|
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
|
||||||
if (approverConnections.isEmpty()) {
|
|
||||||
return NetExceptionResponseFactory.error(
|
|
||||||
req,
|
|
||||||
422,
|
|
||||||
"PAIRING_NO_TRUSTED_SESSION_ONLINE",
|
|
||||||
"Нет ни одной активной доверенной сессии пользователя в сети"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
|
EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint(
|
||||||
canonicalLogin,
|
canonicalLogin,
|
||||||
requesterSessionKey,
|
requesterSessionKey,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public final class SolanaUserPdaImportService {
|
|||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||||
private static final String MAGIC = "SHiNE";
|
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() {}
|
private SolanaUserPdaImportService() {}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class Net_CreateAuthSession_Response extends Net_Response {
|
|||||||
|
|
||||||
/** Идентификатор сессии, base64 от 32 байт. */
|
/** Идентификатор сессии, base64 от 32 байт. */
|
||||||
private String sessionId;
|
private String sessionId;
|
||||||
|
private String connectionScope;
|
||||||
|
|
||||||
public String getSessionId() {
|
public String getSessionId() {
|
||||||
return sessionId;
|
return sessionId;
|
||||||
@@ -30,4 +31,7 @@ public class Net_CreateAuthSession_Response extends Net_Response {
|
|||||||
public void setSessionId(String sessionId) {
|
public void setSessionId(String sessionId) {
|
||||||
this.sessionId = 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 {
|
public class Net_SessionLogin_Response extends Net_Response {
|
||||||
|
|
||||||
private String storagePwd;
|
private String storagePwd;
|
||||||
|
private String connectionScope;
|
||||||
|
|
||||||
public String getStoragePwd() {
|
public String getStoragePwd() {
|
||||||
return storagePwd;
|
return storagePwd;
|
||||||
@@ -17,4 +18,7 @@ public class Net_SessionLogin_Response extends Net_Response {
|
|||||||
public void setStoragePwd(String storagePwd) {
|
public void setStoragePwd(String storagePwd) {
|
||||||
this.storagePwd = 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.setValue_num(entry.getValueNum());
|
||||||
resp.setClient_key(entry.getClientKey());
|
resp.setClient_key(entry.getClientKey());
|
||||||
resp.setSignature(entry.getSignature());
|
resp.setSignature(entry.getSignature());
|
||||||
resp.setSynced(entry.isSynced());
|
|
||||||
return resp;
|
return resp;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("GetUserSetting failed", e);
|
log.error("GetUserSetting failed", e);
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ public class Net_ListUserSettings_Handler implements JsonMessageHandler {
|
|||||||
item.setValue_num(e.getValueNum());
|
item.setValue_num(e.getValueNum());
|
||||||
item.setClient_key(e.getClientKey());
|
item.setClient_key(e.getClientKey());
|
||||||
item.setSignature(e.getSignature());
|
item.setSignature(e.getSignature());
|
||||||
item.setSynced(e.isSynced());
|
|
||||||
items.add(item);
|
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.handlers.userSettings.entyties.Net_UpsertUserSetting_Response;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import server.sync.RemoteUserSettingsSyncClient;
|
|
||||||
import shine.db.DbController;
|
import shine.db.DbController;
|
||||||
import shine.db.dao.CurrentUsersDAO;
|
import shine.db.dao.CurrentUsersDAO;
|
||||||
import shine.db.dao.UserAccessServersCurrentDAO;
|
|
||||||
import shine.db.dao.UserSettingsDAO;
|
import shine.db.dao.UserSettingsDAO;
|
||||||
import shine.db.entities.CurrentUserEntry;
|
import shine.db.entities.CurrentUserEntry;
|
||||||
import shine.db.entities.UserAccessServerRouteEntry;
|
|
||||||
import shine.db.entities.UserSettingEntry;
|
import shine.db.entities.UserSettingEntry;
|
||||||
import utils.config.ShineSignatureConstants;
|
import utils.config.ShineSignatureConstants;
|
||||||
import utils.config.AppConfig;
|
|
||||||
import utils.crypto.Ed25519Util;
|
import utils.crypto.Ed25519Util;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
|
/** Приём и строгая проверка локальной пользовательской настройки. */
|
||||||
public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
||||||
private static final Logger log = LoggerFactory.getLogger(Net_UpsertUserSetting_Handler.class);
|
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
|
@Override
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
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();
|
long valueNum = req.getValue_num() == null ? 0L : req.getValue_num();
|
||||||
String clientKeyB64 = req.getClient_key().trim();
|
String clientKeyB64 = req.getClient_key().trim();
|
||||||
String signatureB64 = req.getSignature().trim();
|
String signatureB64 = req.getSignature().trim();
|
||||||
boolean syncDelivery = Boolean.TRUE.equals(req.getSync_delivery());
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
byte[] pubKey32;
|
byte[] pubKey32;
|
||||||
@@ -63,7 +56,8 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
|||||||
pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key");
|
pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key");
|
||||||
sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature");
|
sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature");
|
||||||
} catch (IllegalArgumentException e) {
|
} 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
|
String signText = ShineSignatureConstants.USER_SETTINGS_PREFIX
|
||||||
@@ -74,65 +68,29 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
|||||||
+ escapePart(valueText) + '|'
|
+ escapePart(valueText) + '|'
|
||||||
+ valueNum;
|
+ valueNum;
|
||||||
|
|
||||||
DbController db = DbController.getInstance();
|
try (Connection c = DbController.getInstance().getConnection()) {
|
||||||
CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance();
|
CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(c, login);
|
||||||
UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance();
|
|
||||||
|
|
||||||
try (Connection c = db.getConnection()) {
|
|
||||||
CurrentUserEntry user = usersDAO.getByLogin(c, login);
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||||
}
|
}
|
||||||
String userClientKey = user.getClientKey();
|
String userClientKey = user.getClientKey();
|
||||||
if (userClientKey == null || userClientKey.isBlank()) {
|
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)) {
|
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 не соответствует пользователю");
|
||||||
}
|
}
|
||||||
|
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
|
||||||
boolean signatureOk = Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32);
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||||
if (!signatureOk) {
|
"INVALID_SIGNATURE", "Подпись настройки не прошла проверку");
|
||||||
// В логах t2/legacy уже виден системный разброс подписей для user_settings.
|
|
||||||
// Не блокируем запись cursor-настроек, если запрос пришёл от текущего владельца ключа.
|
|
||||||
log.warn("user_settings signature verification failed, accepting fallback: login={} settingType={} settingKey={}",
|
|
||||||
login, settingType, settingKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
UserSettingEntry entry = new UserSettingEntry(
|
UserSettingEntry entry = new UserSettingEntry(
|
||||||
login,
|
login, settingType, settingKey, timeMs,
|
||||||
settingType,
|
valueText, valueNum, clientKeyB64, signatureB64);
|
||||||
settingKey,
|
UserSettingsDAO.getInstance().upsertIfNewer(c, entry);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Net_UpsertUserSetting_Response resp = new Net_UpsertUserSetting_Response();
|
Net_UpsertUserSetting_Response resp = new Net_UpsertUserSetting_Response();
|
||||||
resp.setOp(req.getOp());
|
resp.setOp(req.getOp());
|
||||||
@@ -142,15 +100,18 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
|
|||||||
resp.setSetting_type(settingType);
|
resp.setSetting_type(settingType);
|
||||||
resp.setSetting_key(settingKey);
|
resp.setSetting_key(settingKey);
|
||||||
resp.setTime_ms(timeMs);
|
resp.setTime_ms(timeMs);
|
||||||
resp.setSynced(syncDelivery || changed == 0);
|
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
log.error("UpsertUserSetting DB error", 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) {
|
} catch (Exception e) {
|
||||||
log.error("UpsertUserSetting failed", 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 Long value_num;
|
||||||
private String client_key;
|
private String client_key;
|
||||||
private String signature;
|
private String signature;
|
||||||
private Boolean synced;
|
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = 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 String getSignature() { return signature; }
|
||||||
public void setSignature(String signature) { this.signature = 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 Long value_num;
|
||||||
private String client_key;
|
private String client_key;
|
||||||
private String signature;
|
private String signature;
|
||||||
private Boolean synced;
|
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = 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 void setClient_key(String client_key) { this.client_key = client_key; }
|
||||||
public String getSignature() { return signature; }
|
public String getSignature() { return signature; }
|
||||||
public void setSignature(String signature) { this.signature = 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 Long value_num;
|
||||||
private String client_key;
|
private String client_key;
|
||||||
private String signature;
|
private String signature;
|
||||||
private Boolean sync_delivery;
|
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = 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 String getSignature() { return signature; }
|
||||||
public void setSignature(String signature) { this.signature = 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 Integer setting_type;
|
||||||
private String setting_key;
|
private String setting_key;
|
||||||
private Long time_ms;
|
private Long time_ms;
|
||||||
private Boolean synced;
|
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = 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 Long getTime_ms() { return time_ms; }
|
||||||
public void setTime_ms(Long time_ms) { this.time_ms = 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 server.sync.DmFederationService;
|
||||||
import shine.db.dao.SignedMessagesDAO;
|
import shine.db.dao.SignedMessagesDAO;
|
||||||
import shine.db.dao.DmDeliveryStateDAO;
|
import shine.db.dao.DmDeliveryStateDAO;
|
||||||
import shine.db.dao.DmSyncOutboxDAO;
|
|
||||||
import shine.db.entities.SignedMessageEntry;
|
import shine.db.entities.SignedMessageEntry;
|
||||||
|
|
||||||
public class Net_DeleteConversation_Handler implements JsonMessageHandler {
|
public class Net_DeleteConversation_Handler implements JsonMessageHandler {
|
||||||
@@ -46,7 +45,6 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler {
|
|||||||
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (status.applied()) {
|
if (status.applied()) {
|
||||||
DmDeliveryStateDAO.getInstance().removeMissingMessages();
|
DmDeliveryStateDAO.getInstance().removeMissingMessages();
|
||||||
recordOutboxForLocalOwners(entry);
|
|
||||||
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block);
|
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block);
|
||||||
}
|
}
|
||||||
if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) {
|
if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) {
|
||||||
@@ -68,14 +66,4 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler {
|
|||||||
return s == null || s.isBlank();
|
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 server.sync.DmFederationService;
|
||||||
import shine.db.dao.SignedMessagesDAO;
|
import shine.db.dao.SignedMessagesDAO;
|
||||||
import shine.db.dao.DmDeliveryStateDAO;
|
import shine.db.dao.DmDeliveryStateDAO;
|
||||||
import shine.db.dao.DmSyncOutboxDAO;
|
|
||||||
import shine.db.entities.SignedMessageEntry;
|
import shine.db.entities.SignedMessageEntry;
|
||||||
|
|
||||||
public class Net_DeleteMessage_Handler implements JsonMessageHandler {
|
public class Net_DeleteMessage_Handler implements JsonMessageHandler {
|
||||||
@@ -46,7 +45,6 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler {
|
|||||||
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (status.applied()) {
|
if (status.applied()) {
|
||||||
DmDeliveryStateDAO.getInstance().removeByBaseKey(entry.getBaseKey());
|
DmDeliveryStateDAO.getInstance().removeByBaseKey(entry.getBaseKey());
|
||||||
recordOutboxForLocalOwners(entry);
|
|
||||||
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block);
|
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block);
|
||||||
}
|
}
|
||||||
if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) {
|
if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) {
|
||||||
@@ -68,14 +66,4 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler {
|
|||||||
return s == null || s.isBlank();
|
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.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
import server.sync.DmDeliveryCoordinator;
|
import server.sync.DmDeliveryCoordinator;
|
||||||
import shine.db.dao.DmSyncOutboxDAO;
|
|
||||||
import shine.db.dao.SignedMessagesDAO;
|
import shine.db.dao.SignedMessagesDAO;
|
||||||
import shine.db.entities.SignedMessageEntry;
|
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())) {
|
if (stored == null || !Arrays.equals(stored.getRawBlock(), entry.getRawBlock())) {
|
||||||
return NetExceptionResponseFactory.error(req, 409, "STALE_MESSAGE_REVISION", "На сервере уже есть более новая ревизия сообщения");
|
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();
|
Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response();
|
||||||
resp.setOp(req.getOp());
|
resp.setOp(req.getOp());
|
||||||
resp.setRequestId(req.getRequestId());
|
resp.setRequestId(req.getRequestId());
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import server.logic.ws_protocol.WireCodes;
|
|||||||
import server.sync.DmDeliveryCoordinator;
|
import server.sync.DmDeliveryCoordinator;
|
||||||
import server.sync.DmFederationService;
|
import server.sync.DmFederationService;
|
||||||
import shine.db.dao.DmDeliveryStateDAO;
|
import shine.db.dao.DmDeliveryStateDAO;
|
||||||
import shine.db.dao.DmSyncOutboxDAO;
|
|
||||||
import shine.db.dao.SignedMessagesDAO;
|
import shine.db.dao.SignedMessagesDAO;
|
||||||
import shine.db.entities.DmDeliveryStateEntry;
|
import shine.db.entities.DmDeliveryStateEntry;
|
||||||
import shine.db.entities.SignedMessageEntry;
|
import shine.db.entities.SignedMessageEntry;
|
||||||
@@ -48,12 +47,10 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
SignedMessageEntry incomingEntry;
|
SignedMessageEntry incomingEntry;
|
||||||
SignedMessageEntry outgoingEntry;
|
SignedMessageEntry outgoingEntry;
|
||||||
boolean fromPeer = !isBlank(req.getSourceServerLogin());
|
|
||||||
try {
|
try {
|
||||||
String sourceApi = fromPeer ? "ReceiveOutcomingMessage" : "SendMessagePair";
|
|
||||||
String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null;
|
String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null;
|
||||||
incomingEntry = SignedMessagesCore.toEntry(incoming, sourceApi, originSessionId);
|
incomingEntry = SignedMessagesCore.toEntry(incoming, "SendMessagePair", originSessionId);
|
||||||
outgoingEntry = SignedMessagesCore.toEntry(outgoing, sourceApi, originSessionId);
|
outgoingEntry = SignedMessagesCore.toEntry(outgoing, "SendMessagePair", originSessionId);
|
||||||
} catch (IllegalArgumentException ex) {
|
} catch (IllegalArgumentException ex) {
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
|
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);
|
String eventId = DmDeliveryIds.forEntry(outgoingEntry);
|
||||||
long nowMs = System.currentTimeMillis();
|
long nowMs = System.currentTimeMillis();
|
||||||
long acceptedAtMs;
|
long acceptedAtMs;
|
||||||
long signedAtMs = Math.max(outgoing.timeMs,
|
acceptedAtMs = nowMs;
|
||||||
Math.max(outgoing.revisionTimeMs, outgoing.reencryptedAtMs));
|
|
||||||
acceptedAtMs = fromPeer && signedAtMs > 0L ? Math.min(nowMs, signedAtMs) : nowMs;
|
|
||||||
long expiresAtMs = acceptedAtMs + 60L * 60L * 1000L;
|
long expiresAtMs = acceptedAtMs + 60L * 60L * 1000L;
|
||||||
int initialState = DmDeliveryStateEntry.ACCEPTED;
|
int initialState = DmDeliveryStateEntry.ACCEPTED;
|
||||||
DmDeliveryStateEntry delivery = DmDeliveryStateDAO.getInstance().upsertPair(
|
DmDeliveryStateEntry delivery = DmDeliveryStateDAO.getInstance().upsertPair(
|
||||||
outgoingEntry.getMessageKey(), eventId, outgoingEntry.getBaseKey(),
|
outgoingEntry.getMessageKey(), eventId, outgoingEntry.getBaseKey(),
|
||||||
outgoingEntry.getFromLogin(), outgoingEntry.getToLogin(), incomingEntry.getMessageKey(),
|
outgoingEntry.getFromLogin(), outgoingEntry.getToLogin(), incomingEntry.getMessageKey(),
|
||||||
acceptedAtMs, expiresAtMs, initialState,
|
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 (delivery != null && pairStatus.applied()) {
|
||||||
if (fromPeer) {
|
// Первая доставка на единственный access-сервер получателя
|
||||||
DmDeliveryCoordinator.assistReceivedPairAsync(delivery.getEventId());
|
// выполняется до ответа клиенту.
|
||||||
} else {
|
DmDeliveryCoordinator.processDueEntry(delivery);
|
||||||
// Первая доставка выполняется до ответа клиенту. Два сервера
|
delivery = DmDeliveryStateDAO.getInstance()
|
||||||
// получателя вызываются параллельно внутри координатора.
|
.getByOutgoingMessageKey(outgoingEntry.getMessageKey());
|
||||||
DmDeliveryCoordinator.processDueEntry(delivery);
|
|
||||||
delivery = DmDeliveryStateDAO.getInstance()
|
|
||||||
.getByOutgoingMessageKey(outgoingEntry.getMessageKey());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
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 {
|
public class Net_SendMessagePair_Request extends Net_Request {
|
||||||
private String incomingBlobB64;
|
private String incomingBlobB64;
|
||||||
private String outgoingBlobB64;
|
private String outgoingBlobB64;
|
||||||
private String sourceServerLogin;
|
|
||||||
|
|
||||||
public String getIncomingBlobB64() { return incomingBlobB64; }
|
public String getIncomingBlobB64() { return incomingBlobB64; }
|
||||||
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
|
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
|
||||||
public String getOutgoingBlobB64() { return outgoingBlobB64; }
|
public String getOutgoingBlobB64() { return outgoingBlobB64; }
|
||||||
public void setOutgoingBlobB64(String outgoingBlobB64) { this.outgoingBlobB64 = 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 org.slf4j.LoggerFactory;
|
||||||
import server.logic.ws_protocol.JSON.messages.DmDeliveryRealtime;
|
import server.logic.ws_protocol.JSON.messages.DmDeliveryRealtime;
|
||||||
import shine.db.dao.DmDeliveryStateDAO;
|
import shine.db.dao.DmDeliveryStateDAO;
|
||||||
import shine.db.dao.DmSyncOutboxDAO;
|
|
||||||
import shine.db.dao.SignedMessagesDAO;
|
import shine.db.dao.SignedMessagesDAO;
|
||||||
import shine.db.dao.UserAccessServersCurrentDAO;
|
import shine.db.dao.UserAccessServersCurrentDAO;
|
||||||
import shine.db.entities.DmDeliveryStateEntry;
|
import shine.db.entities.DmDeliveryStateEntry;
|
||||||
@@ -14,21 +13,14 @@ import utils.config.AppConfig;
|
|||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
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 {
|
public final class DmDeliveryCoordinator {
|
||||||
private static final Logger log = LoggerFactory.getLogger(DmDeliveryCoordinator.class);
|
private static final Logger log = LoggerFactory.getLogger(DmDeliveryCoordinator.class);
|
||||||
@@ -40,21 +32,10 @@ public final class DmDeliveryCoordinator {
|
|||||||
60L * 60_000L
|
60L * 60_000L
|
||||||
};
|
};
|
||||||
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
|
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 DmDeliveryStateDAO DELIVERY_DAO = DmDeliveryStateDAO.getInstance();
|
||||||
private static final DmSyncOutboxDAO OUTBOX_DAO = DmSyncOutboxDAO.getInstance();
|
|
||||||
private static final SignedMessagesDAO MESSAGES_DAO = SignedMessagesDAO.getInstance();
|
private static final SignedMessagesDAO MESSAGES_DAO = SignedMessagesDAO.getInstance();
|
||||||
private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.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() {}
|
private DmDeliveryCoordinator() {}
|
||||||
|
|
||||||
@@ -63,10 +44,6 @@ public final class DmDeliveryCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void processDueEntry(DmDeliveryStateEntry snapshot) {
|
public static void processDueEntry(DmDeliveryStateEntry snapshot) {
|
||||||
processDueEntry(snapshot, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void processDueEntry(DmDeliveryStateEntry snapshot, boolean allowInitialHandoff) {
|
|
||||||
if (snapshot == null) return;
|
if (snapshot == null) return;
|
||||||
try {
|
try {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
@@ -79,27 +56,9 @@ public final class DmDeliveryCoordinator {
|
|||||||
|
|
||||||
boolean finalAttempt = snapshot.getAttemptIndex() >= ATTEMPT_OFFSETS_MS.length - 1
|
boolean finalAttempt = snapshot.getAttemptIndex() >= ATTEMPT_OFFSETS_MS.length - 1
|
||||||
|| now >= current.getDeliveryExpiresAtMs();
|
|| now >= current.getDeliveryExpiresAtMs();
|
||||||
|
DmDeliveryStateEntry afterAttempt = attemptPrimaryRecipientRoute(
|
||||||
// Перед попытками на 5-й, 25-й и 60-й минутах сначала спрашиваем
|
|
||||||
// второй сервер отправителя: возможно, он уже доставил сообщение.
|
|
||||||
if (current.getDeliveryState() == DmDeliveryStateEntry.ACCEPTED
|
|
||||||
&& (snapshot.getAttemptIndex() >= 2 || finalAttempt)) {
|
|
||||||
current = acceptPeerDeliveryStatus(current);
|
|
||||||
if (current != null && current.isDelivered()) {
|
|
||||||
DmDeliveryRealtime.notifySender(current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DmDeliveryStateEntry afterAttempt = attemptRecipientRoutes(
|
|
||||||
current, finalAttempt ? null : nextAttemptAtMs);
|
current, finalAttempt ? null : nextAttemptAtMs);
|
||||||
|
|
||||||
// После первой попытки сразу передаём полную пару второму серверу
|
|
||||||
// отправителя старой операцией ReceiveOutcomingMessage.
|
|
||||||
if (allowInitialHandoff && snapshot.getAttemptIndex() == 0 && afterAttempt != null) {
|
|
||||||
afterAttempt = handoffPairToSenderPeer(afterAttempt);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalAttempt && afterAttempt != null && !afterAttempt.isDelivered()) {
|
if (finalAttempt && afterAttempt != null && !afterAttempt.isDelivered()) {
|
||||||
afterAttempt = DELIVERY_DAO.finishAtExpiry(afterAttempt.getEventId(), System.currentTimeMillis());
|
afterAttempt = DELIVERY_DAO.finishAtExpiry(afterAttempt.getEventId(), System.currentTimeMillis());
|
||||||
}
|
}
|
||||||
@@ -109,28 +68,18 @@ public final class DmDeliveryCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void assistReceivedPairAsync(String eventId) {
|
private static DmDeliveryStateEntry attemptPrimaryRecipientRoute(
|
||||||
if (eventId == null || eventId.isBlank()) return;
|
|
||||||
ASSIST_EXECUTOR.execute(() -> {
|
|
||||||
try {
|
|
||||||
DmDeliveryStateEntry row = DELIVERY_DAO.getByEventId(eventId);
|
|
||||||
if (row != null) processDueEntry(row, false);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("DM peer assist failed: eventId={}", eventId, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DmDeliveryStateEntry attemptRecipientRoutes(
|
|
||||||
DmDeliveryStateEntry current,
|
DmDeliveryStateEntry current,
|
||||||
Long nextAttemptAtMs
|
Long nextAttemptAtMs
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
if (current == null) return null;
|
if (current == null) return null;
|
||||||
List<UserAccessServerRouteEntry> routes = cappedRoutes(current.getToLogin());
|
UserAccessServerRouteEntry route = primaryRoute(current.getToLogin());
|
||||||
String routesHash = routesHash(routes);
|
String routesHash = routesHash(route);
|
||||||
String alreadyDelivered = normalize(current.getDeliveredServerLogin());
|
if (route == null) {
|
||||||
List<String> acceptedLogins = new ArrayList<>();
|
return DELIVERY_DAO.updateAfterAttempt(
|
||||||
if (alreadyDelivered != null) acceptedLogins.add(alreadyDelivered);
|
current.getEventId(), DmDeliveryStateEntry.ACCEPTED, null,
|
||||||
|
routesHash, nextAttemptAtMs, "RECIPIENT_ACCESS_SERVER_NOT_FOUND", System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
|
||||||
SignedMessageEntry incoming = MESSAGES_DAO.getByMessageKey(current.getIncomingMessageKey());
|
SignedMessageEntry incoming = MESSAGES_DAO.getByMessageKey(current.getIncomingMessageKey());
|
||||||
if (incoming == null || incoming.getRawBlock() == null) {
|
if (incoming == null || incoming.getRawBlock() == null) {
|
||||||
@@ -138,138 +87,51 @@ public final class DmDeliveryCoordinator {
|
|||||||
current.getEventId(), current.getDeliveryState(), current.getDeliveredServerLogin(),
|
current.getEventId(), current.getDeliveryState(), current.getDeliveredServerLogin(),
|
||||||
routesHash, nextAttemptAtMs, "INCOMING_BLOB_NOT_FOUND", System.currentTimeMillis());
|
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;
|
String routeLogin = normalize(route.getServerLogin());
|
||||||
for (int i = 0; i < submitted && acceptedLogins.isEmpty(); i++) {
|
|
||||||
RouteAttempt result = completion.take().get();
|
|
||||||
if (result.error() == null) {
|
|
||||||
acceptedLogins.add(result.serverLogin());
|
|
||||||
} else {
|
|
||||||
lastError = result.error();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int nextState = acceptedLogins.isEmpty()
|
|
||||||
? DmDeliveryStateEntry.ACCEPTED
|
|
||||||
: DmDeliveryStateEntry.DELIVERED;
|
|
||||||
String oneLogin = acceptedLogins.isEmpty() ? null : acceptedLogins.get(0);
|
|
||||||
return DELIVERY_DAO.updateAfterAttempt(
|
|
||||||
current.getEventId(), nextState, oneLogin, routesHash,
|
|
||||||
nextAttemptAtMs, lastError, System.currentTimeMillis());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DmDeliveryStateEntry handoffPairToSenderPeer(DmDeliveryStateEntry current) throws Exception {
|
|
||||||
UserAccessServerRouteEntry peer = senderPeer(current.getFromLogin());
|
|
||||||
if (peer == null) return current;
|
|
||||||
SignedMessageEntry incoming = MESSAGES_DAO.getByMessageKey(current.getIncomingMessageKey());
|
|
||||||
SignedMessageEntry outgoing = MESSAGES_DAO.getByMessageKey(current.getOutgoingMessageKey());
|
|
||||||
if (incoming == null || outgoing == null) return current;
|
|
||||||
try {
|
try {
|
||||||
REMOTE.sendMessagePair(
|
if (!routeLogin.equals(ownServerLogin())) {
|
||||||
peer.getServerLogin(),
|
REMOTE.receiveIncomingMessage(
|
||||||
peer.getServerUrl(),
|
routeLogin,
|
||||||
Base64.getEncoder().encodeToString(incoming.getRawBlock()),
|
route.getServerUrl(),
|
||||||
Base64.getEncoder().encodeToString(outgoing.getRawBlock()),
|
Base64.getEncoder().encodeToString(incoming.getRawBlock()),
|
||||||
ownServerLogin());
|
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());
|
|
||||||
}
|
}
|
||||||
return current;
|
return DELIVERY_DAO.updateAfterAttempt(
|
||||||
|
current.getEventId(), DmDeliveryStateEntry.DELIVERED, routeLogin,
|
||||||
|
routesHash, nextAttemptAtMs, null, System.currentTimeMillis());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.info("DM delivery status unavailable: eventId={} server={}", current.getEventId(), peer.getServerLogin());
|
log.info("DM recipient server unavailable: messageKey={} server={}",
|
||||||
return current;
|
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 {
|
public static boolean isLocalAccessServer(String ownerLogin) throws Exception {
|
||||||
|
UserAccessServerRouteEntry route = primaryRoute(ownerLogin);
|
||||||
String own = ownServerLogin();
|
String own = ownServerLogin();
|
||||||
if (own == null) return false;
|
return route != null && own != null && own.equals(normalize(route.getServerLogin()));
|
||||||
for (UserAccessServerRouteEntry route : cappedRoutes(ownerLogin)) {
|
|
||||||
if (own.equals(normalize(route.getServerLogin()))) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean sourceIsOtherAccessServer(String ownerLogin, String sourceServerLogin) throws Exception {
|
|
||||||
String source = normalize(sourceServerLogin);
|
|
||||||
String own = ownServerLogin();
|
|
||||||
if (source == null || source.equals(own)) return false;
|
|
||||||
for (UserAccessServerRouteEntry route : cappedRoutes(ownerLogin)) {
|
|
||||||
if (source.equals(normalize(route.getServerLogin()))) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String currentServerLogin() {
|
public static String currentServerLogin() {
|
||||||
return ownServerLogin();
|
return ownServerLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<UserAccessServerRouteEntry> cappedRoutes(String ownerLogin) throws Exception {
|
private static UserAccessServerRouteEntry primaryRoute(String ownerLogin) throws Exception {
|
||||||
Map<String, UserAccessServerRouteEntry> unique = new LinkedHashMap<>();
|
|
||||||
for (UserAccessServerRouteEntry route : ACCESS_DAO.listByUserLogin(ownerLogin)) {
|
for (UserAccessServerRouteEntry route : ACCESS_DAO.listByUserLogin(ownerLogin)) {
|
||||||
if (route == null || route.getServerUrl() == null || route.getServerUrl().isBlank()) continue;
|
if (route == null || route.getServerUrl() == null || route.getServerUrl().isBlank()) continue;
|
||||||
String login = normalize(route.getServerLogin());
|
if (normalize(route.getServerLogin()) != null) return route;
|
||||||
if (login == null) continue;
|
|
||||||
unique.putIfAbsent(login, route);
|
|
||||||
if (unique.size() == 2) break;
|
|
||||||
}
|
}
|
||||||
return new ArrayList<>(unique.values());
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String routesHash(List<UserAccessServerRouteEntry> routes) throws Exception {
|
private static String routesHash(UserAccessServerRouteEntry route) throws Exception {
|
||||||
List<String> logins = new ArrayList<>();
|
String normalized = route == null ? null : normalize(route.getServerLogin());
|
||||||
for (UserAccessServerRouteEntry route : routes) {
|
String login = normalized == null ? "" : normalized;
|
||||||
String login = normalize(route.getServerLogin());
|
|
||||||
if (login != null) logins.add(login);
|
|
||||||
}
|
|
||||||
logins.sort(String::compareTo);
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
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);
|
StringBuilder out = new StringBuilder(hash.length * 2);
|
||||||
for (byte b : hash) out.append(String.format("%02x", b & 0xff));
|
for (byte b : hash) out.append(String.format("%02x", b & 0xff));
|
||||||
return out.toString();
|
return out.toString();
|
||||||
@@ -280,8 +142,6 @@ public final class DmDeliveryCoordinator {
|
|||||||
return createdAtMs + ATTEMPT_OFFSETS_MS[nextAttemptIndex];
|
return createdAtMs + ATTEMPT_OFFSETS_MS[nextAttemptIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
private record RouteAttempt(String serverLogin, String error) {}
|
|
||||||
|
|
||||||
private static String ownServerLogin() {
|
private static String ownServerLogin() {
|
||||||
return normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
|
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());
|
String text = String.valueOf(e == null ? "unknown" : e.getMessage());
|
||||||
return text.length() <= 500 ? text : text.substring(0, 500);
|
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.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Доставка DM-tombstone на единственные access-серверы обеих сторон. */
|
||||||
public final class DmFederationService {
|
public final class DmFederationService {
|
||||||
private static final Logger log = LoggerFactory.getLogger(DmFederationService.class);
|
private static final Logger log = LoggerFactory.getLogger(DmFederationService.class);
|
||||||
private static final String CONFIG_KEY = "server.SHiNE.login";
|
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() {}
|
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) {
|
public static void fanOutDeleteMessage(String fromLogin, String toLogin, int messageType, String blobB64) {
|
||||||
fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true);
|
fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true);
|
||||||
}
|
}
|
||||||
@@ -73,12 +27,17 @@ public final class DmFederationService {
|
|||||||
fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, false);
|
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 {
|
try {
|
||||||
Map<String, UserAccessServerRouteEntry> routes = routesByLogin(
|
Map<String, UserAccessServerRouteEntry> routes = new LinkedHashMap<>();
|
||||||
UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin),
|
putPrimaryRoute(routes, fromLogin);
|
||||||
UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin)
|
putPrimaryRoute(routes, toLogin);
|
||||||
);
|
|
||||||
String ownServerLogin = ownServerLogin();
|
String ownServerLogin = ownServerLogin();
|
||||||
for (UserAccessServerRouteEntry route : routes.values()) {
|
for (UserAccessServerRouteEntry route : routes.values()) {
|
||||||
if (isOwnServer(route, ownServerLogin)) continue;
|
if (isOwnServer(route, ownServerLogin)) continue;
|
||||||
@@ -89,33 +48,29 @@ public final class DmFederationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} 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);
|
fromLogin, toLogin, messageType, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SafeVarargs
|
private static void putPrimaryRoute(Map<String, UserAccessServerRouteEntry> out, String ownerLogin)
|
||||||
private static Map<String, UserAccessServerRouteEntry> routesByLogin(
|
throws Exception {
|
||||||
List<UserAccessServerRouteEntry>... routeLists
|
List<UserAccessServerRouteEntry> routes =
|
||||||
) {
|
UserAccessServersCurrentDAO.getInstance().listByUserLogin(ownerLogin);
|
||||||
Map<String, UserAccessServerRouteEntry> out = new LinkedHashMap<>();
|
for (UserAccessServerRouteEntry route : routes) {
|
||||||
for (List<UserAccessServerRouteEntry> routeList : routeLists) {
|
if (route == null) continue;
|
||||||
for (UserAccessServerRouteEntry route : routeList) {
|
String login = normalize(route.getServerLogin());
|
||||||
if (route == null) continue;
|
String address = route.getServerUrl() == null ? "" : route.getServerUrl().trim();
|
||||||
String login = normalize(route.getServerLogin());
|
if (login == null || address.isBlank()) continue;
|
||||||
String address = route.getServerUrl() == null ? "" : route.getServerUrl().trim();
|
out.putIfAbsent(login, route);
|
||||||
if (login == null || address.isBlank()) continue;
|
return;
|
||||||
out.putIfAbsent(login, route);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isOwnServer(UserAccessServerRouteEntry route, String ownServerLogin) {
|
private static boolean isOwnServer(UserAccessServerRouteEntry route, String ownServerLogin) {
|
||||||
return ownServerLogin != null
|
return ownServerLogin != null
|
||||||
&& route != null
|
&& route != null
|
||||||
&& route.getServerLogin() != null
|
&& ownServerLogin.equals(normalize(route.getServerLogin()));
|
||||||
&& ownServerLogin.equalsIgnoreCase(route.getServerLogin());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String ownServerLogin() {
|
private static String ownServerLogin() {
|
||||||
@@ -124,7 +79,7 @@ public final class DmFederationService {
|
|||||||
|
|
||||||
private static String normalize(String value) {
|
private static String normalize(String value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
String s = value.trim().toLowerCase();
|
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||||
return s.isEmpty() ? null : s;
|
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
|
ELSE u.access_servers_json::jsonb
|
||||||
END
|
END
|
||||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
WHERE ord <= 2
|
WHERE ord = 1
|
||||||
) AS access_server
|
) AS access_server
|
||||||
JOIN solana_user_pda_current s
|
JOIN solana_user_pda_current s
|
||||||
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
|
||||||
@@ -1138,7 +1138,7 @@ public final class PostgresStorageRepository
|
|||||||
ELSE u.access_servers_json::jsonb
|
ELSE u.access_servers_json::jsonb
|
||||||
END
|
END
|
||||||
) WITH ORDINALITY AS access_server(login_value, ord)
|
) WITH ORDINALITY AS access_server(login_value, ord)
|
||||||
WHERE ord <= 2
|
WHERE ord = 1
|
||||||
) AS access_server
|
) AS access_server
|
||||||
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
|
||||||
LOOP
|
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.debug.DebugApiConfigurator;
|
||||||
import server.sync.BlockchainResyncRecoveryOnStartup;
|
import server.sync.BlockchainResyncRecoveryOnStartup;
|
||||||
import server.sync.PeriodicBlockchainSyncService;
|
import server.sync.PeriodicBlockchainSyncService;
|
||||||
import server.sync.PeriodicDmSyncService;
|
import server.sync.PeriodicDmDeliveryService;
|
||||||
import server.sync.PeriodicUserSettingsSyncService;
|
|
||||||
import server.sync.SolanaUsersSyncStartupService;
|
import server.sync.SolanaUsersSyncStartupService;
|
||||||
import server.sync.SyncServersBootstrapService;
|
import server.sync.SyncServersBootstrapService;
|
||||||
import server.sync.ServerConnectionPool;
|
import server.sync.ServerConnectionPool;
|
||||||
@@ -109,8 +108,7 @@ public final class WsServer {
|
|||||||
Runtime.getRuntime().addShutdownHook(new Thread(
|
Runtime.getRuntime().addShutdownHook(new Thread(
|
||||||
() -> ServerConnectionPool.getInstance().close(),
|
() -> ServerConnectionPool.getInstance().close(),
|
||||||
"server-connection-pool-shutdown"));
|
"server-connection-pool-shutdown"));
|
||||||
PeriodicDmSyncService.startOrLog();
|
PeriodicDmDeliveryService.startOrLog();
|
||||||
PeriodicUserSettingsSyncService.startOrLog();
|
|
||||||
server.join();
|
server.join();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,13 +34,10 @@ sync.importUserProfileFromPartner.enabled=false
|
|||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
server.version=${projectVersion}
|
server.version=${projectVersion}
|
||||||
|
|
||||||
# Доставка и межсерверная синхронизация личных сообщений.
|
# Доставка личных сообщений на единственный access-сервер получателя.
|
||||||
dm.sync.enabled=true
|
dm.delivery.enabled=true
|
||||||
dm.worker.pollSeconds=5
|
dm.worker.pollSeconds=5
|
||||||
dm.worker.dueLimit=100
|
dm.worker.dueLimit=100
|
||||||
dm.sync.batchLimit=200
|
|
||||||
dm.sync.batchMaxBytes=3000000
|
|
||||||
dm.sync.maxPagesPerPeer=20
|
|
||||||
server.pool.pingIdleSeconds=120
|
server.pool.pingIdleSeconds=120
|
||||||
server.pool.pongTimeoutSeconds=15
|
server.pool.pongTimeoutSeconds=15
|
||||||
server.pool.requestTimeoutSeconds=12
|
server.pool.requestTimeoutSeconds=12
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.7.0
|
client.version=1.7.6
|
||||||
server.version=1.6.1
|
server.version=1.6.2
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ done < "$TMP/files.z"
|
|||||||
|
|
||||||
sort -u "$LIST" -o "$LIST"
|
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.
|
# Content scan: fail closed on common credential/private-key patterns.
|
||||||
# We scan only text-ish files; grep -I skips binary data.
|
# 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,}'
|
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`
|
- `AuthChallenge`
|
||||||
- `CreateAuthSession`
|
- `CreateAuthSession`
|
||||||
- `SessionChallenge`
|
- `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`
|
## 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`
|
- `400 / BAD_PAYLOAD_TYPE`
|
||||||
- `422 / PAIRING_NOT_AVAILABLE`
|
- `422 / PAIRING_NOT_AVAILABLE`
|
||||||
- `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля.
|
- `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля.
|
||||||
- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` — сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся.
|
Код создаётся даже если сейчас нет ни одной онлайн доверённой сессии пользователя.
|
||||||
|
В таком случае `trustedSessionOnline=false`, а pairing-заявка остаётся в состоянии
|
||||||
|
`created` до подтверждения, отмены или истечения TTL.
|
||||||
- `429 / PAIRING_RATE_LIMITED`
|
- `429 / PAIRING_RATE_LIMITED`
|
||||||
|
|
||||||
|
Также `StartTrustedDeviceLogin` разрешён только на первом access server пользователя.
|
||||||
|
На другом сервере операция возвращает `409 / USER_NOT_LOCAL`.
|
||||||
|
|
||||||
### 5.4. `ListTrustedDeviceLoginRequests`
|
### 5.4. `ListTrustedDeviceLoginRequests`
|
||||||
|
|
||||||
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
Доступно для любой уже авторизованной доверенной сессии пользователя.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
|
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
|
||||||
| `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара |
|
| `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара |
|
||||||
| `TestUploadFreeAvatar` | `14_Test_Free_Avatar_Upload_API.md` | временная тестовая бесплатная загрузка маленького аватара в Arweave |
|
| `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 для создания новой сессии |
|
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
|
||||||
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
|
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
|
||||||
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
|
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
|
||||||
@@ -63,14 +64,9 @@
|
|||||||
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
|
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
|
||||||
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
|
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
|
||||||
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
| `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 одного личного сообщения у обеих сторон |
|
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
||||||
| `DeleteConversation` | `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` | постраничная загрузка истории личного диалога |
|
| `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога |
|
||||||
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
||||||
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
||||||
@@ -78,7 +74,9 @@
|
|||||||
|
|
||||||
## Важные замечания
|
## Важные замечания
|
||||||
|
|
||||||
- `ReceiveOutcomingMessage` зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`, и сохраняет прежний межсерверный payload.
|
- UI отправляет полную подписанную пару только через `SendMessagePair`.
|
||||||
|
- `ReceiveIncomingMessage` вызывается сервером отправителя для доставки одной
|
||||||
|
входящей копии на единственный access-сервер получателя.
|
||||||
- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее.
|
- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее.
|
||||||
- `ServerHello` пока принимает заявленный `serverLogin` на доверии и не является криптографической аутентификацией.
|
- `ServerHello` пока принимает заявленный `serverLogin` на доверии и не является криптографической аутентификацией.
|
||||||
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
|
- Отдельных HTTP endpoints для DM-файлов сейчас нет.
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
|
|
||||||
Важно:
|
Важно:
|
||||||
|
|
||||||
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
|
- UI использует `SendMessagePair`, `DeleteMessage`, `DeleteConversation`
|
||||||
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
|
и `GetDirectMessages`;
|
||||||
|
- `ReceiveIncomingMessage` — внутренняя доставка одной входящей копии на
|
||||||
|
единственный access-сервер получателя.
|
||||||
- сервер поддерживает материализованный слой диалогов `dm_dialog_state`; `read receipt` обновляет серверный watermark и `unreadCount`, а не только локальный клиентский флаг.
|
- сервер поддерживает материализованный слой диалогов `dm_dialog_state`; `read receipt` обновляет серверный watermark и `unreadCount`, а не только локальный клиентский флаг.
|
||||||
- в `dm_dialog_state` сервер также хранит `last_message_blob_b64` для последнего контентного DM в base64, чтобы клиент мог отрисовать список чатов без дополнительного запроса.
|
- в `dm_dialog_state` сервер также хранит `last_message_blob_b64` для последнего контентного DM в base64, чтобы клиент мог отрисовать список чатов без дополнительного запроса.
|
||||||
|
|
||||||
@@ -69,9 +71,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. `SendMessagePair` и `ReceiveOutcomingMessage`
|
## 3. `SendMessagePair`
|
||||||
|
|
||||||
`ReceiveOutcomingMessage` — алиас `SendMessagePair`.
|
|
||||||
|
|
||||||
### Назначение
|
### Назначение
|
||||||
|
|
||||||
@@ -114,7 +114,10 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Успешный `status=200` подтверждает локальное сохранение. До ответа клиенту сервер параллельно пробует оба актуальных сервера получателя. Поэтому `deliveryState` уже может быть `delivered`; если никто не ответил, возвращается `accepted`.
|
Успешный `status=200` подтверждает локальное сохранение. До ответа клиенту
|
||||||
|
сервер пробует единственный актуальный сервер получателя. Поэтому
|
||||||
|
`deliveryState` уже может быть `delivered`; если сервер получателя не
|
||||||
|
ответил, возвращается `accepted`.
|
||||||
|
|
||||||
Возможные `deliveryState`: `accepted`, `delivered`, `failed`.
|
Возможные `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-доставки. Повтор уже сохранённой той же ревизии обрабатывается идемпотентно.
|
Успешный ответ содержит существующие `messageKey`, `baseKey` и счётчики realtime-доставки. Повтор уже сохранённой той же ревизии обрабатывается идемпотентно.
|
||||||
|
|
||||||
@@ -262,31 +268,11 @@
|
|||||||
|
|
||||||
Поле `deliveryState` заполняется для исходящих элементов `type=2`. У входящих `type=1` оно отсутствует/равно `null`.
|
Поле `deliveryState` заполняется для исходящих элементов `type=2`. У входящих `type=1` оно отсутствует/равно `null`.
|
||||||
|
|
||||||
## 8. Межсерверные операции DM
|
## 8. Внутренняя межсерверная доставка DM
|
||||||
|
|
||||||
До отдельного этапа server-auth поля `sourceServerLogin` доверяются. Каждый `SHiNE_DM` всё равно заново проходит проверку формата и пользовательской подписи.
|
### 8.1. `ReceiveIncomingMessage`
|
||||||
|
|
||||||
### 8.1. `ReceiveOutcomingMessage`
|
Передаёт одну входящую копию на единственный access-сервер получателя.
|
||||||
|
|
||||||
Прежняя операция передачи полной пары второму access-серверу отправителя. Delivery-state в межсерверный запрос не входит.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "ReceiveOutcomingMessage",
|
|
||||||
"requestId": "dm-peer-001",
|
|
||||||
"payload": {
|
|
||||||
"incomingBlobB64": "BASE64_INCOMING",
|
|
||||||
"outgoingBlobB64": "BASE64_OUTGOING",
|
|
||||||
"sourceServerLogin": "server-a"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ответ имеет прежний формат `SendMessagePair`. Принимающий сервер сохраняет пару идемпотентно и самостоятельно ставит локальную задачу доставки.
|
|
||||||
|
|
||||||
### 8.2. `ReceiveIncomingMessage`
|
|
||||||
|
|
||||||
Прежняя операция передачи одной входящей копии серверу получателя или второму серверу самого получателя.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -301,72 +287,9 @@
|
|||||||
|
|
||||||
Успешный 2xx-ответ означает, что signed-блок проверен и находится в БД (новая или идемпотентно повторённая запись).
|
Успешный 2xx-ответ означает, что signed-блок проверен и находится в БД (новая или идемпотентно повторённая запись).
|
||||||
|
|
||||||
### 8.3. `DmSyncBatch`
|
Операции `ReceiveOutcomingMessage`, `DmSyncBatch` и
|
||||||
|
`GetDmDeliveryStatus` удалены вместе с репликацией между access-серверами
|
||||||
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`.
|
|
||||||
|
|
||||||
## 9. `AckSessionDelivery`
|
## 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` - курсор прочитанности канала;
|
и подписывает её текущим Ed25519 clientKey пользователя. Сервер:
|
||||||
- `setting_key = ownerBlockchainName/channelName`;
|
|
||||||
- `value_num = number of messages already seen in channel`;
|
|
||||||
- `value_text = ''`.
|
|
||||||
|
|
||||||
Если настройки нет, канал считается полностью прочитанным, то есть unread = `0`.
|
1. находит пользователя;
|
||||||
После первого открытия канала UI отправляет текущий курсор, чтобы зафиксировать baseline и дальше считать только новые сообщения.
|
2. проверяет совпадение client_key с актуальным ключом пользователя;
|
||||||
|
3. строго проверяет подпись;
|
||||||
|
4. при неверной подписи возвращает INVALID_SIGNATURE;
|
||||||
|
5. сохраняет запись только если time_ms новее локальной версии.
|
||||||
|
|
||||||
## 2. Структура записи
|
## Операции
|
||||||
|
|
||||||
- `login` - логин владельца настройки;
|
### UpsertUserSetting
|
||||||
- `setting_type` - числовой код типа настройки;
|
|
||||||
- `setting_key` - строковый ключ настройки;
|
|
||||||
- `time_ms` - время установки значения в миллисекундах;
|
|
||||||
- `value_text` - строковое значение;
|
|
||||||
- `value_num` - числовое значение;
|
|
||||||
- `client_key` - публичный Ed25519 ключ клиента в Base64;
|
|
||||||
- `signature` - Ed25519 подпись preimage в Base64;
|
|
||||||
- `synced` - была ли настройка успешно доставлена на второй сервер.
|
|
||||||
|
|
||||||
Уникальность: `(login, setting_type, setting_key)`.
|
Обязательные поля:
|
||||||
Обновление: только если `time_ms` новее текущего значения.
|
|
||||||
|
|
||||||
## 3. Формат подписи
|
- 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
|
||||||
|
|
||||||
Записывает или обновляет настройку пользователя.
|
Возвращает все локальные настройки пользователя.
|
||||||
|
|
||||||
Если запрос пришёл от клиента, сервер:
|
## Удалённые операции
|
||||||
|
|
||||||
- сохраняет запись локально;
|
После перехода на один access-сервер удалены:
|
||||||
- пытается сразу отправить её на доступный sync-сервер;
|
|
||||||
- если отправка успешна, помечает запись как `synced=true`;
|
|
||||||
- если нет, оставляет `synced=false`.
|
|
||||||
|
|
||||||
Если запрос пришёл по синхронизации между серверами, используется `sync_delivery=true`, и повторной пересылки дальше не делается.
|
- UserSettingsSyncBatch;
|
||||||
|
- MarkAllUserSettingsUnsynced.
|
||||||
|
|
||||||
### `GetUserSetting`
|
Также удалены таблица peer-cursor и периодический сервис синхронизации
|
||||||
|
настроек.
|
||||||
Чтение одной настройки по `(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 в списке каналов и в канале.
|
|
||||||
|
|||||||
@@ -55,7 +55,8 @@
|
|||||||
- физическое соединение создаётся одно на `serverLogin`;
|
- физическое соединение создаётся одно на `serverLogin`;
|
||||||
- логические операции DM, settings и blockchain используют один WSS;
|
- логические операции DM, settings и blockchain используют один WSS;
|
||||||
- завершение `RemoteSyncSession` не закрывает физический сокет;
|
- завершение `RemoteSyncSession` не закрывает физический сокет;
|
||||||
- известные peer берутся из `sync_servers` и `user_access_servers_current`;
|
- известные peer берутся из `sync_servers` и первых действующих маршрутов
|
||||||
|
`user_access_servers_current`;
|
||||||
- список перечитывается каждые 30 секунд;
|
- список перечитывается каждые 30 секунд;
|
||||||
- при изменении URL соединение пересоздаётся;
|
- при изменении URL соединение пересоздаётся;
|
||||||
- новые запросы не повторяются транспортом автоматически: действующие
|
- новые запросы не повторяются транспортом автоматически: действующие
|
||||||
@@ -65,8 +66,8 @@
|
|||||||
|
|
||||||
| Приоритет | Операции |
|
| Приоритет | Операции |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `REALTIME` | доставка DM, deletes, `GetDmDeliveryStatus` |
|
| `REALTIME` | доставка DM и signed deletes |
|
||||||
| `NORMAL` | настройки, `DmSyncBatch` и access-data sync |
|
| `NORMAL` | прочие межсерверные запросы |
|
||||||
| `BULK` | blockchain heads, blocks, `AddBlock` backfill |
|
| `BULK` | blockchain heads, blocks, `AddBlock` backfill |
|
||||||
|
|
||||||
На одном peer одновременно исполняется один запрос. Приоритет применяется к
|
На одном peer одновременно исполняется один запрос. Приоритет применяется к
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
## Смежная документация
|
## Смежная документация
|
||||||
- [../ИТХ/README.md](../ИТХ/README.md) — ежедневное закрытие блокчейна (ИТХ): краткий обзор.
|
- [../ИТХ/README.md](../ИТХ/README.md) — ежедневное закрытие блокчейна (ИТХ): краткий обзор.
|
||||||
- [../ИТХ/Спецификация_ИТХ_v1.md](../ИТХ/Спецификация_ИТХ_v1.md) — точная спецификация чекпоинтов (Arweave/Solana/канал закрытий).
|
- [../ИТХ/Спецификация_ИТХ_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
|
## Важные ограничения MVP
|
||||||
- Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI.
|
- Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Синхронизация блоков и DM между серверами SHiNE
|
# Синхронизация блокчейнов и доставка DM между серверами SHiNE
|
||||||
|
|
||||||
Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE.
|
Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE.
|
||||||
|
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
Каждый сервер регистрирует в своей Solana PDA список `sync_servers` —
|
Каждый сервер регистрирует в своей Solana PDA список `sync_servers` —
|
||||||
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
|
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
|
||||||
|
|
||||||
Важно: в текущей архитектуре у пользователя одновременно может быть не более
|
`sync_servers` относится к серверному узлу и не является списком
|
||||||
двух sync/access-серверов. Это ограничение считается обязательным для runtime-логики
|
access-серверов обычного пользователя. У пользователя действует только первый
|
||||||
`synced` и пользовательских курсоров.
|
`access_servers[0]`.
|
||||||
|
|
||||||
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
|
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
|
||||||
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
|
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
- Сервер-отправитель: сохраняет пару и ставит асинхронную delivery-задачу.
|
- Сервер-отправитель: сохраняет пару и ставит асинхронную delivery-задачу.
|
||||||
- Сервер-получатель: сохраняет входящий блок в `signed_messages`, затем доставляет его активным сессиям.
|
- Сервер-получатель: сохраняет входящий блок в `signed_messages`, затем доставляет его активным сессиям.
|
||||||
- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`.
|
- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`.
|
||||||
- Репликация между двумя access-серверами пользователя работает через `dm_sync_outbox.synced`, без постоянного time-cursor.
|
- Между access-серверами одного пользователя DM не реплицируются.
|
||||||
- Полная актуальная схема: `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`.
|
- Полная актуальная схема: `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`.
|
||||||
|
|
||||||
### 3.2 Блоки пользовательского блокчейна
|
### 3.2 Блоки пользовательского блокчейна
|
||||||
@@ -47,10 +47,8 @@
|
|||||||
|
|
||||||
### 3.3 Пользовательские настройки
|
### 3.3 Пользовательские настройки
|
||||||
|
|
||||||
- Отдельная таблица `user_settings`.
|
Пользовательские настройки хранятся локально на единственном access-сервере и
|
||||||
- Синхронизируются технические настройки пользователя, включая курсор прочитанности каналов.
|
между серверами не синхронизируются.
|
||||||
- Для текущего UI-кейса хранится `setting_type = 1` и `setting_key = ownerBlockchainName/channelName`.
|
|
||||||
- Синхронизация идёт с учётом `time_ms` и флага `synced`.
|
|
||||||
|
|
||||||
## 4. Текущая реализованная схема
|
## 4. Текущая реализованная схема
|
||||||
|
|
||||||
@@ -196,7 +194,7 @@ Full resync запускается только тогда, когда:
|
|||||||
- На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена.
|
- На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена.
|
||||||
- При разрыве выполняется переподключение с jitter/backoff до 60 секунд.
|
- При разрыве выполняется переподключение с jitter/backoff до 60 секунд.
|
||||||
- После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд.
|
- После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд.
|
||||||
- Один физический канал переиспользуют DM, настройки и blockchain.
|
- Один физический канал переиспользуют доставка DM и blockchain.
|
||||||
|
|
||||||
### 5.2 Доставка новых данных (push)
|
### 5.2 Доставка новых данных (push)
|
||||||
|
|
||||||
@@ -208,7 +206,7 @@ Full resync запускается только тогда, когда:
|
|||||||
|
|
||||||
- При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов.
|
- При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов.
|
||||||
- Сервер с более полной историей досылает недостающее партнёру.
|
- Сервер с более полной историей досылает недостающее партнёру.
|
||||||
- DM time-cursor удалён: новый peer получает историю после сброса `dm_sync_outbox.synced=false`.
|
- DM-history backfill между access-серверами отсутствует.
|
||||||
|
|
||||||
### 5.4 Разрешение конфликтов
|
### 5.4 Разрешение конфликтов
|
||||||
|
|
||||||
@@ -222,11 +220,11 @@ Full resync запускается только тогда, когда:
|
|||||||
|
|
||||||
1. Клиент A отправляет пару блоков на свой сервер X.
|
1. Клиент A отправляет пару блоков на свой сервер X.
|
||||||
2. Сервер X валидирует и локально сохраняет пару.
|
2. Сервер X валидирует и локально сохраняет пару.
|
||||||
3. До ответа клиенту X параллельно отправляет входящую копию максимум двум актуальным `access_servers` B.
|
3. До ответа клиенту X отправляет входящую копию на единственный
|
||||||
4. ACK хотя бы одного маршрута B означает терминальный `delivered`; второй сервер B догоняется собственной DM-синхронизацией.
|
`access_servers[0]` пользователя B через `ReceiveIncomingMessage`.
|
||||||
5. После первой попытки X передаёт полную пару второму access-серверу A старым `ReceiveOutcomingMessage`, без delivery-state.
|
4. Успешное сохранение на сервере B означает терминальный `delivered`.
|
||||||
6. При нулевой доставке выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час. Перед тремя последними X спрашивает peer A через `GetDmDeliveryStatus(messageKey)`.
|
5. При неудаче выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час.
|
||||||
7. После неудачной попытки через час ставится терминальный `failed`.
|
6. После неудачной попытки через час ставится терминальный `failed`.
|
||||||
|
|
||||||
Изменения routing B учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке.
|
Изменения routing B учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке.
|
||||||
|
|
||||||
@@ -251,15 +249,14 @@ Full resync запускается только тогда, когда:
|
|||||||
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
|
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
|
||||||
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
|
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
|
||||||
| Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` |
|
| Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` |
|
||||||
| Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано |
|
| Асинхронная доставка DM на единственный access-сервер получателя | ✅ Реализовано |
|
||||||
| Retry DM до 1 часа + UI-state | ✅ Реализовано |
|
| Retry DM до 1 часа + UI-state | ✅ Реализовано |
|
||||||
| Репликация DM на второй access-сервер по `synced` | ✅ Реализовано |
|
| Репликация DM и настроек между access-серверами | Не используется |
|
||||||
| Read-only `GetDmDeliveryStatus` | ✅ Реализовано |
|
|
||||||
| Push блоков блокчейна партнёрам | ✅ Выполняется через постоянный WSS-пул |
|
| Push блоков блокчейна партнёрам | ✅ Выполняется через постоянный WSS-пул |
|
||||||
| Periodic backfill отсутствующего хвоста | ✅ Реализовано |
|
| Periodic backfill отсутствующего хвоста | ✅ Реализовано |
|
||||||
| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync |
|
| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync |
|
||||||
| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано |
|
| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано |
|
||||||
| Маршрутизация DM через один/два `access_servers` | ✅ Реализовано |
|
| Маршрутизация DM только через `access_servers[0]` | ✅ Реализовано |
|
||||||
| Криптографическая server-to-server авторизация DM | Нужна реализация |
|
| Криптографическая server-to-server авторизация DM | Нужна реализация |
|
||||||
|
|
||||||
Текущая версия сервера использует постоянный WSS-пул для существующих
|
Текущая версия сервера использует постоянный WSS-пул для существующих
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
- `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers`
|
- `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers`
|
||||||
- `docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM`
|
- `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 после расшифровки
|
- `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` связывает входящую и исходящую копии одного логического сообщения;
|
Формат SHiNE_DM не меняется. Для сообщения клиент создаёт:
|
||||||
- `incomingKey` идентифицирует копию получателя;
|
|
||||||
- `outgoingKey`/`messageKey` идентифицирует копию отправителя и используется для проверки доставки;
|
|
||||||
- дополнительный публичный `eventId` для доставки не создаётся;
|
|
||||||
- `syncId` используется только внутри `DmSyncBatch` как ACK конкретной ревизии, потому что редактирование сохраняет прежний `messageKey`.
|
|
||||||
|
|
||||||
## 3. Состояния доставки
|
- входящую копию для получателя;
|
||||||
|
- исходящую копию для отправителя.
|
||||||
|
|
||||||
| Состояние | Смысл | Повторные попытки |
|
Сервер отправителя принимает пару через SendMessagePair, атомарно сохраняет её
|
||||||
|---|---|---|
|
и создаёт изменяемое состояние доставки.
|
||||||
| `accepted` | пара сохранена сервером отправителя, но ни один сервер получателя ещё не подтвердил запись | да |
|
|
||||||
| `delivered` | хотя бы один сервер получателя подтвердил запись | нет |
|
|
||||||
| `failed` | последняя попытка через час завершилась без доставки | никогда |
|
|
||||||
|
|
||||||
Состояние `delivered` терминальное. Сервер отправителя не пытается отдельно добиться второго ACK получателя.
|
## 3. Основной маршрут
|
||||||
|
|
||||||
## 4. Обычная отправка
|
1. UI отправителя вызывает SendMessagePair на своём access-сервере.
|
||||||
|
2. Сервер сохраняет исходящую копию отправителя и входящую копию, необходимую
|
||||||
|
для доставки и повторных попыток.
|
||||||
|
3. Сервер читает единственный маршрут получателя из
|
||||||
|
user_access_servers_current.
|
||||||
|
4. Если это другой физический сервер, он вызывает внутреннюю операцию
|
||||||
|
ReceiveIncomingMessage и передаёт только входящую копию.
|
||||||
|
5. Сервер получателя проверяет пользовательскую подпись, идемпотентно сохраняет
|
||||||
|
входящую копию и уведомляет активные сессии.
|
||||||
|
6. Успешное сохранение означает delivered.
|
||||||
|
|
||||||
1. Клиент отправляет `SendMessagePair` на один свой access-сервер.
|
ReceiveIncomingMessage не является синхронизацией двух серверов одного
|
||||||
2. Сервер проверяет формат, пользователей, подписи и согласованность пары.
|
пользователя. Это основная доставка между серверами разных пользователей.
|
||||||
3. Сервер атомарно сохраняет входящую и исходящую копии.
|
|
||||||
4. В том же request-процессе сервер читает до двух актуальных маршрутов получателя.
|
|
||||||
5. Оба вызова `ReceiveIncomingMessage` запускаются параллельно.
|
|
||||||
6. Если хотя бы один вызов успешен, устанавливается `delivered`.
|
|
||||||
7. После этой попытки сервер передаёт полную пару своему второму access-серверу старой операцией `ReceiveOutcomingMessage`.
|
|
||||||
8. `SendMessagePair` возвращает существующие ключи и единственное новое поле `deliveryState`.
|
|
||||||
|
|
||||||
Успешный повтор уже сохранённого 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
|
Подписанные DM-контейнеры остаются криптографическими фактами. Сетевое
|
||||||
{
|
deliveryState остаётся изменяемой серверной метаданной.
|
||||||
"op": "GetDmDeliveryStatus",
|
|
||||||
"payload": {
|
|
||||||
"messageKey": "alice|bob|1774700000123|123456789|2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ответ:
|
## 6. Удаления
|
||||||
|
|
||||||
```json
|
DeleteMessage и DeleteConversation доставляют подписанный tombstone только на
|
||||||
{
|
единственные access-серверы обеих сторон. Повторное применение идемпотентно.
|
||||||
"messageKey": "alice|bob|1774700000123|123456789|2",
|
|
||||||
"known": true,
|
|
||||||
"delivered": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Операция 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. Старая межсерверная доставка
|
Блокчейн-синхронизация через server-PDA sync_servers остаётся отдельным
|
||||||
|
механизмом и продолжает работать.
|
||||||
Форматы существующих операций не расширяются данными о результате доставки:
|
|
||||||
|
|
||||||
- `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 и не изменяет данные.
|
|
||||||
|
|||||||
@@ -1,684 +1,205 @@
|
|||||||
# Личные сообщения (DM) — спецификация v1
|
# Протокол личных сообщений SHiNE DM v1
|
||||||
|
|
||||||
## Статус документа
|
## 1. Статус документа
|
||||||
|
|
||||||
Этот файл — актуальная логическая спецификация DM-протокола SHiNE v1.
|
Это актуальная спецификация логики личных сообщений. Точный байтовый формат
|
||||||
|
контейнера описан в Формат_DM_v1.md, а расписание доставки — в
|
||||||
|
Доставка_и_синхронизация_DM.md.
|
||||||
|
|
||||||
Важно:
|
Формат SHiNE_DM не изменён переходом на один access-сервер.
|
||||||
|
|
||||||
- это актуальная реализованная спецификация;
|
## 2. Инварианты
|
||||||
- код и документы по DM должны изменяться синхронно;
|
|
||||||
- при любых будущих изменениях DM сначала обновляется эта спецификация и соседний документ формата.
|
|
||||||
|
|
||||||
Документ фиксирует:
|
- каждый DM-контейнер подписан clientKey автора;
|
||||||
|
- сервер проверяет формат, пользователей и подпись до сохранения;
|
||||||
|
- повторная доставка одной ревизии идемпотентна;
|
||||||
|
- более старая ревизия не заменяет новую;
|
||||||
|
- у каждого пользователя действует только access_servers[0];
|
||||||
|
- дополнительные элементы старой PDA игнорируются без fallback;
|
||||||
|
- DM и настройки не реплицируются между access-серверами одного пользователя;
|
||||||
|
- sync_servers серверного PDA используются только для синхронизации
|
||||||
|
пользовательских блокчейнов.
|
||||||
|
|
||||||
- модель DM и смысл типов сообщений;
|
## 3. Типы DM
|
||||||
- правила редактирования, перешифровки и удаления;
|
|
||||||
- роли существующих API-методов;
|
|
||||||
- правила межсерверной маршрутизации через `access_servers`;
|
|
||||||
- общее поведение сервера и БД.
|
|
||||||
|
|
||||||
Точный байтовый формат контейнера вынесен отдельно:
|
- 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` — входящее сообщение для получателя;
|
UI позволяет:
|
||||||
- `type=2` — исходящая копия сообщения для отправителя.
|
|
||||||
|
|
||||||
Обе копии имеют общий логический идентификатор:
|
- увидеть текущий сервер;
|
||||||
|
- заменить его другим сервером;
|
||||||
|
- записать только один логин в 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.
|
||||||
|
|
||||||
- входящую копию может прочитать только получатель;
|
Попытки выполняются сразу, через 30 секунд, 5 минут, 25 минут и 1 час.
|
||||||
- исходящую копию может прочитать только отправитель;
|
Перед каждой попыткой первый маршрут получателя читается заново.
|
||||||
- сервер не должен расшифровывать DM;
|
|
||||||
- ciphertext у `type=1` и `type=2` обычно разный и не обязан совпадать побайтно.
|
|
||||||
|
|
||||||
### 2.3. Что именно знает сервер о `body`
|
Peer отправителя не получает полную пару и не опрашивается о состоянии.
|
||||||
|
DmSyncBatch и GetDmDeliveryStatus удалены.
|
||||||
|
|
||||||
Для контентных DM (`type=1/2`) сервер в v1 должен рассматривать `body` как opaque blob:
|
## 8. Чтение и realtime
|
||||||
|
|
||||||
- сервер проверяет внешний контейнер `SHiNE_DM`;
|
GetDirectMessages возвращает историю страницами. UI использует стабильные
|
||||||
- сервер проверяет подпись;
|
messageKey/baseKey для дедупликации и отображает более новую ревизию.
|
||||||
- сервер проверяет служебные поля envelope;
|
|
||||||
- сервер проверяет только то, что `bodyLen > 0` и не превышает допустимый лимит;
|
|
||||||
- сервер не обязан разбирать внутренний crypto-контейнер `body`;
|
|
||||||
- сервер не обязан знать конкретный алгоритм шифрования `body`.
|
|
||||||
|
|
||||||
Это позволяет альтернативным клиентам использовать совместимый внешний DM-envelope при собственном формате зашифрованного payload внутри `body`.
|
dm_dialog_state хранит:
|
||||||
|
|
||||||
Официальный UI SHiNE при приёме такого сообщения:
|
- последнюю контентную копию;
|
||||||
|
- время и ключ последнего сообщения;
|
||||||
|
- unreadCount;
|
||||||
|
- watermark прочтения.
|
||||||
|
|
||||||
- пытается расшифровать знакомый формат `EncryptedBody_v1_0`;
|
WebSocket push ускоряет отображение, но после переподключения клиент запрашивает
|
||||||
- показывает текст сообщения при успешной расшифровке;
|
историю у своего единственного access-сервера.
|
||||||
- если внутренний формат не поддерживается, показывает `Формат сообщения не поддерживается`;
|
|
||||||
- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`;
|
|
||||||
- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`.
|
|
||||||
|
|
||||||
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<S:...>` в начале текста. Legacy-вставки `<SHiNE:...>` также продолжают поддерживаться при чтении.
|
## 9. Подтверждения прочтения
|
||||||
Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope.
|
|
||||||
|
|
||||||
### 2.4. Источник истины по пользователю
|
Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же
|
||||||
|
маршрут SendMessagePair → ReceiveIncomingMessage.
|
||||||
|
|
||||||
Для DM-проверки сервер использует:
|
Сервер хранит наибольший известный watermark, поэтому переставленные или
|
||||||
|
повторные receipts не уменьшают состояние прочтения.
|
||||||
|
|
||||||
- локальную runtime-проекцию пользователей как кэш;
|
## 10. Редактирование
|
||||||
- Solana PDA как источник истины по `clientKey` и `access_servers`.
|
|
||||||
|
|
||||||
Если нужного пользователя нет локально, сервер обязан попытаться lazy-import из Solana PDA:
|
Редактирование сохраняет логический messageKey исходной копии и увеличивает
|
||||||
|
revisionTimeMs. Сервер принимает только более новую валидную ревизию.
|
||||||
|
|
||||||
- в `GetUser`;
|
Если доставка исходного сообщения ещё не завершилась, следующая попытка
|
||||||
- при серверной верификации DM-подписи;
|
использует актуальную сохранённую incoming-копию.
|
||||||
- перед межсерверной маршрутизацией через `access_servers`.
|
|
||||||
|
|
||||||
## 3. Типы сообщений
|
## 11. Удаление
|
||||||
|
|
||||||
В версии v1 используются следующие `messageType`:
|
DeleteMessage принимает type=5/6. DeleteConversation принимает type=7/8.
|
||||||
|
|
||||||
- `1` — входящее сообщение;
|
Удаление является подписанным tombstone. Оно:
|
||||||
- `2` — исходящая копия сообщения;
|
|
||||||
- `3` — входящее подтверждение прочтения;
|
|
||||||
- `4` — исходящая копия подтверждения прочтения;
|
|
||||||
- `5` — сообщение удалено отправителем;
|
|
||||||
- `6` — сообщение удалено получателем;
|
|
||||||
- `7` — переписка удалена отправителем;
|
|
||||||
- `8` — переписка удалена получателем.
|
|
||||||
|
|
||||||
Типы `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`
|
- dm_sync_outbox;
|
||||||
- `ReceiveOutcomingMessage` как алиас
|
- dm_sync_peer_state;
|
||||||
|
- user_settings_sync_peer_state;
|
||||||
|
- synced-флаги репликации.
|
||||||
|
|
||||||
Пару создаёт автор сообщения.
|
## 13. Границы текущей версии
|
||||||
|
|
||||||
Пара содержит:
|
- межсерверная авторизация ServerHello пока не подписана;
|
||||||
|
- sourceServerLogin не является криптографическим доказательством сервера;
|
||||||
|
- пользовательская подпись каждого SHiNE_DM проверяется независимо от
|
||||||
|
транспорта;
|
||||||
|
- автоматической миграции истории при смене access-сервера нет;
|
||||||
|
- резервного fallback-сервера нет.
|
||||||
|
|
||||||
- одну входящую копию `type=1`;
|
## 14. Сохранённые механизмы
|
||||||
- одну исходящую копию `type=2`;
|
|
||||||
- одинаковые `fromLogin`, `toLogin`, `timeMs`, `nonce`;
|
|
||||||
- одинаковый логический plaintext;
|
|
||||||
- разные ciphertext для разных владельцев копий.
|
|
||||||
|
|
||||||
### 4.2. Редактирование сообщения
|
Переход на один access-сервер не затрагивает:
|
||||||
|
|
||||||
Редактирование общего текста делает автор сообщения.
|
- байтовый формат SHiNE_DM;
|
||||||
|
- шифрование входящей и исходящей копий;
|
||||||
|
- пользовательские подписи;
|
||||||
|
- deliveryState и retry-воркер;
|
||||||
|
- read-receipts;
|
||||||
|
- синхронизацию пользовательских блокчейнов через server-PDA sync_servers;
|
||||||
|
- общий постоянный WSS ServerConnectionPool.
|
||||||
|
|
||||||
При редактировании:
|
## UI списка личных чатов — фильтры и отображение времени (2026-08-28)
|
||||||
|
- Заголовок «Чаты» является переключателем фильтра списка.
|
||||||
- `baseKey` остаётся тем же;
|
- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые».
|
||||||
- `messageType` остаётся тем же;
|
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
|
||||||
- `revisionTimeMs` увеличивается;
|
- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени.
|
||||||
- автор заново шифрует обе копии и снова отправляет пару через `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-синхронизации и доставки.
|
|
||||||
|
|||||||
@@ -340,4 +340,19 @@ ReadReceiptBody_v1_0
|
|||||||
|
|
||||||
В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`.
|
В версии 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;
|
- сервер запускает sync-модуль до продолжения собственного startup;
|
||||||
- server startup ждёт входа модуля в состояние `READY`;
|
- server startup ждёт входа модуля в состояние `READY`;
|
||||||
- актуальный источник истины по пользователям для нового PostgreSQL runtime-слоя: `solana_user_pda_current`.
|
- актуальный источник истины по пользователям для нового PostgreSQL runtime-слоя: `solana_user_pda_current`.
|
||||||
- для быстрого локального маршрутизационного lookup серверов доступа поддерживается вторичная проекция
|
- для быстрого локального lookup единственного сервера доступа поддерживается
|
||||||
`user_access_servers_current`, которая автоматически пересобирается из `solana_user_pda_current`.
|
вторичная проекция `user_access_servers_current`, которая автоматически
|
||||||
|
пересобирается только из первого элемента `access_servers[0]` в
|
||||||
|
`solana_user_pda_current`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -291,8 +291,13 @@ AccessServersBlock
|
|||||||
|
|
||||||
- блок может отсутствовать, если серверы доступа не заданы;
|
- блок может отсутствовать, если серверы доступа не заданы;
|
||||||
- список может обновляться при изменении маршрутизации пользователя;
|
- список может обновляться при изменении маршрутизации пользователя;
|
||||||
- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay для конкретного пользователя. Через этот список клиентская и серверная логика SHiNE может маршрутизировать доставку личных сообщений, доступ к сессиям и другие пользовательские операции. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы;
|
- `access_servers` — массив логинов серверов доступа/relay. Формат массива
|
||||||
- точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE.
|
сохранён для совместимости, но текущая клиентская и серверная логика использует
|
||||||
|
только первый элемент `access_servers[0]`;
|
||||||
|
- остальные элементы игнорируются без fallback;
|
||||||
|
- официальный UI записывает ровно один сервер и позволяет только заменить его;
|
||||||
|
- Solana-программа не обязана проверять, что логин действительно зарегистрирован
|
||||||
|
как сервер.
|
||||||
|
|
||||||
## 14. SessionsBlock
|
## 14. SessionsBlock
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,8 @@
|
|||||||
- `server_key: Pubkey`
|
- `server_key: Pubkey`
|
||||||
- `server_address: String`
|
- `server_address: String`
|
||||||
- `sync_servers: Vec<String>`
|
- `sync_servers: Vec<String>`
|
||||||
- `access_servers: Vec<String>`
|
- `access_servers: Vec<String>` — формат остаётся массивом, но runtime
|
||||||
|
использует только первый элемент
|
||||||
- `trusted_count: u8`
|
- `trusted_count: u8`
|
||||||
|
|
||||||
## Главные PDA
|
## Главные 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">
|
<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"/>
|
<circle cx="32" cy="23.5" r="10.5" stroke="#65B7FF" stroke-width="3.2"/>
|
||||||
<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"/>
|
<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>
|
</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">
|
<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="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"/>
|
||||||
<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="33.2" r="8.2" stroke="#65B7FF" stroke-width="3.2"/>
|
||||||
<circle cx="32" cy="32" r="3.5" fill="#F2F5FB"/>
|
|
||||||
</svg>
|
</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">
|
<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="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 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"/>
|
<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="30.5" r="2.4" fill="#F2F5FB"/>
|
<circle cx="42.5" cy="32" r="2.5" fill="#65B7FF"/>
|
||||||
<path d="M18.5 22H38" stroke="#F2F5FB" stroke-width="3" stroke-linecap="round" opacity="0.9"/>
|
|
||||||
</svg>
|
</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" />
|
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
||||||
<title>СИЯНИЕ</title>
|
<title>СИЯНИЕ</title>
|
||||||
<script>
|
<script>
|
||||||
window.__SHINE_BUILD_HASH__ = '20260822140000';
|
window.__SHINE_BUILD_HASH__ = '20260826145335';
|
||||||
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
@@ -229,9 +229,17 @@ window.__SHINE_BUILD_HASH__ = '20260822140000';
|
|||||||
document.getElementById(menuId)?.remove();
|
document.getElementById(menuId)?.remove();
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
const dismissError = () => {
|
||||||
|
removeMenu();
|
||||||
|
try {
|
||||||
|
document.getElementById('boot-error-overlay')?.remove();
|
||||||
|
setState(null);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
const openMenu = () => {
|
const openMenu = () => {
|
||||||
try {
|
try {
|
||||||
removeMenu();
|
removeMenu();
|
||||||
|
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: 'boot-error' } }));
|
||||||
const report = buildReport();
|
const report = buildReport();
|
||||||
const root = document.getElementById('modal-root') || document.body;
|
const root = document.getElementById('modal-root') || document.body;
|
||||||
const shell = document.createElement('div');
|
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="copy">Скопировать текст ошибки</button>
|
||||||
<button type="button" class="secondary-btn" data-action="share">Отправить текст ошибки</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="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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -308,7 +316,18 @@ window.__SHINE_BUILD_HASH__ = '20260822140000';
|
|||||||
close();
|
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?.();
|
dialog?.focus?.();
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -200,17 +200,41 @@ let orientationLockInFlight = false;
|
|||||||
let currentChromeCleanup = null;
|
let currentChromeCleanup = null;
|
||||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||||
const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
||||||
'messages-list',
|
|
||||||
'chat-view',
|
'chat-view',
|
||||||
'channels-list',
|
|
||||||
'channel-view',
|
'channel-view',
|
||||||
'channel-thread-view',
|
'channel-thread-view',
|
||||||
'notifications-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([
|
const GUEST_ALLOWED_PAGES = new Set([
|
||||||
'start-view',
|
'start-view',
|
||||||
'entry-settings-view',
|
'entry-settings-view',
|
||||||
|
'language-view',
|
||||||
'network-view',
|
'network-view',
|
||||||
'channels-list',
|
'channels-list',
|
||||||
'channel-view',
|
'channel-view',
|
||||||
@@ -1175,7 +1199,8 @@ function renderApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const page = routes[pageId] || routes['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);
|
||||||
if (typeof currentCleanup === 'function') {
|
if (typeof currentCleanup === 'function') {
|
||||||
currentCleanup();
|
currentCleanup();
|
||||||
currentCleanup = null;
|
currentCleanup = null;
|
||||||
@@ -1204,6 +1229,8 @@ function renderApp() {
|
|||||||
|
|
||||||
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
|
||||||
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
|
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 = '';
|
toolbarEl.innerHTML = '';
|
||||||
if (showAppChrome) {
|
if (showAppChrome) {
|
||||||
@@ -1221,7 +1248,8 @@ function refreshToolbarOnly() {
|
|||||||
const route = getRoute();
|
const route = getRoute();
|
||||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||||
const page = routes[pageId] || routes['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 = '';
|
toolbarEl.innerHTML = '';
|
||||||
if (showAppChrome) {
|
if (showAppChrome) {
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ function normalizeLogin(value) {
|
|||||||
|
|
||||||
function pickSizeClass(size) {
|
function pickSizeClass(size) {
|
||||||
const raw = String(size || '').trim().toLowerCase();
|
const raw = String(size || '').trim().toLowerCase();
|
||||||
if (raw === 'large') return 'large';
|
|
||||||
if (raw === 'node') return 'node-dot';
|
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 || '';
|
return raw || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +31,7 @@ export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {
|
|||||||
export function renderAvatar({
|
export function renderAvatar({
|
||||||
initials = '?',
|
initials = '?',
|
||||||
avatar = null,
|
avatar = null,
|
||||||
size = 'large',
|
size = 'xl',
|
||||||
className = '',
|
className = '',
|
||||||
title = '',
|
title = '',
|
||||||
alt = 'Аватар',
|
alt = 'Аватар',
|
||||||
@@ -139,7 +142,7 @@ export function renderUserAvatar({
|
|||||||
firstName = '',
|
firstName = '',
|
||||||
lastName = '',
|
lastName = '',
|
||||||
avatar = null,
|
avatar = null,
|
||||||
size = 'large',
|
size = 'xl',
|
||||||
className = '',
|
className = '',
|
||||||
title = '',
|
title = '',
|
||||||
glow = false,
|
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 = '' } = {}) {
|
export function createShineConnectionsLogo({ className = '' } = {}) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
} else if (isNetwork) {
|
||||||
|
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||||
|
btn.setAttribute('aria-label', item.label);
|
||||||
|
btn.title = item.label;
|
||||||
} else {
|
} else {
|
||||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
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 { readShineUserPda, updateShineUserPdaOnSolana } from '../services/shine-user-pda-service.js';
|
||||||
import { getTopupSiteUrl } from '../services/solana-wallet-service.js';
|
import { getTopupSiteUrl } from '../services/solana-wallet-service.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' };
|
export const pageMeta = { id: 'access-servers-view', title: 'Сервер доступа' };
|
||||||
const MAX_ACCESS_SERVERS = 2;
|
const MAX_ACCESS_SERVERS = 1;
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
@@ -281,9 +281,10 @@ export function render({ navigate }) {
|
|||||||
introCard.innerHTML = `
|
introCard.innerHTML = `
|
||||||
<p class="field-label">Где хранятся личные данные</p>
|
<p class="field-label">Где хранятся личные данные</p>
|
||||||
<p class="meta-muted">
|
<p class="meta-muted">
|
||||||
Серверы доступа хранят зашифрованную личную переписку пользователя и участвуют в звонках.
|
Единственный сервер доступа хранит зашифрованную личную переписку пользователя и участвует в звонках.
|
||||||
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
|
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
|
||||||
а доступ пользователя и приватная переписка хранятся только на этих серверах.
|
а настройки и приватная переписка хранятся только на выбранном сервере.
|
||||||
|
При смене сервера прежняя переписка и настройки автоматически не переносятся.
|
||||||
</p>
|
</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -291,7 +292,7 @@ export function render({ navigate }) {
|
|||||||
listCard.className = 'card stack';
|
listCard.className = 'card stack';
|
||||||
const listTitle = document.createElement('p');
|
const listTitle = document.createElement('p');
|
||||||
listTitle.className = 'field-label';
|
listTitle.className = 'field-label';
|
||||||
listTitle.textContent = 'Текущий список серверов доступа';
|
listTitle.textContent = 'Текущий сервер доступа';
|
||||||
const listHint = document.createElement('p');
|
const listHint = document.createElement('p');
|
||||||
listHint.className = 'meta-muted';
|
listHint.className = 'meta-muted';
|
||||||
listHint.textContent = sessionLogin
|
listHint.textContent = sessionLogin
|
||||||
@@ -308,10 +309,10 @@ export function render({ navigate }) {
|
|||||||
addCard.className = 'card stack';
|
addCard.className = 'card stack';
|
||||||
const addTitle = document.createElement('p');
|
const addTitle = document.createElement('p');
|
||||||
addTitle.className = 'field-label';
|
addTitle.className = 'field-label';
|
||||||
addTitle.textContent = 'Добавить сервер доступа';
|
addTitle.textContent = 'Сменить сервер доступа';
|
||||||
const addHint = document.createElement('p');
|
const addHint = document.createElement('p');
|
||||||
addHint.className = 'meta-muted';
|
addHint.className = 'meta-muted';
|
||||||
addHint.textContent = 'Можно использовать максимум два сервера доступа. Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.';
|
addHint.textContent = 'Введите логин нового сервера или несколько первых букв, затем выберите сервер из подсказок. Новый сервер заменит текущий.';
|
||||||
const addInput = document.createElement('input');
|
const addInput = document.createElement('input');
|
||||||
addInput.className = 'input';
|
addInput.className = 'input';
|
||||||
addInput.type = 'text';
|
addInput.type = 'text';
|
||||||
@@ -326,13 +327,12 @@ export function render({ navigate }) {
|
|||||||
const addButton = document.createElement('button');
|
const addButton = document.createElement('button');
|
||||||
addButton.className = 'primary-btn';
|
addButton.className = 'primary-btn';
|
||||||
addButton.type = 'button';
|
addButton.type = 'button';
|
||||||
addButton.textContent = 'Добавить сервер';
|
addButton.textContent = 'Сменить сервер';
|
||||||
addButton.disabled = true;
|
addButton.disabled = true;
|
||||||
addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton);
|
addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton);
|
||||||
|
|
||||||
const refreshAddButton = () => {
|
const refreshAddButton = () => {
|
||||||
addButton.disabled = operationBusy
|
addButton.disabled = operationBusy
|
||||||
|| currentAccessServers.length >= MAX_ACCESS_SERVERS
|
|
||||||
|| (!selectedCandidate && !normalizeLogin(addInput.value));
|
|| (!selectedCandidate && !normalizeLogin(addInput.value));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -352,9 +352,7 @@ export function render({ navigate }) {
|
|||||||
addInput.value = candidate.login;
|
addInput.value = candidate.login;
|
||||||
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
|
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
|
||||||
} else {
|
} else {
|
||||||
addStatus.textContent = currentAccessServers.length >= MAX_ACCESS_SERVERS
|
addStatus.textContent = 'Для смены сервера понадобится подпись root key.';
|
||||||
? 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.'
|
|
||||||
: 'Для изменения списка понадобится подпись root key.';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -368,69 +366,15 @@ export function render({ navigate }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentAccessServers.forEach((server, index) => {
|
currentAccessServers.forEach((server) => {
|
||||||
const button = document.createElement('button');
|
const row = document.createElement('div');
|
||||||
button.className = 'text-btn';
|
row.innerHTML = `
|
||||||
button.type = 'button';
|
|
||||||
button.disabled = operationBusy;
|
|
||||||
button.innerHTML = `
|
|
||||||
<span style="display:block; text-align:left;">
|
<span style="display:block; text-align:left;">
|
||||||
<strong>@${escapeHtml(server.login)}</strong>
|
<strong>@${escapeHtml(server.login)}</strong>
|
||||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(server.url || 'URL не указан')}</span>
|
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(server.url || 'URL не указан')}</span>
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
button.addEventListener('click', () => {
|
listBody.append(row);
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -465,16 +409,13 @@ export function render({ navigate }) {
|
|||||||
renderServerList();
|
renderServerList();
|
||||||
refreshAddButton();
|
refreshAddButton();
|
||||||
listStatus.textContent = rows.length
|
listStatus.textContent = rows.length
|
||||||
? `Найдено серверов доступа: ${rows.length}`
|
? 'Сервер доступа загружен из PDA.'
|
||||||
: 'В PDA пользователя пока нет серверов доступа.';
|
: 'В PDA пользователя пока нет серверов доступа.';
|
||||||
if (rows.length >= MAX_ACCESS_SERVERS) {
|
|
||||||
addStatus.textContent = 'У пользователя уже выбраны два сервера доступа. Сначала отключите один из них.';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
currentAccessServers = [];
|
currentAccessServers = [];
|
||||||
renderServerList();
|
renderServerList();
|
||||||
refreshAddButton();
|
refreshAddButton();
|
||||||
listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.';
|
listStatus.textContent = error?.message || 'Не удалось прочитать сервер доступа.';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -559,7 +500,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const passwordResult = await passwordModal?.open({
|
const passwordResult = await passwordModal?.open({
|
||||||
title: 'Нужен пароль для обновления серверов доступа',
|
title: 'Нужен пароль для обновления серверов доступа',
|
||||||
text: 'Чтобы изменить список серверов доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
text: 'Чтобы изменить сервер доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||||
note: savedClient
|
note: savedClient
|
||||||
? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.'
|
? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.'
|
||||||
: 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.',
|
: 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.',
|
||||||
@@ -604,7 +545,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const normalizedList = uniqueLogins(nextLogins);
|
const normalizedList = uniqueLogins(nextLogins);
|
||||||
setOperationBusy(true);
|
setOperationBusy(true);
|
||||||
target.textContent = String(inFlightText || 'Обновляем список серверов доступа...');
|
target.textContent = String(inFlightText || 'Обновляем сервер доступа...');
|
||||||
let signingMaterial = null;
|
let signingMaterial = null;
|
||||||
try {
|
try {
|
||||||
const currentPda = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
|
const currentPda = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
|
||||||
@@ -626,7 +567,7 @@ export function render({ navigate }) {
|
|||||||
if (isInsufficientFundsForRentError(error)) {
|
if (isInsufficientFundsForRentError(error)) {
|
||||||
showTopupRequiredStatus(target, signingMaterial?.clientAddress);
|
showTopupRequiredStatus(target, signingMaterial?.clientAddress);
|
||||||
} else {
|
} else {
|
||||||
target.textContent = error?.message || 'Не удалось обновить список серверов доступа.';
|
target.textContent = error?.message || 'Не удалось обновить сервер доступа.';
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -662,29 +603,25 @@ export function render({ navigate }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentAccessServers.some((item) => item.login === login)) {
|
if (currentAccessServers.some((item) => item.login === login)) {
|
||||||
addStatus.textContent = `Сервер @${login} уже есть в списке.`;
|
addStatus.textContent = `Сервер @${login} уже выбран.`;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (currentAccessServers.length >= MAX_ACCESS_SERVERS) {
|
|
||||||
addStatus.textContent = 'Нельзя добавить больше двух серверов доступа. Сначала отключите один из текущих.';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
||||||
confirmModal?.open({
|
confirmModal?.open({
|
||||||
title: 'Добавить сервер доступа?',
|
title: 'Сменить сервер доступа?',
|
||||||
text: `Вы хотите добавить сервер доступа @${resolved.serverLogin}?`,
|
text: `Заменить текущий сервер доступа на @${resolved.serverLogin}?`,
|
||||||
note: resolved.httpBase
|
note: resolved.httpBase
|
||||||
? `Адрес сервера: ${resolved.httpBase}\nИзменение будет записано в Solana user PDA.`
|
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.`
|
||||||
: 'Изменение будет записано в Solana user PDA.',
|
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.',
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
const nextList = uniqueLogins([...currentAccessServers.map((item) => item.login), resolved.serverLogin]);
|
const nextList = [resolved.serverLogin];
|
||||||
try {
|
try {
|
||||||
await updateAccessServers(nextList, {
|
await updateAccessServers(nextList, {
|
||||||
statusTarget: addStatus,
|
statusTarget: addStatus,
|
||||||
successText: `Сервер доступа @${resolved.serverLogin} добавлен.`,
|
successText: `Сервер доступа заменён на @${resolved.serverLogin}.`,
|
||||||
inFlightText: `Обновляем PDA и добавляем сервер @${resolved.serverLogin}...`,
|
inFlightText: `Обновляем PDA и меняем сервер на @${resolved.serverLogin}...`,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Сообщение уже показано в статусе.
|
// Сообщение уже показано в статусе.
|
||||||
@@ -698,7 +635,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Серверы доступа',
|
title: 'Сервер доступа',
|
||||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||||
}),
|
}),
|
||||||
introCard,
|
introCard,
|
||||||
|
|||||||
@@ -44,12 +44,12 @@ function renderAvatarPreview(slot, avatar, title) {
|
|||||||
const wrap = renderAvatar({
|
const wrap = renderAvatar({
|
||||||
initials: label.slice(0, 1).toUpperCase() || 'К',
|
initials: label.slice(0, 1).toUpperCase() || 'К',
|
||||||
avatar,
|
avatar,
|
||||||
size: 'small',
|
size: 'xl',
|
||||||
className: 'channel-profile-avatar',
|
className: 'channel-profile-avatar',
|
||||||
title: label,
|
title: label,
|
||||||
alt: 'Аватар канала',
|
alt: 'Аватар канала',
|
||||||
});
|
});
|
||||||
wrap.style.setProperty('--channel-avatar-size', '104px');
|
wrap.style.setProperty('--channel-avatar-size', '96px');
|
||||||
slot.append(wrap);
|
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="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>
|
<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>
|
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
||||||
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
|
<button class="shine-btn shine-btn--settings" type="button" data-action="upload-menu">Добавить файл</button>
|
||||||
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
|
<button class="shine-btn shine-btn--settings" 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="help">Справка</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function createThreadAvatar(login) {
|
|||||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
const avatarEl = renderUserAvatar({
|
const avatarEl = renderUserAvatar({
|
||||||
login: cleanLogin || 'unknown',
|
login: cleanLogin || 'unknown',
|
||||||
size: 'small',
|
size: 'sm',
|
||||||
className: 'channel-message-avatar',
|
className: 'channel-message-avatar',
|
||||||
title,
|
title,
|
||||||
});
|
});
|
||||||
@@ -77,7 +77,7 @@ function createThreadAvatar(login) {
|
|||||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
size: 'small',
|
size: 'sm',
|
||||||
className: 'channel-message-avatar',
|
className: 'channel-message-avatar',
|
||||||
title,
|
title,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function createMessageAvatar(login) {
|
|||||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
const avatarEl = renderUserAvatar({
|
const avatarEl = renderUserAvatar({
|
||||||
login: cleanLogin || 'unknown',
|
login: cleanLogin || 'unknown',
|
||||||
size: 'small',
|
size: 'sm',
|
||||||
className: 'channel-message-avatar',
|
className: 'channel-message-avatar',
|
||||||
title,
|
title,
|
||||||
});
|
});
|
||||||
@@ -101,7 +101,7 @@ function createMessageAvatar(login) {
|
|||||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
size: 'small',
|
size: 'sm',
|
||||||
className: 'channel-message-avatar',
|
className: 'channel-message-avatar',
|
||||||
title,
|
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 txId = String(channel?.avaAr || '').trim();
|
||||||
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
|
const title = String(channel?.displayTitle || channel?.name || 'К').trim() || 'К';
|
||||||
const wrap = renderAvatar({
|
const wrap = renderAvatar({
|
||||||
initials: title.slice(0, 1).toUpperCase() || 'К',
|
initials: title.slice(0, 1).toUpperCase() || 'К',
|
||||||
avatar: txId ? { ar: txId } : null,
|
avatar: txId ? { ar: txId } : null,
|
||||||
size: 'small',
|
size: 'xl',
|
||||||
className: 'channel-profile-avatar',
|
className: 'channel-profile-avatar',
|
||||||
title,
|
title,
|
||||||
alt: 'Аватар канала',
|
alt: 'Аватар канала',
|
||||||
@@ -626,7 +626,7 @@ function openChannelMetaDetailsModal({
|
|||||||
</div>
|
</div>
|
||||||
</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.querySelector('#about-channel-close')?.addEventListener('click', () => {
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { makeShineChannelRoute } from '../services/shine-routes.js';
|
|||||||
import { makeShineChannelShortRoute } from '../services/shine-routes.js';
|
import { makeShineChannelShortRoute } from '../services/shine-routes.js';
|
||||||
import { renderAvatar } from '../components/avatar-image.js';
|
import { renderAvatar } from '../components/avatar-image.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
|
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||||
@@ -31,6 +32,19 @@ const CHANNELS_VIEW_ALL = 'all';
|
|||||||
const CHANNELS_VIEW_OWNED = 'owned';
|
const CHANNELS_VIEW_OWNED = 'owned';
|
||||||
const CHANNELS_VIEW_FOLLOWING = 'following';
|
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) {
|
function cleanChannelMessagePreview(text) {
|
||||||
const parsed = parseMessageAttachments(text);
|
const parsed = parseMessageAttachments(text);
|
||||||
const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
|
const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
|
||||||
@@ -94,7 +108,7 @@ function createChannelAvatar(channel = {}) {
|
|||||||
return renderAvatar({
|
return renderAvatar({
|
||||||
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
|
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
|
||||||
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
|
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
|
||||||
size: 'small',
|
size: 'lg',
|
||||||
title: channel.displayTitle || channel.title || channel.name || 'Канал',
|
title: channel.displayTitle || channel.title || channel.name || 'Канал',
|
||||||
alt: 'Аватар канала',
|
alt: 'Аватар канала',
|
||||||
});
|
});
|
||||||
@@ -952,6 +966,7 @@ function openTopChannelsMenu({
|
|||||||
onFindChannel,
|
onFindChannel,
|
||||||
}) {
|
}) {
|
||||||
closeTopChannelsMenu(listState);
|
closeTopChannelsMenu(listState);
|
||||||
|
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
if (!root || !anchorEl) return;
|
if (!root || !anchorEl) return;
|
||||||
|
|
||||||
@@ -961,7 +976,9 @@ function openTopChannelsMenu({
|
|||||||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||||||
|
|
||||||
const estimatedHeight = 250;
|
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) {
|
if (top + estimatedHeight > window.innerHeight - 10) {
|
||||||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||||||
}
|
}
|
||||||
@@ -977,12 +994,8 @@ function openTopChannelsMenu({
|
|||||||
menu.style.width = `${Math.round(menuWidth)}px`;
|
menu.style.width = `${Math.round(menuWidth)}px`;
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: 'Поиск', action: () => onFindChannel?.() },
|
{ label: 'Найти канал', icon: 'search', action: () => onFindChannel?.() },
|
||||||
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
|
{ label: 'Новый канал', icon: 'add', 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)) },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
@@ -998,7 +1011,7 @@ function openTopChannelsMenu({
|
|||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'channel-menu-item';
|
btn.className = 'channel-menu-item';
|
||||||
btn.textContent = item.label;
|
btn.innerHTML = `${channelMenuIcon(item.icon)}<span>${item.label}</span>`;
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
closeTopChannelsMenu(listState);
|
closeTopChannelsMenu(listState);
|
||||||
item.action?.();
|
item.action?.();
|
||||||
@@ -1012,13 +1025,19 @@ function openTopChannelsMenu({
|
|||||||
const onOverlayClick = (event) => {
|
const onOverlayClick = (event) => {
|
||||||
if (event.target === overlay) closeTopChannelsMenu(listState);
|
if (event.target === overlay) closeTopChannelsMenu(listState);
|
||||||
};
|
};
|
||||||
|
const onPeerDropdownOpen = (event) => {
|
||||||
|
if (event?.detail?.owner === anchorEl) return;
|
||||||
|
closeTopChannelsMenu(listState);
|
||||||
|
};
|
||||||
const onWindowResize = () => closeTopChannelsMenu(listState);
|
const onWindowResize = () => closeTopChannelsMenu(listState);
|
||||||
|
|
||||||
overlay.addEventListener('click', onOverlayClick);
|
overlay.addEventListener('click', onOverlayClick);
|
||||||
window.addEventListener('resize', onWindowResize);
|
window.addEventListener('resize', onWindowResize);
|
||||||
|
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||||
listState.topMenuCleanup = () => {
|
listState.topMenuCleanup = () => {
|
||||||
overlay.removeEventListener('click', onOverlayClick);
|
overlay.removeEventListener('click', onOverlayClick);
|
||||||
window.removeEventListener('resize', onWindowResize);
|
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.type = 'button';
|
||||||
actionBtn.className = `channel-menu-item ${channel.isSubscribed ? 'destructive' : ''}`.trim();
|
actionBtn.className = `channel-menu-item ${channel.isSubscribed ? 'destructive' : ''}`.trim();
|
||||||
|
|
||||||
|
const actionLabel = document.createElement('span');
|
||||||
|
actionBtn.append(document.createRange().createContextualFragment(channelMenuIcon('subscribe')), actionLabel);
|
||||||
|
|
||||||
if (canToggleSubscription) {
|
if (canToggleSubscription) {
|
||||||
actionBtn.textContent = channel.pending
|
actionLabel.textContent = channel.pending
|
||||||
? 'Выполняется...'
|
? 'Выполняется...'
|
||||||
: channel.isSubscribed
|
: channel.isSubscribed
|
||||||
? 'Отписаться'
|
? 'Отписаться'
|
||||||
@@ -1075,7 +1097,7 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi
|
|||||||
|
|
||||||
channel.pending = true;
|
channel.pending = true;
|
||||||
actionBtn.disabled = true;
|
actionBtn.disabled = true;
|
||||||
actionBtn.textContent = 'Выполняется...';
|
actionLabel.textContent = 'Выполняется...';
|
||||||
|
|
||||||
const nextSubscribed = !channel.isSubscribed;
|
const nextSubscribed = !channel.isSubscribed;
|
||||||
try {
|
try {
|
||||||
@@ -1097,13 +1119,13 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
channel.pending = false;
|
channel.pending = false;
|
||||||
actionBtn.disabled = false;
|
actionBtn.disabled = false;
|
||||||
actionBtn.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться';
|
actionLabel.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться';
|
||||||
showToast(toUserMessage(error, 'Не удалось изменить подписку.'), { kind: 'error' });
|
showToast(toUserMessage(error, 'Не удалось изменить подписку.'), { kind: 'error' });
|
||||||
rerenderList();
|
rerenderList();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
actionBtn.textContent = 'Собственный канал';
|
actionLabel.textContent = 'Собственный канал';
|
||||||
actionBtn.disabled = true;
|
actionBtn.disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,7 +1133,8 @@ function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderLi
|
|||||||
toggleWrap.className = 'channel-menu-toggle';
|
toggleWrap.className = 'channel-menu-toggle';
|
||||||
|
|
||||||
const toggleLabel = document.createElement('span');
|
const toggleLabel = document.createElement('span');
|
||||||
toggleLabel.textContent = 'Уведомления';
|
toggleLabel.className = 'channel-menu-toggle-label';
|
||||||
|
toggleLabel.innerHTML = `${channelMenuIcon('notifications')}<span>Уведомления</span>`;
|
||||||
|
|
||||||
const toggleBtn = document.createElement('button');
|
const toggleBtn = document.createElement('button');
|
||||||
toggleBtn.type = 'button';
|
toggleBtn.type = 'button';
|
||||||
@@ -1174,16 +1197,24 @@ function renderChannelMain(channel) {
|
|||||||
main.append(desc);
|
main.append(desc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previewLine = document.createElement('div');
|
||||||
|
previewLine.className = 'channel-row-preview-line';
|
||||||
|
|
||||||
const preview = document.createElement('p');
|
const preview = document.createElement('p');
|
||||||
preview.className = 'channel-row-message';
|
preview.className = 'channel-row-message';
|
||||||
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
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');
|
const meta = document.createElement('p');
|
||||||
meta.className = 'channel-row-owner channel-counter-meta';
|
meta.className = 'channel-row-owner channel-counter-meta';
|
||||||
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
||||||
|
|
||||||
main.prepend(title, technical);
|
main.prepend(title, technical);
|
||||||
main.append(preview, meta);
|
main.append(previewLine, meta);
|
||||||
return main;
|
return main;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1220,10 +1251,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
const controls = document.createElement('div');
|
const controls = document.createElement('div');
|
||||||
controls.className = 'channel-row-controls';
|
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');
|
const count = document.createElement('span');
|
||||||
count.className = 'unread channel-row-count';
|
count.className = 'unread channel-row-count';
|
||||||
const unreadCount = Number(channel.unreadCount || 0);
|
const unreadCount = Number(channel.unreadCount || 0);
|
||||||
@@ -1231,7 +1258,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||||
count.classList.toggle('is-empty', unreadCount <= 0);
|
count.classList.toggle('is-empty', unreadCount <= 0);
|
||||||
|
|
||||||
controls.append(time, count);
|
controls.append(count);
|
||||||
|
|
||||||
row.append(avatar, main, controls);
|
row.append(avatar, main, controls);
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
@@ -1343,8 +1370,21 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const topBarLeft = document.createElement('div');
|
const topBarLeft = document.createElement('div');
|
||||||
topBarLeft.className = 'channels-top-left';
|
topBarLeft.className = 'channels-top-left';
|
||||||
|
|
||||||
const topTitle = document.createElement('strong');
|
const topTitle = document.createElement('button');
|
||||||
topTitle.className = 'channels-top-title';
|
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');
|
const topBarRight = document.createElement('div');
|
||||||
topBarRight.className = 'channels-top-right';
|
topBarRight.className = 'channels-top-right';
|
||||||
@@ -1411,6 +1451,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
closeChannelMenu(listState);
|
closeChannelMenu(listState);
|
||||||
closeTopChannelsMenu(listState);
|
closeTopChannelsMenu(listState);
|
||||||
|
channelsFilterMenu.destroy();
|
||||||
appScreen?.classList.remove('channels-scroll-clean');
|
appScreen?.classList.remove('channels-scroll-clean');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
|
||||||
import { directMessages } from '../mock-data.js';
|
import { directMessages } from '../mock-data.js';
|
||||||
import {
|
import {
|
||||||
addAppLogEntry,
|
addAppLogEntry,
|
||||||
@@ -36,6 +35,72 @@ import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-route
|
|||||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
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) {
|
function createChatHeaderParts(login, navigate) {
|
||||||
const cleanLogin = String(login || '').trim() || 'unknown';
|
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';
|
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
|
||||||
const initialAvatar = renderUserAvatar({
|
const initialAvatar = renderUserAvatar({
|
||||||
login: cleanLogin,
|
login: cleanLogin,
|
||||||
size: 'small',
|
size: 'md',
|
||||||
className: 'chat-header-avatar',
|
className: 'chat-header-avatar',
|
||||||
title: cleanLogin,
|
title: cleanLogin,
|
||||||
});
|
});
|
||||||
@@ -52,12 +117,13 @@ function createChatHeaderParts(login, navigate) {
|
|||||||
const avatarButton = document.createElement('button');
|
const avatarButton = document.createElement('button');
|
||||||
avatarButton.type = 'button';
|
avatarButton.type = 'button';
|
||||||
avatarButton.className = 'chat-header-avatar-btn';
|
avatarButton.className = 'chat-header-avatar-btn';
|
||||||
avatarButton.title = `Профиль ${cleanLogin}`;
|
avatarButton.title = `Меню ${cleanLogin}`;
|
||||||
avatarButton.setAttribute('aria-label', `Открыть профиль ${cleanLogin}`);
|
avatarButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
|
||||||
avatarButton.append(avatarSlot);
|
avatarButton.append(avatarSlot);
|
||||||
|
|
||||||
const loginEl = document.createElement('span');
|
const loginEl = document.createElement('button');
|
||||||
loginEl.className = 'chat-header-login';
|
loginEl.type = 'button';
|
||||||
|
loginEl.className = 'chat-header-login chat-header-login-btn';
|
||||||
loginEl.setAttribute('role', 'heading');
|
loginEl.setAttribute('role', 'heading');
|
||||||
loginEl.setAttribute('aria-level', '1');
|
loginEl.setAttribute('aria-level', '1');
|
||||||
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
|
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
|
||||||
@@ -76,7 +142,7 @@ function createChatHeaderParts(login, navigate) {
|
|||||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
size: 'small',
|
size: 'md',
|
||||||
className: 'chat-header-avatar',
|
className: 'chat-header-avatar',
|
||||||
title: cleanLogin,
|
title: cleanLogin,
|
||||||
});
|
});
|
||||||
@@ -84,23 +150,14 @@ function createChatHeaderParts(login, navigate) {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
const connectionsButton = document.createElement('button');
|
const openMenu = (event) => {
|
||||||
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', () => {
|
|
||||||
if (!cleanLogin || cleanLogin === 'unknown') return;
|
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', () => {
|
return { centerNode: loginEl, avatarButton };
|
||||||
if (!cleanLogin || cleanLogin === 'unknown') return;
|
|
||||||
navigate(makeProfileRoute(cleanLogin));
|
|
||||||
});
|
|
||||||
|
|
||||||
return { centerNode: loginEl, avatarButton, connectionsButton };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncatePreviewText(value, maxLen = 72) {
|
function truncatePreviewText(value, maxLen = 72) {
|
||||||
@@ -232,11 +289,11 @@ function openMessageActionsMenu({
|
|||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
|
<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}">
|
<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>' : ''}
|
${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">Скопировать как текст</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">Прочесть</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">Изменить</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">Удалить</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>` : ''}
|
${String(infoText || '').trim() ? `<div class="meta-muted">${String(infoText || '').trim()}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,10 +385,10 @@ function openChatActionsMenu({
|
|||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
|
<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}">
|
<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-call">${menuIconSvg('call')}<span>Звонок</span></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-video-call">${menuIconSvg('video')}<span>Видеозвонок</span></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" 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">Удалить чат</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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1083,7 +1140,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const chatHeaderLeft = chatHeader.querySelector('.header-left');
|
const chatHeaderLeft = chatHeader.querySelector('.header-left');
|
||||||
chatHeaderLeft?.append(chatHeaderParts.connectionsButton, chatHeaderParts.avatarButton);
|
chatHeaderLeft?.append(chatHeaderParts.avatarButton);
|
||||||
chrome?.setTopbar(chatHeader);
|
chrome?.setTopbar(chatHeader);
|
||||||
|
|
||||||
if (!isKnownContact) {
|
if (!isKnownContact) {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function render({ navigate }) {
|
|||||||
state.deviceConnect.blockchain = blockchainToggle.checked;
|
state.deviceConnect.blockchain = blockchainToggle.checked;
|
||||||
});
|
});
|
||||||
deviceToggle.addEventListener('change', () => {
|
deviceToggle.addEventListener('change', () => {
|
||||||
state.deviceConnect.device = true;
|
state.deviceConnect.client = true;
|
||||||
deviceToggle.checked = true;
|
deviceToggle.checked = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,22 +100,22 @@ export function render({ navigate }) {
|
|||||||
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
||||||
const hasRoot = Boolean(savedKeys.rootKey);
|
const hasRoot = Boolean(savedKeys.rootKey);
|
||||||
const hasBlockchain = Boolean(savedKeys.blockchainKey);
|
const hasBlockchain = Boolean(savedKeys.blockchainKey);
|
||||||
const hasDevice = Boolean(savedKeys.clientKey);
|
const hasClient = Boolean(savedKeys.clientKey);
|
||||||
|
|
||||||
rootToggle.disabled = !hasRoot;
|
rootToggle.disabled = !hasRoot;
|
||||||
blockchainToggle.disabled = !hasBlockchain;
|
blockchainToggle.disabled = !hasBlockchain;
|
||||||
deviceToggle.disabled = true;
|
deviceToggle.disabled = true;
|
||||||
state.deviceConnect.root = hasRoot && rootToggle.checked;
|
state.deviceConnect.root = hasRoot && rootToggle.checked;
|
||||||
state.deviceConnect.blockchain = hasBlockchain && blockchainToggle.checked;
|
state.deviceConnect.blockchain = hasBlockchain && blockchainToggle.checked;
|
||||||
state.deviceConnect.device = hasDevice;
|
state.deviceConnect.client = hasClient;
|
||||||
rootToggle.checked = state.deviceConnect.root;
|
rootToggle.checked = state.deviceConnect.root;
|
||||||
blockchainToggle.checked = state.deviceConnect.blockchain;
|
blockchainToggle.checked = state.deviceConnect.blockchain;
|
||||||
deviceToggle.checked = hasDevice;
|
deviceToggle.checked = hasClient;
|
||||||
openQrBtn.disabled = !hasDevice;
|
openQrBtn.disabled = !hasClient;
|
||||||
openPairBtn.disabled = !hasDevice;
|
openPairBtn.disabled = !hasClient;
|
||||||
|
|
||||||
const available = [
|
const available = [
|
||||||
hasDevice ? 'device' : '',
|
hasClient ? 'client' : '',
|
||||||
hasBlockchain ? 'blockchain' : '',
|
hasBlockchain ? 'blockchain' : '',
|
||||||
hasRoot ? 'root' : '',
|
hasRoot ? 'root' : '',
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
@@ -128,7 +128,7 @@ export function render({ navigate }) {
|
|||||||
deviceToggle.checked = false;
|
deviceToggle.checked = false;
|
||||||
state.deviceConnect.root = false;
|
state.deviceConnect.root = false;
|
||||||
state.deviceConnect.blockchain = false;
|
state.deviceConnect.blockchain = false;
|
||||||
state.deviceConnect.device = false;
|
state.deviceConnect.client = false;
|
||||||
openQrBtn.disabled = true;
|
openQrBtn.disabled = true;
|
||||||
openPairBtn.disabled = true;
|
openPairBtn.disabled = true;
|
||||||
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
|
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function createSearchAvatar(login) {
|
|||||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
const avatarEl = renderUserAvatar({
|
const avatarEl = renderUserAvatar({
|
||||||
login: cleanLogin || 'unknown',
|
login: cleanLogin || 'unknown',
|
||||||
size: 'small',
|
size: 'md',
|
||||||
className: 'avatar',
|
className: 'avatar',
|
||||||
title,
|
title,
|
||||||
});
|
});
|
||||||
@@ -49,7 +49,7 @@ function createSearchAvatar(login) {
|
|||||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
size: 'small',
|
size: 'md',
|
||||||
className: 'avatar',
|
className: 'avatar',
|
||||||
title,
|
title,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -260,15 +260,15 @@ export function render({ navigate }) {
|
|||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card stack settings-developer-card';
|
card.className = 'card stack settings-developer-card';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<button class="text-btn" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
|
||||||
<button class="text-btn" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
||||||
<button class="text-btn" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
||||||
<button class="text-btn" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
|
<button class="shine-btn shine-btn--settings" 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="shine-btn shine-btn--settings" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button>
|
||||||
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-app-log">Лог приложения</button>
|
||||||
<button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
||||||
<button class="text-btn" type="button" id="settings-pwa-install">Как установить PWA</button>
|
<button class="shine-btn shine-btn--settings" 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-upload-avatar">Загрузить аватар</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const appLogBtn = card.querySelector('#settings-app-log');
|
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';
|
return Number(sessionType || 0) === SESSION_TYPE_WALLET ? 'Wallet session' : 'Client session';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTransferKeys(savedKeys, { withExtras = false }) {
|
function buildTransferKeys(savedKeys, selection = {}) {
|
||||||
const keys = {
|
const keys = {
|
||||||
clientKey: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
|
clientKey: String(savedKeys?.clientKey || '').trim(),
|
||||||
blockchainKey: '',
|
blockchainKey: '',
|
||||||
rootKey: '',
|
rootKey: '',
|
||||||
};
|
};
|
||||||
if (!keys.clientKey) {
|
if (!keys.clientKey) {
|
||||||
throw new Error('На этом устройстве нет сохранённого client key для передачи.');
|
throw new Error('На этом устройстве нет сохранённого client key для передачи.');
|
||||||
}
|
}
|
||||||
if (withExtras) {
|
if (selection.blockchain && savedKeys?.blockchainKey) {
|
||||||
if (state.deviceConnect.blockchain && savedKeys?.blockchainKey) {
|
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
|
||||||
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
|
}
|
||||||
}
|
if (selection.root && savedKeys?.rootKey) {
|
||||||
if (state.deviceConnect.root && savedKeys?.rootKey) {
|
keys.rootKey = String(savedKeys.rootKey || '').trim();
|
||||||
keys.rootKey = String(savedKeys.rootKey || '').trim();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
@@ -73,10 +71,9 @@ function requestCardHtml(request) {
|
|||||||
<span class="meta-muted">Истекает: ${expiresText}</span>
|
<span class="meta-muted">Истекает: ${expiresText}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="flex-wrap:wrap;">
|
<div class="row pairing-request-actions" style="flex-wrap:wrap;">
|
||||||
<button class="ghost-btn" type="button" data-action="approve-device">${sessionOnly ? 'Подключить wallet-session' : 'Подключить без доп. ключей'}</button>
|
<button class="primary-btn pairing-approve-btn" type="button" data-action="approve-device">Подключить</button>
|
||||||
${sessionOnly ? '' : '<button class="primary-btn" type="button" data-action="approve-full">Подключить и передать ключи</button>'}
|
<button class="text-btn pairing-reject-btn" type="button" data-action="reject">Отклонить</button>
|
||||||
<button class="text-btn" type="button" data-action="reject">Отклонить</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -179,6 +176,7 @@ export function render({ navigate }) {
|
|||||||
let settingsBusy = false;
|
let settingsBusy = false;
|
||||||
let pairingPasswordConfigured = false;
|
let pairingPasswordConfigured = false;
|
||||||
let dialogMode = '';
|
let dialogMode = '';
|
||||||
|
let pendingTransferRequest = null;
|
||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
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');
|
const settingsCard = document.createElement('div');
|
||||||
settingsCard.className = 'card stack';
|
settingsCard.className = 'card stack';
|
||||||
const passwordIcons = makePasswordToggleIcons();
|
const passwordIcons = makePasswordToggleIcons();
|
||||||
@@ -432,11 +462,11 @@ export function render({ navigate }) {
|
|||||||
const loadSavedKeys = async () => {
|
const loadSavedKeys = async () => {
|
||||||
savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
||||||
const available = [];
|
const available = [];
|
||||||
if (savedKeys?.clientKey || savedKeys?.clientKey) available.push('client');
|
if (savedKeys?.clientKey) available.push('client');
|
||||||
if (savedKeys?.blockchainKey && state.deviceConnect.blockchain) available.push('blockchain');
|
if (savedKeys?.blockchainKey) available.push('blockchain');
|
||||||
if (savedKeys?.rootKey && state.deviceConnect.root) available.push('root');
|
if (savedKeys?.rootKey) available.push('root');
|
||||||
keySummaryEl.textContent = available.length
|
keySummaryEl.textContent = available.length > 1
|
||||||
? `При расширенном подключении будут переданы: ${available.join(', ')}.`
|
? `Доступны для подключения: ${available.join(', ')}.`
|
||||||
: 'На этом устройстве доступен только client key.';
|
: 'На этом устройстве доступен только client key.';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -460,10 +490,10 @@ export function render({ navigate }) {
|
|||||||
refreshBtn.disabled = flag;
|
refreshBtn.disabled = flag;
|
||||||
};
|
};
|
||||||
|
|
||||||
const approveRequest = async (request, mode) => {
|
const approveRequest = async (request, selection = {}) => {
|
||||||
const withExtras = mode === 'with-extras';
|
const withExtras = !!selection.root || !!selection.blockchain;
|
||||||
let payload;
|
let payload;
|
||||||
if (!withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
|
if (Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
|
||||||
const delegatedSession = await authService.createDelegatedSessionWithClientKey({
|
const delegatedSession = await authService.createDelegatedSessionWithClientKey({
|
||||||
login: state.session.login,
|
login: state.session.login,
|
||||||
clientPrivPkcs8: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
|
clientPrivPkcs8: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(),
|
||||||
@@ -477,7 +507,7 @@ export function render({ navigate }) {
|
|||||||
session: delegatedSession,
|
session: delegatedSession,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const keys = buildTransferKeys(savedKeys, { withExtras });
|
const keys = buildTransferKeys(savedKeys, selection);
|
||||||
payload = buildSecretsPayload({
|
payload = buildSecretsPayload({
|
||||||
login: state.session.login,
|
login: state.session.login,
|
||||||
keys,
|
keys,
|
||||||
@@ -486,7 +516,7 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
|
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
|
||||||
await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload));
|
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(
|
showToast(
|
||||||
withExtras
|
withExtras
|
||||||
? 'Ключи переданы на новое устройство'
|
? 'Ключи переданы на новое устройство'
|
||||||
@@ -505,6 +535,53 @@ export function render({ navigate }) {
|
|||||||
await reloadRequests({ silent: true });
|
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) => {
|
passwordDialog.addEventListener('click', (event) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!(target instanceof HTMLElement)) return;
|
if (!(target instanceof HTMLElement)) return;
|
||||||
@@ -609,9 +686,14 @@ export function render({ navigate }) {
|
|||||||
buttons.forEach((btn) => { btn.disabled = true; });
|
buttons.forEach((btn) => { btn.disabled = true; });
|
||||||
try {
|
try {
|
||||||
if (action === 'approve-device') {
|
if (action === 'approve-device') {
|
||||||
await approveRequest(request, 'device-only');
|
const isWallet = Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
|
||||||
} else if (action === 'approve-full') {
|
const hasExtras = !!String(savedKeys?.rootKey || '').trim() || !!String(savedKeys?.blockchainKey || '').trim();
|
||||||
await approveRequest(request, 'with-extras');
|
if (!isWallet && hasExtras) {
|
||||||
|
openTransferDialog(request);
|
||||||
|
buttons.forEach((btn) => { btn.disabled = false; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await approveRequest(request, {});
|
||||||
} else if (action === 'reject') {
|
} else if (action === 'reject') {
|
||||||
await runPairingOpWithSessionRestore(() => authService.rejectTrustedDeviceLogin(pairingId, 'rejected_by_user'));
|
await runPairingOpWithSessionRestore(() => authService.rejectTrustedDeviceLogin(pairingId, 'rejected_by_user'));
|
||||||
showToast('Заявка отклонена', { kind: 'error' });
|
showToast('Заявка отклонена', { kind: 'error' });
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function render({ navigate, route }) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const actionBtn = document.createElement('button');
|
const actionBtn = document.createElement('button');
|
||||||
actionBtn.className = 'text-btn';
|
actionBtn.className = 'shine-btn shine-btn--settings';
|
||||||
actionBtn.type = 'button';
|
actionBtn.type = 'button';
|
||||||
actionBtn.textContent = 'Завершить сеанс';
|
actionBtn.textContent = 'Завершить сеанс';
|
||||||
|
|
||||||
|
|||||||
@@ -54,11 +54,13 @@ export function render({ navigate }) {
|
|||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'card stack';
|
actions.className = 'card stack';
|
||||||
actions.innerHTML = `
|
actions.innerHTML = `
|
||||||
<button class="primary-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||||
<button class="ghost-btn" type="button" id="connect-device-btn">Подключить устройство</button>
|
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="pair-by-code-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="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('#connect-device-btn').addEventListener('click', () => navigate('connect-device-view'));
|
||||||
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-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));
|
currentMenu.append(createSessionItem(current, true));
|
||||||
|
|
||||||
const endCurrentSessionBtn = document.createElement('button');
|
const endCurrentSessionBtn = document.createElement('button');
|
||||||
endCurrentSessionBtn.className = 'text-btn';
|
endCurrentSessionBtn.className = 'shine-btn shine-btn--settings settings-bordered-btn';
|
||||||
endCurrentSessionBtn.type = 'button';
|
endCurrentSessionBtn.type = 'button';
|
||||||
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
||||||
endCurrentSessionBtn.addEventListener('click', async () => {
|
endCurrentSessionBtn.addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -33,24 +33,6 @@ export function render({ navigate }) {
|
|||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'card stack';
|
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) => {
|
SERVER_FIELDS.forEach((field) => {
|
||||||
const block = document.createElement('div');
|
const block = document.createElement('div');
|
||||||
block.className = 'stack';
|
block.className = 'stack';
|
||||||
@@ -68,7 +50,7 @@ export function render({ navigate }) {
|
|||||||
controls.className = 'row wrap-row';
|
controls.className = 'row wrap-row';
|
||||||
|
|
||||||
const checkButton = document.createElement('button');
|
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.type = 'button';
|
||||||
checkButton.textContent = 'Проверить';
|
checkButton.textContent = 'Проверить';
|
||||||
|
|
||||||
@@ -150,7 +132,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
if (isLocalDemoAvailable()) {
|
if (isLocalDemoAvailable()) {
|
||||||
const localDemoButton = document.createElement('button');
|
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.type = 'button';
|
||||||
localDemoButton.textContent = 'Открыть локальный тестовый режим';
|
localDemoButton.textContent = 'Открыть локальный тестовый режим';
|
||||||
localDemoButton.addEventListener('click', () => {
|
localDemoButton.addEventListener('click', () => {
|
||||||
@@ -161,7 +143,7 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serverUiButton = document.createElement('button');
|
const serverUiButton = document.createElement('button');
|
||||||
serverUiButton.className = 'ghost-btn';
|
serverUiButton.className = 'shine-btn shine-btn--settings';
|
||||||
serverUiButton.type = 'button';
|
serverUiButton.type = 'button';
|
||||||
serverUiButton.textContent = 'Настроить свой сервер';
|
serverUiButton.textContent = 'Настроить свой сервер';
|
||||||
serverUiButton.addEventListener('click', () => {
|
serverUiButton.addEventListener('click', () => {
|
||||||
@@ -170,13 +152,13 @@ export function render({ navigate }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cancelButton = document.createElement('button');
|
const cancelButton = document.createElement('button');
|
||||||
cancelButton.className = 'ghost-btn';
|
cancelButton.className = 'shine-btn shine-btn--settings';
|
||||||
cancelButton.type = 'button';
|
cancelButton.type = 'button';
|
||||||
cancelButton.textContent = 'Отмена';
|
cancelButton.textContent = 'Отмена';
|
||||||
cancelButton.addEventListener('click', () => navigate('start-view'));
|
cancelButton.addEventListener('click', () => navigate('start-view'));
|
||||||
|
|
||||||
const saveButton = document.createElement('button');
|
const saveButton = document.createElement('button');
|
||||||
saveButton.className = 'primary-btn';
|
saveButton.className = 'shine-btn shine-btn--primary';
|
||||||
saveButton.type = 'button';
|
saveButton.type = 'button';
|
||||||
saveButton.textContent = 'Сохранить';
|
saveButton.textContent = 'Сохранить';
|
||||||
saveButton.addEventListener('click', async () => {
|
saveButton.addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -1,43 +1,70 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { state } from '../state.js';
|
import { saveEntryLanguage, state } from '../state.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'language-view', title: 'Язык' };
|
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 }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
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(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Язык',
|
title: 'Язык / Language',
|
||||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
leftAction: { label: '←', onClick: () => {
|
||||||
|
sessionStorage.removeItem('shine-language-return-page');
|
||||||
|
navigate(returnPage);
|
||||||
|
} },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card stack';
|
card.className = 'card stack language-choice-card';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<label class="checkbox-row"><input type="radio" name="language" value="ru" ${state.entrySettings.language === 'ru' ? 'checked' : ''} /> Русский</label>
|
<p class="meta-muted language-choice-hint">Выберите язык интерфейса</p>
|
||||||
<label class="checkbox-row"><input type="radio" name="language" value="en" ${state.entrySettings.language === 'en' ? 'checked' : ''} /> English</label>
|
<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');
|
const syncSelection = () => {
|
||||||
actions.className = 'auth-footer-actions';
|
card.dataset.language = pendingLanguage;
|
||||||
actions.innerHTML = `
|
card.querySelectorAll('[data-language]').forEach((item) => {
|
||||||
<button class="primary-btn" type="button" id="language-ok">ОК</button>
|
const selected = item.dataset.language === pendingLanguage;
|
||||||
<button class="ghost-btn" type="button" id="language-cancel">Отмена</button>
|
item.classList.toggle('is-selected', selected);
|
||||||
`;
|
item.setAttribute('aria-checked', String(selected));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
syncSelection();
|
||||||
|
|
||||||
actions.querySelector('#language-ok').addEventListener('click', () => {
|
card.querySelectorAll('[data-language]').forEach((button) => {
|
||||||
const selected = card.querySelector('input[name="language"]:checked');
|
button.addEventListener('click', () => {
|
||||||
if (selected) {
|
pendingLanguage = button.dataset.language === 'en' ? 'en' : 'ru';
|
||||||
state.entrySettings.language = selected.value;
|
syncSelection();
|
||||||
}
|
});
|
||||||
navigate('settings-view');
|
});
|
||||||
|
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);
|
||||||
|
|
||||||
screen.append(card, actions);
|
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
|
||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
authorizeSession,
|
authorizeSession,
|
||||||
@@ -60,7 +59,7 @@ function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expi
|
|||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
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 pollTimer = 0;
|
||||||
let countdownTimer = 0;
|
let countdownTimer = 0;
|
||||||
let activePairingId = '';
|
let activePairingId = '';
|
||||||
@@ -70,34 +69,29 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
clearAuthMessages();
|
clearAuthMessages();
|
||||||
|
|
||||||
screen.append(
|
|
||||||
renderHeader({
|
|
||||||
title: 'Войти через другое устройство',
|
|
||||||
leftAction: { label: '←', onClick: () => navigate('login-view') },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const panel = document.createElement('section');
|
const panel = document.createElement('section');
|
||||||
panel.className = 'login-panel login-panel--wide stack';
|
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');
|
const formCard = document.createElement('div');
|
||||||
formCard.className = 'card stack';
|
formCard.className = 'card stack login-device-preparation';
|
||||||
formCard.innerHTML = `
|
formCard.innerHTML = `
|
||||||
<label class="stack">
|
<p class="auth-copy" id="pair-login-label"></p>
|
||||||
<span class="field-label">Введите логин</span>
|
<input id="pair-login" type="hidden" value="" />
|
||||||
<input class="input" id="pair-login" type="text" autocomplete="username" placeholder="" value="" />
|
<input id="pair-use-password" type="checkbox" hidden />
|
||||||
</label>
|
<label class="stack" id="pair-password-wrap" style="display:none;">
|
||||||
<label class="checkbox-row">
|
<span class="field-label">Дополнительный пароль подключения</span>
|
||||||
<input type="checkbox" id="pair-use-password" />
|
|
||||||
использовать доп. пароль
|
|
||||||
</label>
|
|
||||||
<label class="stack">
|
|
||||||
<span class="field-label">Пароль подключения</span>
|
|
||||||
<input class="input" id="pair-password" type="password" autocomplete="current-password" placeholder="Пароль, заданный на другом устройстве" />
|
<input class="input" id="pair-password" type="password" autocomplete="current-password" placeholder="Пароль, заданный на другом устройстве" />
|
||||||
</label>
|
</label>
|
||||||
<button class="primary-btn" type="button" id="pair-start-btn">Получить код</button>
|
<button class="primary-btn" type="button" id="pair-start-btn" style="display:none;">Получить код</button>
|
||||||
<p class="meta-muted" id="pair-mode-hint">Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.</p>
|
<p class="meta-muted" id="pair-mode-hint">Создаём код для входа…</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const status = document.createElement('p');
|
const status = document.createElement('p');
|
||||||
@@ -110,10 +104,14 @@ export function render({ navigate }) {
|
|||||||
resultWrap.innerHTML = codeCardHtml();
|
resultWrap.innerHTML = codeCardHtml();
|
||||||
|
|
||||||
const loginInput = formCard.querySelector('#pair-login');
|
const loginInput = formCard.querySelector('#pair-login');
|
||||||
|
const loginLabelEl = formCard.querySelector('#pair-login-label');
|
||||||
const usePasswordInput = formCard.querySelector('#pair-use-password');
|
const usePasswordInput = formCard.querySelector('#pair-use-password');
|
||||||
const passwordInput = formCard.querySelector('#pair-password');
|
const passwordInput = formCard.querySelector('#pair-password');
|
||||||
const startBtn = formCard.querySelector('#pair-start-btn');
|
const startBtn = formCard.querySelector('#pair-start-btn');
|
||||||
const modeHintEl = formCard.querySelector('#pair-mode-hint');
|
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 shortCodeEl = resultWrap.querySelector('#pairing-short-code');
|
||||||
const statusHintEl = resultWrap.querySelector('#pairing-status-hint');
|
const statusHintEl = resultWrap.querySelector('#pairing-status-hint');
|
||||||
const onlineHintEl = resultWrap.querySelector('#pairing-online-hint');
|
const onlineHintEl = resultWrap.querySelector('#pairing-online-hint');
|
||||||
@@ -127,12 +125,11 @@ export function render({ navigate }) {
|
|||||||
const syncPasswordUi = () => {
|
const syncPasswordUi = () => {
|
||||||
const usePassword = !!usePasswordInput.checked;
|
const usePassword = !!usePasswordInput.checked;
|
||||||
passwordInput.parentElement.style.display = usePassword ? '' : 'none';
|
passwordInput.parentElement.style.display = usePassword ? '' : 'none';
|
||||||
|
startBtn.style.display = usePassword ? '' : 'none';
|
||||||
modeHintEl.textContent = usePassword
|
modeHintEl.textContent = usePassword
|
||||||
? 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети. Если на доверённом устройстве включён доп. пароль, введите его.'
|
? 'На доверённом устройстве для входа по коду включён дополнительный пароль.'
|
||||||
: 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.';
|
: 'Код можно подтвердить на уже подключённом устройстве. Если оно сейчас не в сети, заявка будет ждать до истечения срока.';
|
||||||
if (!usePassword) {
|
if (!usePassword) passwordInput.value = '';
|
||||||
passwordInput.value = '';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopPolling = () => {
|
const stopPolling = () => {
|
||||||
@@ -157,6 +154,8 @@ export function render({ navigate }) {
|
|||||||
activePairingId = '';
|
activePairingId = '';
|
||||||
activeExpiresAtMs = 0;
|
activeExpiresAtMs = 0;
|
||||||
startBtn.disabled = false;
|
startBtn.disabled = false;
|
||||||
|
startBtn.style.display = '';
|
||||||
|
startBtn.textContent = 'Получить новый код';
|
||||||
cancelBtn.style.display = 'none';
|
cancelBtn.style.display = 'none';
|
||||||
resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl);
|
resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl);
|
||||||
setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error');
|
setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error');
|
||||||
@@ -183,7 +182,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const finalizeAuthorizedLogin = async (keys, login) => {
|
const finalizeAuthorizedLogin = async (keys, login) => {
|
||||||
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
||||||
await terminateCurrentSession({ closeServerSession: true });
|
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages().catch(() => {});
|
||||||
clearBrowserClientData();
|
clearBrowserClientData();
|
||||||
await clearClientAuthData().catch(() => {});
|
await clearClientAuthData().catch(() => {});
|
||||||
@@ -216,7 +215,7 @@ export function render({ navigate }) {
|
|||||||
sessionType: Number(payloadSession?.sessionType || 50) || 50,
|
sessionType: Number(payloadSession?.sessionType || 50) || 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
await terminateCurrentSession({ closeServerSession: true });
|
await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false });
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages().catch(() => {});
|
||||||
clearBrowserClientData();
|
clearBrowserClientData();
|
||||||
await clearClientAuthData().catch(() => {});
|
await clearClientAuthData().catch(() => {});
|
||||||
@@ -310,14 +309,16 @@ export function render({ navigate }) {
|
|||||||
setAuthBusy(true);
|
setAuthBusy(true);
|
||||||
setAuthError('');
|
setAuthError('');
|
||||||
setAuthInfo('');
|
setAuthInfo('');
|
||||||
setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info');
|
setStatus(status, 'Создаём код для входа…', 'info');
|
||||||
clearActivePairing();
|
clearActivePairing();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authService.reconnect(state.entrySettings.shineServer);
|
await authService.reconnect(state.entrySettings.shineServer);
|
||||||
const user = await authService.getUser(login);
|
const resolved = await authService.resolveLoginForAuth(login);
|
||||||
if (!user?.exists) {
|
if (String(resolved?.resolution || '').toUpperCase() !== 'LOCAL') {
|
||||||
throw new Error('Пользователь не найден.');
|
state.loginDraft.login = String(resolved?.login || login).trim();
|
||||||
|
navigate('login-view');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requesterMaterial = await createRequesterPairingMaterial();
|
requesterMaterial = await createRequesterPairingMaterial();
|
||||||
@@ -337,61 +338,68 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || '');
|
shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || '');
|
||||||
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.';
|
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить по коду.';
|
||||||
onlineHintEl.textContent = payload?.trustedSessionOnline
|
onlineHintEl.textContent = payload?.trustedSessionOnline
|
||||||
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.'
|
? 'Доверенное устройство сейчас в сети и может сразу принять заявку.'
|
||||||
: 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.';
|
: 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.';
|
||||||
resultWrap.style.display = '';
|
resultWrap.style.display = '';
|
||||||
cancelBtn.style.display = '';
|
|
||||||
startCountdown(payload?.expiresAtMs);
|
startCountdown(payload?.expiresAtMs);
|
||||||
state.loginDraft.login = login;
|
state.loginDraft.login = login;
|
||||||
setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info');
|
setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info');
|
||||||
schedulePoll();
|
schedulePoll();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
startBtn.disabled = false;
|
startBtn.disabled = false;
|
||||||
const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.');
|
if (String(error?.code || '').toUpperCase() === 'PAIRING_PASSWORD_INVALID' && !usePassword) {
|
||||||
setAuthError(message);
|
usePasswordInput.checked = true;
|
||||||
setStatus(status, message, 'error');
|
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 {
|
} finally {
|
||||||
setAuthBusy(false);
|
setAuthBusy(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cancelBtn.addEventListener('click', async () => {
|
const cancelActivePairingAndBack = async () => {
|
||||||
if (!activePairingId || !requesterMaterial?.sessionKey) {
|
const pairingId = activePairingId;
|
||||||
clearActivePairing();
|
const requesterSessionKey = requesterMaterial?.sessionKey;
|
||||||
startBtn.disabled = false;
|
isDisposed = true;
|
||||||
cancelBtn.style.display = 'none';
|
stopPolling();
|
||||||
return;
|
stopCountdown();
|
||||||
|
if (pairingId && requesterSessionKey) {
|
||||||
|
try {
|
||||||
|
await authService.cancelTrustedDeviceLogin(pairingId, requesterSessionKey);
|
||||||
|
} catch {
|
||||||
|
// Навигацию назад не блокируем из-за ошибки отмены уже созданной заявки.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cancelBtn.disabled = true;
|
navigate('login-view');
|
||||||
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';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
isDisposed = true;
|
isDisposed = true;
|
||||||
stopPolling();
|
stopPolling();
|
||||||
stopCountdown();
|
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);
|
panel.append(formCard, status, resultWrap);
|
||||||
screen.append(panel);
|
screen.append(panel);
|
||||||
|
|
||||||
|
if (!String(loginInput.value || '').trim()) {
|
||||||
|
window.setTimeout(() => navigate('login-view'), 0);
|
||||||
|
} else {
|
||||||
|
// После проверки логина на предыдущем экране код создаётся сразу.
|
||||||
|
window.setTimeout(() => startBtn.click(), 0);
|
||||||
|
}
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,256 +7,177 @@ import {
|
|||||||
state,
|
state,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import {
|
import { emptyPasswordWords, PASSWORD_MAX_LENGTH } from '../services/password-words.js';
|
||||||
composePasswordFromWords,
|
|
||||||
emptyPasswordWords,
|
|
||||||
normalizePasswordWords,
|
|
||||||
PASSWORD_MAX_LENGTH,
|
|
||||||
PASSWORD_WORDS_COUNT,
|
|
||||||
} from '../services/password-words.js';
|
|
||||||
|
|
||||||
function createWordsLayout({ words, onInput }) {
|
export const pageMeta = { id: 'login-password-view', title: 'Введите пароль', showAppChrome: false };
|
||||||
const section = document.createElement('div');
|
|
||||||
section.className = 'registration-words-block';
|
|
||||||
|
|
||||||
const grid = document.createElement('div');
|
function setStatus(statusEl, message) {
|
||||||
grid.className = 'registration-words-grid';
|
statusEl.textContent = message;
|
||||||
|
statusEl.style.display = message ? '' : 'none';
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack auth-screen auth-screen--lower';
|
||||||
|
|
||||||
clearAuthMessages();
|
clearAuthMessages();
|
||||||
|
|
||||||
const form = document.createElement('div');
|
const login = String(state.loginDraft.login || '').trim();
|
||||||
form.className = 'card stack';
|
if (!login) {
|
||||||
|
window.setTimeout(() => navigate('login-view'), 0);
|
||||||
|
}
|
||||||
|
|
||||||
let passwordMode = String(state.loginDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
|
const panel = document.createElement('section');
|
||||||
let passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
|
panel.className = 'login-panel stack';
|
||||||
|
|
||||||
const loginInput = document.createElement('input');
|
const title = document.createElement('h1');
|
||||||
loginInput.className = 'input';
|
title.className = 'login-panel-title';
|
||||||
loginInput.type = 'text';
|
title.textContent = 'Введите пароль';
|
||||||
loginInput.autocomplete = 'off';
|
|
||||||
loginInput.autocapitalize = 'off';
|
const passwordField = document.createElement('label');
|
||||||
loginInput.spellcheck = false;
|
passwordField.className = 'stack';
|
||||||
loginInput.value = state.loginDraft.login;
|
|
||||||
loginInput.placeholder = 'Введите логин';
|
|
||||||
|
|
||||||
const passwordInput = document.createElement('input');
|
const passwordInput = document.createElement('input');
|
||||||
passwordInput.className = 'input';
|
passwordInput.className = 'input';
|
||||||
passwordInput.type = 'password';
|
passwordInput.type = 'password';
|
||||||
passwordInput.name = 'shine-login-password';
|
passwordInput.name = 'shine-login-password';
|
||||||
passwordInput.autocomplete = 'new-password';
|
passwordInput.autocomplete = 'current-password';
|
||||||
passwordInput.autocapitalize = 'off';
|
passwordInput.autocapitalize = 'off';
|
||||||
passwordInput.spellcheck = false;
|
passwordInput.spellcheck = false;
|
||||||
passwordInput.maxLength = PASSWORD_MAX_LENGTH;
|
passwordInput.maxLength = PASSWORD_MAX_LENGTH;
|
||||||
passwordInput.value = passwordMode === 'single' ? state.loginDraft.password : '';
|
passwordInput.placeholder = 'Пароль';
|
||||||
passwordInput.placeholder = 'Введите пароль';
|
passwordInput.value = '';
|
||||||
|
|
||||||
const {
|
passwordField.append(passwordInput);
|
||||||
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 = 'Введите логин. На следующем шаге сохраните ключи на устройстве.';
|
|
||||||
|
|
||||||
const status = document.createElement('p');
|
const status = document.createElement('p');
|
||||||
status.className = 'status-line is-unavailable';
|
status.className = 'status-line is-unavailable';
|
||||||
status.style.display = 'none';
|
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');
|
const enterButton = document.createElement('button');
|
||||||
enterButton.className = 'primary-btn';
|
enterButton.className = 'primary-btn';
|
||||||
enterButton.type = 'button';
|
enterButton.type = 'button';
|
||||||
enterButton.textContent = 'Войти';
|
enterButton.textContent = 'Войти';
|
||||||
enterButton.addEventListener('click', async () => {
|
|
||||||
status.style.display = 'none';
|
|
||||||
syncDraftState();
|
|
||||||
|
|
||||||
if (!state.loginDraft.login) {
|
const actions = document.createElement('div');
|
||||||
status.textContent = 'Введите логин.';
|
actions.className = 'auth-footer-actions';
|
||||||
status.style.display = '';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (state.loginDraft.password.length > PASSWORD_MAX_LENGTH) {
|
if (password.length > PASSWORD_MAX_LENGTH) {
|
||||||
status.textContent = `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`;
|
setStatus(status, `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`);
|
||||||
status.style.display = '';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.loginDraft.password = password;
|
||||||
|
state.loginDraft.passwordMode = 'single';
|
||||||
|
state.loginDraft.passwordWords = emptyPasswordWords();
|
||||||
|
|
||||||
setAuthBusy(true);
|
setAuthBusy(true);
|
||||||
setAuthError('');
|
setAuthError('');
|
||||||
enterButton.disabled = true;
|
enterButton.disabled = true;
|
||||||
enterButton.textContent = 'Входим...';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authService.reconnect(state.entrySettings.shineServer);
|
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.flowType = 'login';
|
||||||
state.registrationDraft.login = result.login;
|
state.registrationDraft.login = result.login;
|
||||||
state.registrationDraft.password = state.loginDraft.password;
|
state.registrationDraft.password = password;
|
||||||
state.registrationDraft.passwordMode = state.loginDraft.passwordMode;
|
state.registrationDraft.passwordMode = 'single';
|
||||||
state.registrationDraft.passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
|
state.registrationDraft.passwordWords = emptyPasswordWords();
|
||||||
state.registrationDraft.sessionId = result.sessionId;
|
state.registrationDraft.sessionId = result.sessionId;
|
||||||
state.registrationDraft.storagePwd = result.storagePwd;
|
state.registrationDraft.storagePwd = result.storagePwd;
|
||||||
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
state.registrationDraft.pendingKeyBundle = keyBundle;
|
||||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||||
navigate('registration-keys-view');
|
navigate('registration-keys-view');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
overlay.style.display = 'none';
|
||||||
const message = toUserMessage(error, 'Не удалось выполнить вход.');
|
const message = toUserMessage(error, 'Не удалось выполнить вход.');
|
||||||
setAuthError(message);
|
setAuthError(message);
|
||||||
status.textContent = message;
|
setStatus(status, message);
|
||||||
status.style.display = '';
|
passwordInput.select();
|
||||||
} finally {
|
} finally {
|
||||||
setAuthBusy(false);
|
setAuthBusy(false);
|
||||||
enterButton.disabled = false;
|
enterButton.disabled = false;
|
||||||
enterButton.textContent = 'Войти';
|
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(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Войти по логину',
|
title: '',
|
||||||
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
leftAction: { label: '←', onClick: () => navigate('login-view') },
|
||||||
}),
|
}),
|
||||||
form,
|
panel,
|
||||||
actions,
|
overlay,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
window.setTimeout(() => passwordInput.focus(), 0);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,156 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
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 };
|
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 }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack auth-screen auth-screen--lower login-choice-screen';
|
screen.className = 'stack auth-screen auth-screen--lower login-choice-screen';
|
||||||
|
|
||||||
const loginButton = document.createElement('button');
|
clearAuthMessages();
|
||||||
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);
|
|
||||||
|
|
||||||
const panel = document.createElement('section');
|
const panel = document.createElement('section');
|
||||||
panel.className = 'login-panel stack';
|
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(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
@@ -35,5 +160,6 @@ export function render({ navigate }) {
|
|||||||
panel,
|
panel,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
window.setTimeout(() => loginInput.focus(), 0);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
|
getChatMessages,
|
||||||
isSessionInvalidError,
|
isSessionInvalidError,
|
||||||
normalizeDmChatId,
|
normalizeDmChatId,
|
||||||
setContacts,
|
setContacts,
|
||||||
@@ -8,9 +9,11 @@ import {
|
|||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
|
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||||
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
import { createShineConnectionsLogo } from '../components/shine-logo.js';
|
||||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
import { formatRelativeTime } from '../services/channels-ux.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||||
const PREVIEW_MAX_LEN = 200;
|
const PREVIEW_MAX_LEN = 200;
|
||||||
@@ -52,7 +55,7 @@ function createDmAvatar(login, { className = '' } = {}) {
|
|||||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
const avatarEl = renderUserAvatar({
|
const avatarEl = renderUserAvatar({
|
||||||
login: cleanLogin || 'unknown',
|
login: cleanLogin || 'unknown',
|
||||||
size: 'small',
|
size: 'lg',
|
||||||
title,
|
title,
|
||||||
className,
|
className,
|
||||||
});
|
});
|
||||||
@@ -67,7 +70,7 @@ function createDmAvatar(login, { className = '' } = {}) {
|
|||||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
size: 'small',
|
size: 'lg',
|
||||||
title,
|
title,
|
||||||
className,
|
className,
|
||||||
});
|
});
|
||||||
@@ -106,6 +109,8 @@ function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDialogPreview(dialog) {
|
async function resolveDialogPreview(dialog) {
|
||||||
|
const localText = clipPreviewText(String(dialog?.lastMessageText || '').trim());
|
||||||
|
if (localText) return localText;
|
||||||
const blobB64 = String(dialog?.lastMessageBlobB64 || '').trim();
|
const blobB64 = String(dialog?.lastMessageBlobB64 || '').trim();
|
||||||
if (!blobB64) return 'Диалог пока пуст.';
|
if (!blobB64) return 'Диалог пока пуст.';
|
||||||
|
|
||||||
@@ -145,15 +150,31 @@ async function resolveDialogPreview(dialog) {
|
|||||||
return pending;
|
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) {
|
function formatChatRowTime(ts) {
|
||||||
const value = Number(ts || 0);
|
return formatRelativeTime(ts);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareChatRows(a, b) {
|
function compareChatRows(a, b) {
|
||||||
@@ -174,7 +195,7 @@ export function render({ navigate, chrome }) {
|
|||||||
<div class="dm-head-brand">
|
<div class="dm-head-brand">
|
||||||
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
|
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
|
||||||
</div>
|
</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">
|
<div class="dm-head-menu-wrap">
|
||||||
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
|
<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>
|
<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');
|
const menuButton = head.querySelector('.dm-head-menu-btn');
|
||||||
menuButton?.append(createOverflowDots());
|
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 menuWrap = head.querySelector('.dm-head-menu-wrap');
|
||||||
const menuTemplate = head.querySelector('.dm-head-menu');
|
const menuTemplate = head.querySelector('.dm-head-menu');
|
||||||
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
|
// 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 menuWidth = menuPortal.offsetWidth || 206;
|
||||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
|
||||||
menuPortal.style.left = `${Math.round(left)}px`;
|
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 = () => {
|
const openHeadMenu = () => {
|
||||||
if (!menuButton || menuPortal) return;
|
if (!menuButton || menuPortal) return;
|
||||||
|
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: menuButton } }));
|
||||||
const portal = document.createElement('div');
|
const portal = document.createElement('div');
|
||||||
portal.className = 'dm-head-menu dm-head-menu--portal';
|
portal.className = 'dm-head-menu dm-head-menu--portal';
|
||||||
portal.setAttribute('role', 'menu');
|
portal.setAttribute('role', 'menu');
|
||||||
@@ -262,6 +307,10 @@ export function render({ navigate, chrome }) {
|
|||||||
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
|
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
|
||||||
closeHeadMenu();
|
closeHeadMenu();
|
||||||
};
|
};
|
||||||
|
const onPeerDropdownOpen = (event) => {
|
||||||
|
if (!menuPortal || event?.detail?.owner === menuButton) return;
|
||||||
|
closeHeadMenu();
|
||||||
|
};
|
||||||
const onMenuKeydown = (event) => {
|
const onMenuKeydown = (event) => {
|
||||||
if (event.key !== 'Escape' || !menuPortal) return;
|
if (event.key !== 'Escape' || !menuPortal) return;
|
||||||
closeHeadMenu();
|
closeHeadMenu();
|
||||||
@@ -270,6 +319,7 @@ export function render({ navigate, chrome }) {
|
|||||||
const onMenuViewportChange = () => positionHeadMenu();
|
const onMenuViewportChange = () => positionHeadMenu();
|
||||||
document.addEventListener('click', onOutsideClick);
|
document.addEventListener('click', onOutsideClick);
|
||||||
document.addEventListener('keydown', onMenuKeydown);
|
document.addEventListener('keydown', onMenuKeydown);
|
||||||
|
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||||
window.addEventListener('resize', onMenuViewportChange, { passive: true });
|
window.addEventListener('resize', onMenuViewportChange, { passive: true });
|
||||||
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
|
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
|
||||||
|
|
||||||
@@ -350,24 +400,33 @@ function renderRow(item) {
|
|||||||
unreadCount: Number(dialog?.unreadCount || 0),
|
unreadCount: Number(dialog?.unreadCount || 0),
|
||||||
hasDialog: Boolean(dialog?.hasDialog),
|
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);
|
const current = byPeer.get(key);
|
||||||
if (!current) {
|
if (!current) {
|
||||||
byPeer.set(key, next);
|
byPeer.set(key, next);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentRank = relationOrder(current.relationFlag);
|
const newest = next.lastMessageTimeMs > current.lastMessageTimeMs ? next : current;
|
||||||
const nextRank = relationOrder(relationFlag);
|
newest.relationFlag = relationOrder(relationFlag) < relationOrder(current.relationFlag)
|
||||||
if (nextRank < currentRank || (nextRank === currentRank && next.lastMessageTimeMs > current.lastMessageTimeMs)) {
|
? relationFlag
|
||||||
byPeer.set(key, next);
|
: 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 rows = Array.from(byPeer.values())
|
||||||
const orderA = relationOrder(a.relationFlag);
|
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
|
||||||
const orderB = relationOrder(b.relationFlag);
|
.sort((a, b) => {
|
||||||
if (orderA !== orderB) return orderA - orderB;
|
const orderA = relationOrder(a.relationFlag);
|
||||||
return compareChatRows(a, b);
|
const orderB = relationOrder(b.relationFlag);
|
||||||
});
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
return compareChatRows(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
@@ -425,14 +484,18 @@ function renderRow(item) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reloadForFilter = () => { void loadList(); };
|
||||||
|
|
||||||
chrome?.setTopbar(head);
|
chrome?.setTopbar(head);
|
||||||
screen.append(list);
|
screen.append(list);
|
||||||
loadList();
|
loadList();
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
closeHeadMenu();
|
closeHeadMenu();
|
||||||
|
chatFilterMenu.destroy();
|
||||||
document.removeEventListener('click', onOutsideClick);
|
document.removeEventListener('click', onOutsideClick);
|
||||||
document.removeEventListener('keydown', onMenuKeydown);
|
document.removeEventListener('keydown', onMenuKeydown);
|
||||||
|
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||||
window.removeEventListener('resize', onMenuViewportChange);
|
window.removeEventListener('resize', onMenuViewportChange);
|
||||||
window.removeEventListener('scroll', onMenuViewportChange, true);
|
window.removeEventListener('scroll', onMenuViewportChange, true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ function renderIdentity(item) {
|
|||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
avatar,
|
avatar,
|
||||||
size: 'small',
|
size: 'md',
|
||||||
className: 'notification-avatar',
|
className: 'notification-avatar',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export function render({ navigate, chrome }) {
|
|||||||
avatar: currentAvatar?.txId
|
avatar: currentAvatar?.txId
|
||||||
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
|
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
|
||||||
: null,
|
: null,
|
||||||
size: 'large',
|
size: 'xl',
|
||||||
className: 'profile-avatar',
|
className: 'profile-avatar',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { buildIdentityLines } from '../services/user-connections.js';
|
import { buildIdentityLines } from '../services/user-connections.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
|
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||||
|
|
||||||
@@ -110,84 +111,19 @@ export function render({ navigate, chrome }) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const profileMenuWrap = topbar.querySelector('.profile-head-menu-wrap');
|
|
||||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||||
profileMenuButton?.append(createOverflowDots());
|
profileMenuButton?.append(createOverflowDots());
|
||||||
let profileMenuPortal = null;
|
const profileMenu = createDropdownMenu({
|
||||||
|
anchorEl: profileMenuButton,
|
||||||
const closeProfileMenu = () => {
|
className: 'profile-head-menu',
|
||||||
profileMenuPortal?.remove();
|
minWidth: 230,
|
||||||
profileMenuPortal = null;
|
items: [
|
||||||
profileMenuButton?.setAttribute('aria-expanded', 'false');
|
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
|
||||||
profileMenuWrap?.classList.remove('is-open');
|
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
|
||||||
};
|
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
|
||||||
|
],
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
chrome?.setTopbar(topbar);
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
@@ -252,7 +188,7 @@ export function render({ navigate, chrome }) {
|
|||||||
avatar: currentAvatar?.txId
|
avatar: currentAvatar?.txId
|
||||||
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
|
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
|
||||||
: null,
|
: null,
|
||||||
size: 'large',
|
size: 'xl',
|
||||||
className: 'profile-avatar',
|
className: 'profile-avatar',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -382,5 +318,6 @@ export function render({ navigate, chrome }) {
|
|||||||
updateAvatarUi();
|
updateAvatarUi();
|
||||||
refreshProfileSnapshot();
|
refreshProfileSnapshot();
|
||||||
|
|
||||||
|
screen.cleanup = () => profileMenu.destroy();
|
||||||
return screen;
|
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',
|
id: 'hardware-device',
|
||||||
shortTitle: 'ESP32',
|
shortTitle: 'ESP32',
|
||||||
|
|||||||