diff --git a/AGENTS.md b/AGENTS.md index 286a1818..251b22aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,11 @@ - `docs/Solana_Architecture/README.md` - Документ формата пользовательской PDA-записи `shine_users` находится в: - `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` +- Актуальная документация по серверному модулю синхронизации Solana users находится в: + - `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md` +- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять: + - `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md` + - `deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md` ## Документация блокчейна - Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`. diff --git a/SHiNE-server/AGENTS.md b/SHiNE-server/AGENTS.md index 573f3bea..2cf116e6 100644 --- a/SHiNE-server/AGENTS.md +++ b/SHiNE-server/AGENTS.md @@ -9,7 +9,7 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web - `shine-server-net-server/` — точка входа, запуск HTTP/WS сервера - `shine-server-net-protocol/` — обработчики операций (RPC и события WS) -- `shine-server-db/` — DAO, SQL-схема, SQLite +- `shine-server-db/` — DAO, SQL-схема, PostgreSQL runtime - `shine-server-blockchain/` — логика хранения и проверки блоков блокчейна - `shine-server-crypto/` — криптографические утилиты - `shine-server-config/` — конфигурация сервера diff --git a/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java b/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java index 0d53fe4c..027779b0 100644 --- a/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java +++ b/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java @@ -59,6 +59,8 @@ public final class AppConfig { public String getParam(String name) { String fromSystem = System.getProperty(name); if (fromSystem != null) return fromSystem; + String fromEnv = System.getenv(toEnvName(name)); + if (fromEnv != null && !fromEnv.isBlank()) return fromEnv.trim(); return properties.getProperty(name); } @@ -78,4 +80,11 @@ public final class AppConfig { String v = properties.getProperty(name); return v == null ? defaultValue : Boolean.parseBoolean(v); } + + private static String toEnvName(String name) { + return name + .replace('.', '_') + .replace('-', '_') + .toUpperCase(); + } } diff --git a/SHiNE-server/shine-server-db/build.gradle b/SHiNE-server/shine-server-db/build.gradle index cf446c18..5be85995 100644 --- a/SHiNE-server/shine-server-db/build.gradle +++ b/SHiNE-server/shine-server-db/build.gradle @@ -16,7 +16,7 @@ repositories { } dependencies { - implementation 'org.xerial:sqlite-jdbc:3.47.0.0' // sqlite + implementation 'org.postgresql:postgresql:42.7.7' implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index d99c4706..5d00f285 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -1,49 +1,40 @@ package shine.db; -import utils.config.AppConfig; - -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.file.*; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.sql.DriverManager; +import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; /** - * DatabaseInitializer — создание новой SQLite-БД по схеме SHiNE. - * - * В этой версии: - * - создаём ТОЛЬКО таблицы/индексы - * - в конце вызываем DatabaseTriggersInstaller.createAllTriggers(st) - * - * v2 (sessions): - * - active_sessions.session_pwd удалён - * - active_sessions.session_key хранит публичный ключ сессии целиком одной строкой + * PostgreSQL runtime schema bootstrapper for SHiNE server. */ public final class DatabaseInitializer { public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version"; public static final int SCHEMA_VERSION_1 = 1; + public static final int SCHEMA_VERSION_2 = 2; + public static final int SCHEMA_VERSION_3 = 3; + public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; + public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; + public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; private DatabaseInitializer() {} - /* ===================== TEXT (msg_type=1) ===================== */ - public static final short TEXT_POST = 10; public static final short TEXT_EDIT_POST = 11; public static final short TEXT_REPLY = 20; public static final short TEXT_EDIT_REPLY = 21; public static final short TEXT_REPOST = 30; - /* ===================== REACTION (msg_type=2) ===================== */ - public static final short REACTION_LIKE = 1; public static final short REACTION_UNLIKE = 2; - /* ===================== CONNECTION (msg_type=3) ===================== */ - // Близкий друг (close friend). Исторически в коде использовалось имя FRIEND. public static final short CONNECTION_FRIEND = 10; public static final short CONNECTION_UNFRIEND = 11; public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND; @@ -76,636 +67,221 @@ public final class DatabaseInitializer { public static final short CONNECTION_SHINE_SEEN = 74; public static final short CONNECTION_SHINE_UNSEEN = 75; - public static void createNewDB(String[] args) { - AppConfig config = AppConfig.getInstance(); - String dbPath = config.getParam("db.path"); - - if (dbPath == null || dbPath.isBlank()) { - System.err.println("Параметр db.path не задан в application.properties"); - return; - } - - Path dbFile = Paths.get(dbPath); + public static void ensurePostgresSchemaInitialized(String jdbcUrl, + String user, + String password) throws SQLException { try { - Path parent = dbFile.getParent(); - if (parent != null && !Files.exists(parent)) { - Files.createDirectories(parent); - } - - if (Files.exists(dbFile)) { - System.out.println("Файл базы данных уже существует: " + dbFile.toAbsolutePath()); - System.out.print("Пересоздать БД (СТАРАЯ БУДЕТ УДАЛЕНА)? [y/N]: "); - - BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); - String answer = reader.readLine(); - if (!"y".equalsIgnoreCase(answer) && !"yes".equalsIgnoreCase(answer)) { - System.out.println("Операция отменена. БД не изменена."); - return; - } - - Files.delete(dbFile); - System.out.println("Старый файл БД удалён."); - } - - createSchema("jdbc:sqlite:" + dbPath); - System.out.println("Новая БД успешно создана по пути: " + dbFile.toAbsolutePath()); - - } catch (IOException e) { - System.err.println("Ошибка работы с файлом БД: " + e.getMessage()); - } catch (SQLException e) { - System.err.println("Ошибка создания схемы БД: " + e.getMessage()); - } - } - - public static void ensureSchemaV1Structure(String jdbcUrl) throws SQLException { - createSchema(jdbcUrl, false); - } - - private static void createSchema(String jdbcUrl) throws SQLException { - createSchema(jdbcUrl, true); - } - - private static void createSchema(String jdbcUrl, boolean initializeVersionRow) throws SQLException { - try { - Class.forName("org.sqlite.JDBC"); + Class.forName("org.postgresql.Driver"); } catch (ClassNotFoundException e) { - throw new RuntimeException("SQLite JDBC driver not found", e); + throw new RuntimeException("PostgreSQL JDBC driver not found", e); } - try (Connection conn = DriverManager.getConnection(jdbcUrl); - Statement st = conn.createStatement()) { - - st.execute("PRAGMA foreign_keys = ON"); - - // 1. solana_users - // ВАЖНО: - // - Все требуемые поля теперь лежат в solana_users: - // login, blockchain_name, solana_key, blockchain_key, client_key - // - Поиск по login в DAO сделан case-insensitive. - // - Для защиты от дублей "Anya" и "anya" добавляем COLLATE NOCASE на PRIMARY KEY. - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS solana_users ( - login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - blockchain_name TEXT NOT NULL, - solana_key TEXT NOT NULL, - blockchain_key TEXT NOT NULL, - client_key TEXT NOT NULL - ); - """); - - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_users_blockchain_name - ON solana_users (blockchain_name); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_solana_users_login - ON solana_users (login); - """); - - // 2. active_sessions (v2) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS active_sessions ( - session_id TEXT NOT NULL PRIMARY KEY, - login TEXT NOT NULL, - session_key TEXT NOT NULL, - storage_pwd TEXT NOT NULL, - session_created_at_ms INTEGER NOT NULL, - last_authirificated_at_ms INTEGER NOT NULL, - push_endpoint TEXT, - push_p256dh_key TEXT, - push_auth_key TEXT, - client_ip TEXT, - client_info_from_client TEXT, - client_info_from_request TEXT, - session_type INTEGER NOT NULL DEFAULT 1, - client_platform TEXT NOT NULL DEFAULT '', - user_language TEXT, - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_active_sessions_login - ON active_sessions (login); - """); - - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS esp_pairing_settings ( - login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - enabled INTEGER NOT NULL DEFAULT 0, - password_hash TEXT NOT NULL DEFAULT '', - ttl_seconds INTEGER NOT NULL DEFAULT 300, - failed_attempts INTEGER NOT NULL DEFAULT 0, - first_failed_at_ms INTEGER NOT NULL DEFAULT 0, - blocked_until_ms INTEGER NOT NULL DEFAULT 0, - updated_at_ms INTEGER NOT NULL, - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS esp_pairing_requests ( - pairing_id TEXT NOT NULL PRIMARY KEY, - login TEXT NOT NULL, - requester_session_key TEXT NOT NULL, - requester_session_type INTEGER NOT NULL DEFAULT 1, - requester_client_platform TEXT NOT NULL DEFAULT '', - payload_type INTEGER NOT NULL, - status TEXT NOT NULL, - short_code TEXT NOT NULL, - fingerprint_b58 TEXT NOT NULL, - encrypted_payload TEXT, - reject_reason TEXT, - approved_by_session_id TEXT, - created_at_ms INTEGER NOT NULL, - expires_at_ms INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL, - delivered_to_homeserver INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status - ON esp_pairing_requests (login, status, expires_at_ms); - """); - - // 3. users_params - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS users_params ( - login TEXT NOT NULL, - param TEXT NOT NULL, - time_ms INTEGER NOT NULL, - value TEXT NOT NULL, - client_key TEXT, - signature TEXT, - FOREIGN KEY (login) REFERENCES solana_users(login), - UNIQUE (login, param) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_users_params_login - ON users_params (login); - """); - - // 4. ip_geo_cache - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS ip_geo_cache ( - ip TEXT NOT NULL PRIMARY KEY, - geo TEXT, - updated_at_ms INTEGER NOT NULL - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_ip_geo_cache_updated_at - ON ip_geo_cache (updated_at_ms); - """); - - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS test_free_avatar_uploads ( - login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - used_count INTEGER NOT NULL DEFAULT 0, - updated_at_ms INTEGER NOT NULL, - last_tx_id TEXT NOT NULL DEFAULT '', - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated - ON test_free_avatar_uploads (updated_at_ms); - """); - - // 5. blockchain_state - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS blockchain_state ( - blockchain_name TEXT NOT NULL PRIMARY KEY, - login TEXT NOT NULL, - blockchain_key TEXT NOT NULL, - - size_limit INTEGER NOT NULL, - file_size_bytes INTEGER NOT NULL, - - last_block_number INTEGER NOT NULL, - last_block_hash BLOB, - - updated_at_ms INTEGER NOT NULL, - - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blockchain_state_login - ON blockchain_state (login); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at - ON blockchain_state (updated_at_ms); - """); - - // 6. blocks (+ line_code) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS blocks ( - login TEXT NOT NULL, - bch_name TEXT NOT NULL, - block_number INTEGER NOT NULL CHECK(block_number >= 0), - - msg_type INTEGER NOT NULL, - msg_sub_type INTEGER NOT NULL, - - block_bytes BLOB NOT NULL, - - -- target (reply/like/edit и т.д.) - to_login TEXT, - to_bch_name TEXT, - to_block_number INTEGER CHECK(to_block_number IS NULL OR to_block_number >= 0), - to_block_hash BLOB, - - -- собственные данные - block_hash BLOB NOT NULL, - block_signature BLOB NOT NULL, - - -- если этот блок был изменён последним edit'ом - edited_by_block_number INTEGER CHECK(edited_by_block_number IS NULL OR edited_by_block_number >= 0), - - -- линейность (опционально) - line_code INTEGER CHECK(line_code IS NULL OR line_code >= 0), - prev_line_number INTEGER CHECK(prev_line_number IS NULL OR prev_line_number >= 0), - prev_line_hash BLOB, - this_line_number INTEGER CHECK(this_line_number IS NULL OR this_line_number >= 0), - - FOREIGN KEY (login) REFERENCES solana_users(login), - FOREIGN KEY (bch_name) REFERENCES blockchain_state(blockchain_name), - - UNIQUE (bch_name, block_number) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number - ON blocks (bch_name, block_number); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blocks_to_target - ON blocks (to_login, to_bch_name, to_block_number); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blocks_by_line - ON blocks (bch_name, line_code, this_line_number); - """); - - // 7) connections_state - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS connections_state ( - login TEXT NOT NULL, - rel_type INTEGER NOT NULL, - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER NOT NULL, - to_block_hash BLOB NOT NULL, - - FOREIGN KEY (login) REFERENCES solana_users(login), - - UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_login - ON connections_state (login); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_to_login - ON connections_state (to_login); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_pair - ON connections_state (login, to_login); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_target - ON connections_state (login, rel_type, to_bch_name, to_block_number); - """); - - // 8) message_stats - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS message_stats ( - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER NOT NULL, - to_block_hash BLOB NOT NULL, - - likes_count INTEGER NOT NULL DEFAULT 0, - replies_count INTEGER NOT NULL DEFAULT 0, - edits_count INTEGER NOT NULL DEFAULT 0, - - UNIQUE ( - to_login, - to_bch_name, - to_block_number, - to_block_hash - ) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_message_stats_target - ON message_stats (to_bch_name, to_block_number, to_block_hash); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_message_stats_login - ON message_stats (to_login); - """); - - // 8.0) reactions_state (идемпотентный LIKE/UNLIKE per actor/target) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS reactions_state ( - from_login TEXT NOT NULL, - from_bch_name TEXT NOT NULL, - reaction_type INTEGER NOT NULL, - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER NOT NULL, - to_block_hash BLOB NOT NULL, - last_sub_type INTEGER NOT NULL, - - UNIQUE ( - from_login, - from_bch_name, - reaction_type, - to_login, - to_bch_name, - to_block_number, - to_block_hash - ) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_reactions_state_target - ON reactions_state (to_bch_name, to_block_number, to_block_hash); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_reactions_state_actor - ON reactions_state (from_login, from_bch_name, reaction_type); - """); - - // 9) channel_names_state (global normalized channel names) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS channel_names_state ( - slug TEXT NOT NULL, - display_name TEXT NOT NULL, - channel_description TEXT NOT NULL DEFAULT '', - owner_login TEXT NOT NULL, - owner_bch_name TEXT NOT NULL, - channel_type_code INTEGER NOT NULL DEFAULT 1, - channel_type_version INTEGER NOT NULL DEFAULT 1, - channel_root_block_number INTEGER NOT NULL, - channel_root_block_hash BLOB NOT NULL, - created_at_ms INTEGER NOT NULL - ); - """); - - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug - ON channel_names_state (owner_bch_name, channel_type_code, slug); - """); - - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target - ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner - ON channel_names_state (owner_login, owner_bch_name); - """); - - // 9.1) chat200_state - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS chat200_state ( - owner_login TEXT NOT NULL, - owner_bch_name TEXT NOT NULL, - channel_root_block_number INTEGER NOT NULL, - channel_root_block_hash BLOB NOT NULL, - channel_name TEXT NOT NULL, - channel_type_version INTEGER NOT NULL, - chat_title TEXT NOT NULL DEFAULT '', - updated_at_ms INTEGER NOT NULL, - PRIMARY KEY (owner_bch_name, channel_root_block_number) - ); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_chat200_state_owner - ON chat200_state (owner_login, owner_bch_name); - """); - - // 9.2) chat200_members_state - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS chat200_members_state ( - owner_bch_name TEXT NOT NULL, - channel_root_block_number INTEGER NOT NULL, - member_login TEXT NOT NULL, - member_channel_name TEXT NOT NULL, - is_active INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL, - updated_by_block_number INTEGER NOT NULL, - PRIMARY KEY ( - owner_bch_name, - channel_root_block_number, - member_login, - member_channel_name - ) - ); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_chat200_members_owner - ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active); - """); - - // 10) direct_messages - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS direct_messages ( - message_id TEXT NOT NULL PRIMARY KEY, - from_login TEXT NOT NULL, - to_login TEXT NOT NULL, - text TEXT NOT NULL, - created_at_ms INTEGER NOT NULL, - FOREIGN KEY (from_login) REFERENCES solana_users(login), - FOREIGN KEY (to_login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_direct_messages_to_login - ON direct_messages (to_login, created_at_ms); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_direct_messages_from_login - ON direct_messages (from_login, created_at_ms); - """); - - // 11) user_push_tokens - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS user_push_tokens ( - token_id TEXT NOT NULL PRIMARY KEY, - login TEXT NOT NULL, - session_id TEXT NOT NULL, - provider TEXT NOT NULL, - token TEXT NOT NULL, - platform TEXT, - user_agent TEXT, - updated_at_ms INTEGER NOT NULL, - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login - ON user_push_tokens (login); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login_session - ON user_push_tokens (login, session_id); - """); - - // 11) signed_direct_message_replay (anti-replay window) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS signed_direct_message_replay ( - from_login TEXT NOT NULL, - time_ms INTEGER NOT NULL, - nonce INTEGER NOT NULL, - created_at_ms INTEGER NOT NULL, - UNIQUE (from_login, time_ms, nonce) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created - ON signed_direct_message_replay (created_at_ms); - """); - - // 12) signed_direct_messages_history (сырой бинарный пакет + мета) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS signed_direct_messages_history ( - message_id TEXT NOT NULL PRIMARY KEY, - from_login TEXT NOT NULL, - to_login TEXT NOT NULL, - target_mode INTEGER NOT NULL, - target_session_id TEXT, - message_type INTEGER NOT NULL, - time_ms INTEGER NOT NULL, - nonce INTEGER NOT NULL, - raw_packet BLOB NOT NULL, - created_at_ms INTEGER NOT NULL, - FOREIGN KEY (from_login) REFERENCES solana_users(login), - FOREIGN KEY (to_login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_signed_dm_history_to - ON signed_direct_messages_history (to_login, created_at_ms); - """); - - // 13) signed_messages_v2 (универсальное хранилище блоков типов 1..8) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS signed_messages_v2 ( - message_key TEXT NOT NULL PRIMARY KEY, - base_key TEXT NOT NULL, - target_login TEXT NOT NULL, - from_login TEXT NOT NULL, - to_login TEXT NOT NULL, - time_ms INTEGER NOT NULL, - nonce INTEGER NOT NULL, - message_type INTEGER NOT NULL, - revision_time_ms INTEGER NOT NULL DEFAULT 0, - reencrypted_at_ms INTEGER NOT NULL DEFAULT 0, - raw_block BLOB NOT NULL, - created_at_ms INTEGER NOT NULL, - source_api TEXT NOT NULL, - origin_session_id TEXT, - receipt_ref_base_key TEXT, - receipt_ref_type INTEGER, - read_at_ms INTEGER, - FOREIGN KEY (from_login) REFERENCES solana_users(login), - FOREIGN KEY (to_login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_signed_messages_v2_target - ON signed_messages_v2 (target_login, time_ms, created_at_ms); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_signed_messages_v2_base - ON signed_messages_v2 (base_key, message_type); - """); - - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming - ON signed_messages_v2 (target_login, receipt_ref_base_key) - WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL; - """); - - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing - ON signed_messages_v2 (target_login, receipt_ref_base_key) - WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL; - """); - - // 14) signed_message_session_delivery (доставка по сессиям) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS signed_message_session_delivery ( - message_key TEXT NOT NULL, - session_id TEXT NOT NULL, - delivered INTEGER NOT NULL DEFAULT 0, - delivered_at_ms INTEGER, - created_at_ms INTEGER NOT NULL, - PRIMARY KEY (message_key, session_id), - FOREIGN KEY (message_key) REFERENCES signed_messages_v2(message_key) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session - ON signed_message_session_delivery (session_id, delivered); - """); - - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS db_schema_version ( - id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), - schema_version INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL - ); - """); - - if (initializeVersionRow) { - st.executeUpdate(""" - INSERT INTO db_schema_version (id, schema_version, updated_at_ms) - VALUES (1, 1, CAST(strftime('%s','now') AS INTEGER) * 1000) - ON CONFLICT(id) DO UPDATE SET - schema_version = excluded.schema_version, - updated_at_ms = excluded.updated_at_ms; - """); + try (Connection conn = openConnection(jdbcUrl, user, password)) { + if (!postgresSchemaVersionTableExists(conn)) { + runSqlScript(conn, POSTGRES_SCHEMA_RESOURCE); + return; + } + int currentVersion = readCurrentSchemaVersion(conn); + if (currentVersion < SCHEMA_VERSION_2) { + runSqlScript(conn, POSTGRES_MIGRATION_V2_RESOURCE); + currentVersion = SCHEMA_VERSION_2; + } + if (currentVersion < SCHEMA_VERSION_3) { + runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE); + } + } + } + + private static Connection openConnection(String jdbcUrl, String user, String password) throws SQLException { + if (user == null || user.isBlank()) { + return DriverManager.getConnection(jdbcUrl); + } + return DriverManager.getConnection(jdbcUrl, user, password == null ? "" : password); + } + + private static boolean postgresSchemaVersionTableExists(Connection conn) throws SQLException { + String sql = """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = ? + ) + """; + try (var ps = conn.prepareStatement(sql)) { + ps.setString(1, DB_SCHEMA_VERSION_TABLE); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() && rs.getBoolean(1); + } + } + } + + private static int readCurrentSchemaVersion(Connection conn) throws SQLException { + String sql = """ + SELECT schema_version + FROM db_schema_version + WHERE id = 1 + """; + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery(sql)) { + if (!rs.next()) { + return SCHEMA_VERSION_1; + } + return rs.getInt(1); + } + } + + private static void runSqlScript(Connection conn, String resourcePath) throws SQLException { + String sqlScript = loadClasspathResource(resourcePath); + List statements = splitSqlStatements(sqlScript); + + boolean previousAutoCommit = conn.getAutoCommit(); + conn.setAutoCommit(true); + try (Statement st = conn.createStatement()) { + for (String statement : statements) { + String trimmed = statement.trim(); + if (!trimmed.isEmpty()) { + st.execute(trimmed); + } + } + } finally { + conn.setAutoCommit(previousAutoCommit); + } + } + + private static String loadClasspathResource(String resourcePath) { + try (InputStream in = DatabaseInitializer.class.getClassLoader().getResourceAsStream(resourcePath)) { + if (in == null) { + throw new RuntimeException("Resource not found: " + resourcePath); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException("Failed to read resource: " + resourcePath, e); + } + } + + static List splitSqlStatements(String sqlScript) { + List statements = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean inSingleQuote = false; + boolean inLineComment = false; + boolean inBlockComment = false; + String dollarQuoteTag = null; + + for (int i = 0; i < sqlScript.length(); i++) { + char ch = sqlScript.charAt(i); + + if (inLineComment) { + current.append(ch); + if (ch == '\n') { + inLineComment = false; + } + continue; } - DatabaseTriggersInstaller.createAllTriggers(st); + if (inBlockComment) { + current.append(ch); + if (ch == '*' && i + 1 < sqlScript.length() && sqlScript.charAt(i + 1) == '/') { + current.append('/'); + i += 1; + inBlockComment = false; + } + continue; + } + + if (dollarQuoteTag != null) { + if (startsWithAt(sqlScript, i, dollarQuoteTag)) { + current.append(dollarQuoteTag); + i += dollarQuoteTag.length() - 1; + dollarQuoteTag = null; + } else { + current.append(ch); + } + continue; + } + + if (inSingleQuote) { + current.append(ch); + if (ch == '\'') { + if (i + 1 < sqlScript.length() && sqlScript.charAt(i + 1) == '\'') { + current.append('\''); + i += 1; + } else { + inSingleQuote = false; + } + } + continue; + } + + if (ch == '-' && i + 1 < sqlScript.length() && sqlScript.charAt(i + 1) == '-') { + current.append("--"); + i += 1; + inLineComment = true; + continue; + } + + if (ch == '/' && i + 1 < sqlScript.length() && sqlScript.charAt(i + 1) == '*') { + current.append("/*"); + i += 1; + inBlockComment = true; + continue; + } + + if (ch == '\'') { + inSingleQuote = true; + current.append(ch); + continue; + } + + String tag = readDollarQuoteTag(sqlScript, i); + if (tag != null) { + current.append(tag); + i += tag.length() - 1; + dollarQuoteTag = tag; + continue; + } + + if (ch == ';') { + String statement = current.toString().trim(); + if (!statement.isEmpty()) { + statements.add(current.toString()); + } + current.setLength(0); + continue; + } + + current.append(ch); } + + if (!current.isEmpty()) { + String statement = current.toString().trim(); + if (!statement.isEmpty()) { + statements.add(current.toString()); + } + } + return statements; + } + + private static boolean startsWithAt(String sqlScript, int index, String token) { + return sqlScript.regionMatches(index, token, 0, token.length()); + } + + private static String readDollarQuoteTag(String sqlScript, int index) { + if (sqlScript.charAt(index) != '$') { + return null; + } + + int end = index + 1; + while (end < sqlScript.length()) { + char current = sqlScript.charAt(end); + if (current == '$') { + return sqlScript.substring(index, end + 1); + } + if (!(Character.isLetterOrDigit(current) || current == '_')) { + return null; + } + end++; + } + return null; } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java deleted file mode 100644 index cb430a35..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java +++ /dev/null @@ -1,569 +0,0 @@ -package shine.db; - -import java.sql.SQLException; -import java.sql.ResultSet; -import java.sql.Statement; -import java.util.ArrayList; -import java.util.List; - -/** - * DatabaseTriggersInstaller — устанавливает триггеры, которые поддерживают бизнес-логику БД. - * - * РњС‹ специально сделали триггеры максимально "совместимыми": - * - НЕТ динамических сообщений РІ RAISE(...): только фиксированные строки. - * (Некоторые SQLite-СЃР±РѕСЂРєРё / просмотрщики падают РЅР° "||" внутри RAISE.) - * - НЕТ UPSERT "ON CONFLICT DO UPDATE" — вместо него: - * INSERT OR IGNORE + UPDATE - * (Старые SQLite РЅРµ знают UPSERT.) - * - * ============================================================================= - * ОПИСАНИЕ ТРИГГЕРОВ - * ============================================================================= - * - * [1] trg_blocks_line_integrity_bi (BEFORE INSERT ON blocks) - * Контроль целостности "линий" (line_code / prev_line_number / prev_line_hash / this_line_number). - * - * Зачем это РЅСѓР¶РЅРѕ: - * - Р’ каналах/ветках/действиях ты хочешь иметь "линейную" последовательность, - * РіРґРµ каждый следующий блок СЏРІРЅРѕ ссылается РЅР° предыдущий блок линии - * Рё подтверждает, что ссылка РЅРµ подменена. - * - * РљРѕРіРґР° срабатывает: - * - ТОЛЬКО если РїСЂРё вставке передано РҐРћРўРЇ БЫ ОДНО РёР· line-полей. - * - Если line-поля РЅРµ переданы — триггер вообще РЅРµ работает (это важно). - * - * Что проверяет: - * A) line-поля допускаются только для msg_type: - * 0 (TECH), 1 (TEXT), 3 (CONNECTION), 4 (USER_PARAM) - * B) Если пришло хоть РѕРґРЅРѕ line-поле — обязаны прийти Р’РЎР• 4 (никаких "частичных") - * C) prev-блок линии существует РІ той Р¶Рµ цепочке bch_name - * D) prev_hash совпадает СЃ block_hash найденного prev-блока - * E) line_code корректный: - * - либо первый шаг после root: prev_line_number == line_code - * - либо prev СѓР¶Рµ принадлежит этой линии: p.line_code == NEW.line_code - * F) this_line_number: - * - первый шаг после root: - * TEXT: this_line_number = 0 - * TECH/CONNECTION/USER_PARAM: this_line_number = 1 - * - обычный шаг: - * TEXT: допускаем same или +1 (чтобы "edit" РјРѕРі РЅРµ двигать шаг) - * TECH/CONNECTION/USER_PARAM: строго prev.this + 1 - * - * Какие ошибки кидает: - * - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE - * - LINE_ERR_PARTIAL_FIELDS - * - LINE_ERR_NO_PREV - * - LINE_ERR_PREV_HASH_MISMATCH - * - LINE_ERR_LINE_CODE_MISMATCH - * - LINE_ERR_FIRST_STEP_BAD_THIS - * - LINE_ERR_THIS_LINE_BAD_STEP - * - * [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3) - * Поддерживает таблицу connections_state как "текущее состояние" отношений: - * - FRIEND/CONTACT/FOLLOW -> добавить/обновить состояние - * - UNFRIEND/UNCONTACT/UNFOLLOW -> удалить соответствующее "позитивное" состояние - * - * [3] trg_blocks_message_stats_like_ai (AFTER INSERT ON blocks WHEN msg_type=2 AND sub_type=LIKE) - * Поддерживает likes_count РІ message_stats для цели (to_*). - * - * [4] trg_blocks_message_stats_reply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=REPLY) - * Поддерживает replies_count РІ message_stats. - * - * [5] trg_blocks_edit_apply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=EDIT) - * Логика edit: - * - помечает исходный блок edited_by_block_number = NEW.block_number - * - увеличивает edits_count РІ message_stats - */ -public final class DatabaseTriggersInstaller { - - private DatabaseTriggersInstaller() {} - - public static void createAllTriggers(Statement st) throws SQLException { - dropTriggersByPrefix(st, "trg_blocks_"); - - // РќР° РІСЃСЏРєРёР№ случай убираем старые "РєСЂРёРІРѕ названные" триггеры, - // если РѕРЅРё РєРѕРіРґР°-то попадали РІ БД. - st.executeUpdate("DROP TRIGGER IF EXISTS trg_block_lini_integriti_by;"); - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;"); - - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai;"); - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_like_ai;"); - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_reply_ai;"); - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_edit_apply_ai;"); - - createLineIntegrityTrigger(st); - createConnectionStateTrigger(st); - createMessageStatsLikeTrigger(st); - createMessageStatsReplyTrigger(st); - createEditApplyTrigger(st); - } - - private static void dropTriggersByPrefix(Statement st, String prefix) throws SQLException { - List triggerNames = new ArrayList<>(); - String sql = "SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '" + prefix + "%'"; - try (ResultSet rs = st.executeQuery(sql)) { - while (rs.next()) { - String name = rs.getString("name"); - if (name != null && !name.isBlank()) { - triggerNames.add(name); - } - } - } - - for (String name : triggerNames) { - String safeName = name.replace("\"", "\"\""); - st.executeUpdate("DROP TRIGGER IF EXISTS \"" + safeName + "\""); - } - } - - private static void createLineIntegrityTrigger(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi - BEFORE INSERT ON blocks - WHEN - NEW.line_code IS NOT NULL - OR NEW.prev_line_number IS NOT NULL - OR NEW.prev_line_hash IS NOT NULL - OR NEW.this_line_number IS NOT NULL - BEGIN - SELECT RAISE(ABORT, 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE') - WHERE NOT (NEW.msg_type IN (0, 1, 3, 4)); - - SELECT RAISE(ABORT, 'LINE_ERR_PARTIAL_FIELDS') - WHERE NEW.line_code IS NULL - OR NEW.prev_line_number IS NULL - OR NEW.prev_line_hash IS NULL - OR NEW.this_line_number IS NULL; - - SELECT RAISE(ABORT, 'LINE_ERR_NO_PREV') - WHERE NOT EXISTS( - SELECT 1 - FROM blocks p - WHERE p.bch_name = NEW.bch_name - AND p.block_number = NEW.prev_line_number - LIMIT 1 - ); - - SELECT RAISE(ABORT, 'LINE_ERR_PREV_HASH_MISMATCH') - WHERE NOT EXISTS( - SELECT 1 - FROM blocks p - WHERE p.bch_name = NEW.bch_name - AND p.block_number = NEW.prev_line_number - AND p.block_hash = NEW.prev_line_hash - LIMIT 1 - ); - - SELECT RAISE(ABORT, 'LINE_ERR_LINE_CODE_MISMATCH') - WHERE NEW.prev_line_number <> NEW.line_code - AND NOT EXISTS( - SELECT 1 - FROM blocks p - WHERE p.bch_name = NEW.bch_name - AND p.block_number = NEW.prev_line_number - AND p.line_code = NEW.line_code - LIMIT 1 - ); - - SELECT RAISE(ABORT, 'LINE_ERR_FIRST_STEP_BAD_THIS') - WHERE NEW.prev_line_number = NEW.line_code - AND NEW.this_line_number <> (CASE WHEN NEW.msg_type = 1 THEN 0 ELSE 1 END); - - SELECT RAISE(ABORT, 'LINE_ERR_THIS_LINE_BAD_STEP') - WHERE NEW.prev_line_number <> NEW.line_code - AND NOT EXISTS( - SELECT 1 - FROM blocks p - WHERE p.bch_name = NEW.bch_name - AND p.block_number = NEW.prev_line_number - AND p.this_line_number IS NOT NULL - AND ( - (NEW.msg_type = 1 AND - (NEW.this_line_number = p.this_line_number OR NEW.this_line_number = p.this_line_number + 1) - ) - OR - (NEW.msg_type IN (0,3,4) AND NEW.this_line_number = p.this_line_number + 1) - ) - LIMIT 1 - ); - END; - """); - } - - private static void createConnectionStateTrigger(Statement st) throws SQLException { - int FRIEND = (int) DatabaseInitializer.CONNECTION_FRIEND; - int CONTACT = (int) DatabaseInitializer.CONNECTION_CONTACT; - int FOLLOW = (int) DatabaseInitializer.CONNECTION_FOLLOW; - int SPOUSE = (int) DatabaseInitializer.CONNECTION_SPOUSE; - int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT; - int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD; - int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING; - int KNOWN = (int) DatabaseInitializer.CONNECTION_KNOWN_PERSON; - int SHINE_CONF = (int) DatabaseInitializer.CONNECTION_SHINE_CONFIRMED; - int SHINE_SEEN = (int) DatabaseInitializer.CONNECTION_SHINE_SEEN; - - int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND; - int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT; - int UNFOLLOW = (int) DatabaseInitializer.CONNECTION_UNFOLLOW; - int UNSPOUSE = (int) DatabaseInitializer.CONNECTION_UNSPOUSE; - int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT; - int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD; - int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING; - int UNKNOWN = (int) DatabaseInitializer.CONNECTION_UNKNOWN_PERSON; - int SHINE_UNCONF = (int) DatabaseInitializer.CONNECTION_SHINE_UNCONFIRMED; - int SHINE_UNSEEN = (int) DatabaseInitializer.CONNECTION_SHINE_UNSEEN; - - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai - AFTER INSERT ON blocks - WHEN NEW.msg_type = 3 - BEGIN - -- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING/KNOWN_PERSON/SHINE_*: - -- 1) если записи нет — создаём - INSERT OR IGNORE INTO connections_state ( - login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash - ) - SELECT - NEW.login, - NEW.msg_sub_type, - COALESCE( - NEW.to_login, - ( - SELECT su.login - FROM solana_users su - WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE - LIMIT 1 - ), - CASE - WHEN NEW.to_bch_name IS NOT NULL - AND length(NEW.to_bch_name) > 4 - AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' - THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) - ELSE NULL - END - ), - NEW.to_bch_name, - NEW.to_block_number, - NEW.to_block_hash - WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d) - AND COALESCE( - NEW.to_login, - ( - SELECT su.login - FROM solana_users su - WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE - LIMIT 1 - ), - CASE - WHEN NEW.to_bch_name IS NOT NULL - AND length(NEW.to_bch_name) > 4 - AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' - THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) - ELSE NULL - END - ) IS NOT NULL - AND NEW.to_bch_name IS NOT NULL; - - -- 2) если запись есть — обновляем актуальные to_* - UPDATE connections_state - SET - to_bch_name = NEW.to_bch_name, - to_block_number = NEW.to_block_number, - to_block_hash = NEW.to_block_hash - WHERE login = NEW.login - AND rel_type = NEW.msg_sub_type - AND to_login = COALESCE( - NEW.to_login, - ( - SELECT su.login - FROM solana_users su - WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE - LIMIT 1 - ), - CASE - WHEN NEW.to_bch_name IS NOT NULL - AND length(NEW.to_bch_name) > 4 - AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' - THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) - ELSE NULL - END - ) - AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d) - AND COALESCE( - NEW.to_login, - ( - SELECT su.login - FROM solana_users su - WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE - LIMIT 1 - ), - CASE - WHEN NEW.to_bch_name IS NOT NULL - AND length(NEW.to_bch_name) > 4 - AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' - THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) - ELSE NULL - END - ) IS NOT NULL - AND NEW.to_bch_name IS NOT NULL; - - -- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING/UNKNOWN_PERSON/SHINE_UN*: - -- удаляем соответствующее "позитивное" состояние - DELETE FROM connections_state - WHERE login = NEW.login - AND to_login = COALESCE( - NEW.to_login, - ( - SELECT su.login - FROM solana_users su - WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE - LIMIT 1 - ), - CASE - WHEN NEW.to_bch_name IS NOT NULL - AND length(NEW.to_bch_name) > 4 - AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' - THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) - ELSE NULL - END - ) - AND rel_type = CASE NEW.msg_sub_type - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - ELSE rel_type - END - AND COALESCE( - NEW.to_login, - ( - SELECT su.login - FROM solana_users su - WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE - LIMIT 1 - ), - CASE - WHEN NEW.to_bch_name IS NOT NULL - AND length(NEW.to_bch_name) > 4 - AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' - THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) - ELSE NULL - END - ) IS NOT NULL - AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d); - END; - """.formatted( - FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN, - FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN, - - UNFRIEND, FRIEND, - UNCONTACT, CONTACT, - UNFOLLOW, FOLLOW, - UNSPOUSE, SPOUSE, - UNPARENT, PARENT, - UNCHILD, CHILD, - UNSIBLING, SIBLING, - UNKNOWN, KNOWN, - SHINE_UNCONF, SHINE_CONF, - SHINE_UNSEEN, SHINE_SEEN, - - UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING, UNKNOWN, SHINE_UNCONF, SHINE_UNSEEN - )); - } - - private static void createMessageStatsLikeTrigger(Statement st) throws SQLException { - int LIKE = (int) DatabaseInitializer.REACTION_LIKE; - int UNLIKE = (int) DatabaseInitializer.REACTION_UNLIKE; - - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai - AFTER INSERT ON blocks - WHEN NEW.msg_type = 2 AND NEW.msg_sub_type IN (%d, %d) - BEGIN - -- ensure target stats row exists - INSERT OR IGNORE INTO message_stats ( - to_login, to_bch_name, to_block_number, to_block_hash, - likes_count, replies_count, edits_count - ) - SELECT - NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, - 0, 0, 0 - WHERE NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - - -- apply delta by state transition (none/unlike->like = +1, like->unlike = -1) - UPDATE message_stats - SET likes_count = MAX( - 0, - likes_count + ( - CASE - WHEN NEW.msg_sub_type = %d - AND COALESCE(( - SELECT b.msg_sub_type - FROM blocks b - WHERE b.login = NEW.login - AND b.bch_name = NEW.bch_name - AND b.msg_type = 2 - AND b.to_login = NEW.to_login - AND b.to_bch_name = NEW.to_bch_name - AND b.to_block_number = NEW.to_block_number - AND b.to_block_hash = NEW.to_block_hash - AND b.block_number < NEW.block_number - ORDER BY b.block_number DESC - LIMIT 1 - ), -1) <> %d - THEN 1 - WHEN NEW.msg_sub_type = %d - AND COALESCE(( - SELECT b.msg_sub_type - FROM blocks b - WHERE b.login = NEW.login - AND b.bch_name = NEW.bch_name - AND b.msg_type = 2 - AND b.to_login = NEW.to_login - AND b.to_bch_name = NEW.to_bch_name - AND b.to_block_number = NEW.to_block_number - AND b.to_block_hash = NEW.to_block_hash - AND b.block_number < NEW.block_number - ORDER BY b.block_number DESC - LIMIT 1 - ), -1) = %d - THEN -1 - ELSE 0 - END - ) - ) - WHERE to_login = NEW.to_login - AND to_bch_name = NEW.to_bch_name - AND to_block_number = NEW.to_block_number - AND to_block_hash = NEW.to_block_hash - AND NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - - -- persist latest actor->target reaction state - INSERT OR IGNORE INTO reactions_state ( - from_login, from_bch_name, reaction_type, - to_login, to_bch_name, to_block_number, to_block_hash, - last_sub_type - ) - SELECT - NEW.login, NEW.bch_name, %d, - NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, - NEW.msg_sub_type - WHERE NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - - UPDATE reactions_state - SET last_sub_type = NEW.msg_sub_type - WHERE from_login = NEW.login - AND from_bch_name = NEW.bch_name - AND reaction_type = %d - AND to_login = NEW.to_login - AND to_bch_name = NEW.to_bch_name - AND to_block_number = NEW.to_block_number - AND to_block_hash = NEW.to_block_hash - AND NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - END; - """.formatted( - LIKE, UNLIKE, - LIKE, LIKE, - UNLIKE, LIKE, - LIKE, - LIKE - )); - } - - private static void createMessageStatsReplyTrigger(Statement st) throws SQLException { - int REPLY = (int) DatabaseInitializer.TEXT_REPLY; - - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_reply_ai - AFTER INSERT ON blocks - WHEN NEW.msg_type = 1 AND NEW.msg_sub_type = %d - BEGIN - INSERT OR IGNORE INTO message_stats ( - to_login, to_bch_name, to_block_number, to_block_hash, - likes_count, replies_count, edits_count - ) - SELECT - NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, - 0, 0, 0 - WHERE NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - - UPDATE message_stats - SET replies_count = replies_count + 1 - WHERE to_login = NEW.to_login - AND to_bch_name = NEW.to_bch_name - AND to_block_number = NEW.to_block_number - AND to_block_hash = NEW.to_block_hash - AND NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - END; - """.formatted(REPLY)); - } - - private static void createEditApplyTrigger(Statement st) throws SQLException { - int EDIT_POST = (int) DatabaseInitializer.TEXT_EDIT_POST; - int EDIT_REPLY = (int) DatabaseInitializer.TEXT_EDIT_REPLY; - - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_edit_apply_ai - AFTER INSERT ON blocks - WHEN NEW.msg_type = 1 AND NEW.msg_sub_type IN (%d, %d) - BEGIN - -- 1) помечаем исходный блок, что его "перекрыл" этот edit - UPDATE blocks - SET edited_by_block_number = NEW.block_number - WHERE login = NEW.login - AND bch_name = NEW.bch_name - AND block_number = NEW.to_block_number - AND NEW.to_block_number IS NOT NULL; - - -- 2) создаём stats-строку если её РЅРµ было - INSERT OR IGNORE INTO message_stats ( - to_login, to_bch_name, to_block_number, to_block_hash, - likes_count, replies_count, edits_count - ) - SELECT - NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, - 0, 0, 0 - WHERE NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - - -- 3) +1 edit - UPDATE message_stats - SET edits_count = edits_count + 1 - WHERE to_login = NEW.to_login - AND to_bch_name = NEW.to_bch_name - AND to_block_number = NEW.to_block_number - AND to_block_hash = NEW.to_block_hash - AND NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - END; - """.formatted(EDIT_POST, EDIT_REPLY)); - } -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DbController.java new file mode 100644 index 00000000..72dec60c --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DbController.java @@ -0,0 +1,43 @@ +package shine.db; + +import shine.db.connection.DbProvider; + +import java.sql.Connection; +import java.sql.SQLException; + +/** + * Нейтральная точка входа в runtime БД сервера. + * + * Runtime-сервер теперь поддерживает только PostgreSQL. + */ +public final class DbController implements DbProvider { + + private static volatile DbController instance; + + private final PostgresDbController delegate; + + private DbController() { + this.delegate = PostgresDbController.getInstance(); + } + + public static DbController getInstance() { + if (instance == null) { + synchronized (DbController.class) { + if (instance == null) { + instance = new DbController(); + } + } + } + return instance; + } + + @Override + public Connection getConnection() throws SQLException { + return delegate.getConnection(); + } + + @Override + public void close() { + delegate.close(); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/KeyEncodingUtil.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/KeyEncodingUtil.java new file mode 100644 index 00000000..867bd394 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/KeyEncodingUtil.java @@ -0,0 +1,115 @@ +package shine.db; + +import java.util.Base64; + +/** + * Нормализация ключей из разных storage-слоёв к каноническому Base64(32). + */ +public final class KeyEncodingUtil { + + private static final String BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + + private static final int[] BASE58_INDEXES = new int[128]; + + static { + for (int i = 0; i < BASE58_INDEXES.length; i++) { + BASE58_INDEXES[i] = -1; + } + for (int i = 0; i < BASE58_ALPHABET.length(); i++) { + BASE58_INDEXES[BASE58_ALPHABET.charAt(i)] = i; + } + } + + private KeyEncodingUtil() {} + + public static String normalizeKeyToBase64_32(String rawKey) { + if (rawKey == null) { + return null; + } + + String value = rawKey.trim(); + if (value.isEmpty()) { + return value; + } + + byte[] asBase64 = tryDecodeBase64_32(value); + if (asBase64 != null) { + return Base64.getEncoder().encodeToString(asBase64); + } + + byte[] asBase58 = tryDecodeBase58_32(value); + if (asBase58 != null) { + return Base64.getEncoder().encodeToString(asBase58); + } + + return value; + } + + private static byte[] tryDecodeBase64_32(String value) { + try { + byte[] decoded = Base64.getDecoder().decode(value); + return decoded.length == 32 ? decoded : null; + } catch (IllegalArgumentException ignore) { + return null; + } + } + + private static byte[] tryDecodeBase58_32(String value) { + byte[] decoded = decodeBase58(value); + return decoded.length == 32 ? decoded : null; + } + + private static byte[] decodeBase58(String input) { + if (input.isEmpty()) { + return new byte[0]; + } + + byte[] input58 = new byte[input.length()]; + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (c >= BASE58_INDEXES.length || BASE58_INDEXES[c] < 0) { + return new byte[0]; + } + input58[i] = (byte) BASE58_INDEXES[c]; + } + + int zeros = 0; + while (zeros < input58.length && input58[zeros] == 0) { + zeros++; + } + + byte[] decoded = new byte[input.length()]; + int outputStart = decoded.length; + int inputStart = zeros; + while (inputStart < input58.length) { + int mod = divmod256(input58, inputStart); + if (input58[inputStart] == 0) { + inputStart++; + } + decoded[--outputStart] = (byte) mod; + } + + while (outputStart < decoded.length && decoded[outputStart] == 0) { + outputStart++; + } + + byte[] result = new byte[decoded.length - outputStart + zeros]; + for (int i = 0; i < zeros; i++) { + result[i] = 0; + } + System.arraycopy(decoded, outputStart, result, zeros, decoded.length - outputStart); + return result; + } + + private static int divmod256(byte[] number58, int startAt) { + int remainder = 0; + for (int i = startAt; i < number58.length; i++) { + int digit58 = number58[i] & 0xFF; + int temp = remainder * 58 + digit58; + number58[i] = (byte) (temp / 256); + remainder = temp % 256; + } + return remainder; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/PostgresDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/PostgresDbController.java new file mode 100644 index 00000000..a3b77fcc --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/PostgresDbController.java @@ -0,0 +1,63 @@ +package shine.db; + +import shine.db.connection.DriverManagerDbProvider; +import utils.config.AppConfig; + +import java.sql.Connection; +import java.sql.SQLException; + +public final class PostgresDbController { + + private static volatile PostgresDbController instance; + + private final DriverManagerDbProvider delegate; + + private PostgresDbController() { + AppConfig config = AppConfig.getInstance(); + String jdbcUrl = trimToNull(config.getParam("db.url")); + String dbUser = trimToNull(config.getParam("db.user")); + String dbPassword = trimToNull(config.getParam("db.password")); + + if (jdbcUrl == null) { + throw new IllegalStateException("Config param 'db.url' is required and must point to PostgreSQL"); + } + if (!jdbcUrl.startsWith("jdbc:postgresql:")) { + throw new IllegalStateException("Only PostgreSQL runtime is supported. Unsupported db.url=" + jdbcUrl); + } + + try { + DatabaseInitializer.ensurePostgresSchemaInitialized(jdbcUrl, dbUser, dbPassword); + } catch (SQLException e) { + throw new RuntimeException("PostgreSQL schema auto-init failed", e); + } + + this.delegate = new DriverManagerDbProvider(jdbcUrl, dbUser, dbPassword, connection -> { + connection.setAutoCommit(true); + }); + } + + public static PostgresDbController getInstance() { + if (instance == null) { + synchronized (PostgresDbController.class) { + if (instance == null) { + instance = new PostgresDbController(); + } + } + } + return instance; + } + + public Connection getConnection() throws SQLException { + return delegate.getConnection(); + } + + public void close() { + // no-op + } + + private static String trimToNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java deleted file mode 100644 index c4cb4ab7..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ /dev/null @@ -1,846 +0,0 @@ -package shine.db; - -import utils.config.AppConfig; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; - -public final class SqliteDbController { - - private static volatile SqliteDbController instance; - private static final int LATEST_SCHEMA_VERSION = 12; - - private final String jdbcUrl; - - private SqliteDbController() { - try { - Class.forName("org.sqlite.JDBC"); - } catch (ClassNotFoundException e) { - throw new RuntimeException("SQLite JDBC driver not found", e); - } - - String dbPath = AppConfig.getInstance().getParam("db.path"); - if (dbPath == null || dbPath.isBlank()) { - throw new RuntimeException("Config param 'db.path' is not set in application.properties"); - } - - Path dbFile = Paths.get(dbPath); - - if (!Files.exists(dbFile)) { - System.out.println("[DB] Файл БД не найден: " + dbFile.toAbsolutePath()); - System.out.println("[DB] Создаём новую БД с помощью DatabaseInitializer..."); - DatabaseInitializer.createNewDB(new String[0]); - } - - this.jdbcUrl = "jdbc:sqlite:" + dbPath; - ensureSchemaMigrations(); - } - - public static SqliteDbController getInstance() { - if (instance == null) { - synchronized (SqliteDbController.class) { - if (instance == null) { - instance = new SqliteDbController(); - } - } - } - return instance; - } - - public Connection getConnection() throws SQLException { - Connection conn = DriverManager.getConnection(jdbcUrl); - conn.setAutoCommit(true); - - try (Statement st = conn.createStatement()) { - st.execute("PRAGMA foreign_keys = ON"); - st.execute("PRAGMA journal_mode = WAL"); - st.execute("PRAGMA synchronous = NORMAL"); - st.execute("PRAGMA busy_timeout = 5000"); - } - - return conn; - } - - public void close() { - // no-op - } - - private void ensureSchemaMigrations() { - int currentVersion = getCurrentSchemaVersion(); - - while (currentVersion < LATEST_SCHEMA_VERSION) { - int nextVersion = currentVersion + 1; - applyMigration(nextVersion); - currentVersion = nextVersion; - } - } - - private void applyMigration(int targetVersion) { - switch (targetVersion) { - case 1 -> migrateToV1(); - case 2 -> migrateToV2(); - case 3 -> migrateToV3(); - case 4 -> migrateToV4(); - case 5 -> migrateToV5(); - case 6 -> migrateToV6(); - case 7 -> migrateToV7(); - case 8 -> migrateToV8(); - case 9 -> migrateToV9(); - case 10 -> migrateToV10(); - case 11 -> migrateToV11(); - case 12 -> migrateToV12(); - default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); - } - } - - private void migrateToV1() { - try { - DatabaseInitializer.ensureSchemaV1Structure(jdbcUrl); - } catch (SQLException e) { - throw new RuntimeException("DB migration to v1 failed (base schema)", e); - } - - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - st.execute("PRAGMA foreign_keys = OFF"); - - if (tableExists(c, "connections_state") && needsConnectionsStateUpgrade(c)) { - rebuildConnectionsStateTable(st); - } - - ensureChannelNamesDescriptionColumn(c, st); - ensureChannelNamesTypeColumns(c, st); - ensureSignedMessageReceiptUniq(c, st); - DatabaseTriggersInstaller.createAllTriggers(st); - setSchemaVersion(c, 1); - - st.execute("PRAGMA foreign_keys = ON"); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v1 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v1 failed", e); - } - } - - private void migrateToV2() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - st.execute("PRAGMA foreign_keys = OFF"); - st.executeUpdate("DROP TABLE IF EXISTS message_views_state"); - st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_target"); - st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_viewer_channel"); - setSchemaVersion(c, 2); - st.execute("PRAGMA foreign_keys = ON"); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v2 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v2 failed", e); - } - } - - private void migrateToV3() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureChat200StateTables(st); - setSchemaVersion(c, 3); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v3 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v3 failed", e); - } - } - - private void migrateToV4() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureActiveSessionsSessionTypeColumn(c, st); - ensureActiveSessionsClientPlatformColumn(c, st); - setSchemaVersion(c, 4); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v4 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v4 failed", e); - } - } - - private void migrateToV5() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureEspPairingTables(st); - setSchemaVersion(c, 5); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v5 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v5 failed", e); - } - } - - private void migrateToV6() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureSignedMessagesRevisionColumn(c, st); - setSchemaVersion(c, 6); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v6 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v6 failed", e); - } - } - - private void migrateToV7() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - dropDmFileTables(st); - setSchemaVersion(c, 7); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v7 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v7 failed", e); - } - } - - private void migrateToV8() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureTestFreeAvatarUploadsTable(st); - setSchemaVersion(c, 8); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v8 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v8 failed", e); - } - } - - private void migrateToV9() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureSyncServersTable(st); - setSchemaVersion(c, 9); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v9 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v9 failed", e); - } - } - - private void migrateToV10() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureSignedMessagesReencryptedColumn(c, st); - setSchemaVersion(c, 10); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v10 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v10 failed", e); - } - } - - private void migrateToV11() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - clearLegacySignedMessagesForDmV11(c, st); - setSchemaVersion(c, 11); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v11 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v11 failed", e); - } - } - - private void migrateToV12() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureSignedMessagesReadAtColumn(c, st); - backfillSignedMessagesReadAt(st); - setSchemaVersion(c, 12); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v12 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v12 failed", e); - } - } - - private static void ensureChat200StateTables(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS chat200_state ( - owner_login TEXT NOT NULL, - owner_bch_name TEXT NOT NULL, - channel_root_block_number INTEGER NOT NULL, - channel_root_block_hash BLOB NOT NULL, - channel_name TEXT NOT NULL, - channel_type_version INTEGER NOT NULL, - chat_title TEXT NOT NULL DEFAULT '', - updated_at_ms INTEGER NOT NULL, - PRIMARY KEY (owner_bch_name, channel_root_block_number) - ); - """); - - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS chat200_members_state ( - owner_bch_name TEXT NOT NULL, - channel_root_block_number INTEGER NOT NULL, - member_login TEXT NOT NULL, - member_channel_name TEXT NOT NULL, - is_active INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL, - updated_by_block_number INTEGER NOT NULL, - PRIMARY KEY ( - owner_bch_name, - channel_root_block_number, - member_login, - member_channel_name - ) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_chat200_state_owner - ON chat200_state (owner_login, owner_bch_name); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_chat200_members_owner - ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active); - """); - } - - private int getCurrentSchemaVersion() { - try (Connection c = DriverManager.getConnection(jdbcUrl)) { - if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) { - return 0; - } - - try (var ps = c.prepareStatement(""" - SELECT schema_version - FROM db_schema_version - WHERE id = 1 - LIMIT 1 - """); - ResultSet rs = ps.executeQuery()) { - if (rs.next()) { - return rs.getInt(1); - } - } - return 0; - } catch (SQLException e) { - throw new RuntimeException("Cannot read DB schema version", e); - } - } - - private static void ensureActiveSessionsSessionTypeColumn(Connection c, Statement st) throws SQLException { - if (columnExists(c, "active_sessions", "session_type")) return; - st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN session_type INTEGER NOT NULL DEFAULT 1"); - } - - private static void ensureActiveSessionsClientPlatformColumn(Connection c, Statement st) throws SQLException { - if (columnExists(c, "active_sessions", "client_platform")) return; - st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN client_platform TEXT NOT NULL DEFAULT ''"); - } - - private static void ensureEspPairingTables(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS esp_pairing_settings ( - login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - enabled INTEGER NOT NULL DEFAULT 0, - password_hash TEXT NOT NULL DEFAULT '', - ttl_seconds INTEGER NOT NULL DEFAULT 300, - failed_attempts INTEGER NOT NULL DEFAULT 0, - first_failed_at_ms INTEGER NOT NULL DEFAULT 0, - blocked_until_ms INTEGER NOT NULL DEFAULT 0, - updated_at_ms INTEGER NOT NULL, - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS esp_pairing_requests ( - pairing_id TEXT NOT NULL PRIMARY KEY, - login TEXT NOT NULL, - requester_session_key TEXT NOT NULL, - requester_session_type INTEGER NOT NULL DEFAULT 1, - requester_client_platform TEXT NOT NULL DEFAULT '', - payload_type INTEGER NOT NULL, - status TEXT NOT NULL, - short_code TEXT NOT NULL, - fingerprint_b58 TEXT NOT NULL, - encrypted_payload TEXT, - reject_reason TEXT, - approved_by_session_id TEXT, - created_at_ms INTEGER NOT NULL, - expires_at_ms INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL, - delivered_to_homeserver INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status - ON esp_pairing_requests (login, status, expires_at_ms); - """); - } - - private static void ensureSignedMessagesRevisionColumn(Connection c, Statement st) throws SQLException { - if (!tableExists(c, "signed_messages_v2")) return; - if (!columnExists(c, "signed_messages_v2", "revision_time_ms")) { - st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN revision_time_ms INTEGER NOT NULL DEFAULT 0"); - } - } - - private static void ensureSignedMessagesReencryptedColumn(Connection c, Statement st) throws SQLException { - if (!tableExists(c, "signed_messages_v2")) return; - if (!columnExists(c, "signed_messages_v2", "reencrypted_at_ms")) { - st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN reencrypted_at_ms INTEGER NOT NULL DEFAULT 0"); - } - } - - private static void ensureSignedMessagesReadAtColumn(Connection c, Statement st) throws SQLException { - if (!tableExists(c, "signed_messages_v2")) return; - if (!columnExists(c, "signed_messages_v2", "read_at_ms")) { - st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN read_at_ms INTEGER"); - } - } - - private static void backfillSignedMessagesReadAt(Statement st) throws SQLException { - st.executeUpdate(""" - UPDATE signed_messages_v2 AS content - SET read_at_ms = ( - SELECT MIN(receipt.time_ms) - FROM signed_messages_v2 AS receipt - WHERE receipt.message_type IN (3, 4) - AND receipt.receipt_ref_base_key = content.base_key - ) - WHERE content.message_type IN (1, 2) - AND (content.read_at_ms IS NULL OR content.read_at_ms <= 0) - AND EXISTS ( - SELECT 1 - FROM signed_messages_v2 AS receipt - WHERE receipt.message_type IN (3, 4) - AND receipt.receipt_ref_base_key = content.base_key - ); - """); - } - - /** - * Временная одноразовая миграция на переходе к SHiNE_DM v1: - * старые строки signed_messages_v2 больше не гарантированно совместимы - * с новым бинарным форматом и правилами tombstone/reencrypt. - * Позже эту миграцию нужно убрать, чтобы случайно не очищать рабочие DM. - */ - private static void clearLegacySignedMessagesForDmV11(Connection c, Statement st) throws SQLException { - if (tableExists(c, "signed_message_session_delivery")) { - st.executeUpdate("DELETE FROM signed_message_session_delivery"); - } - if (tableExists(c, "signed_messages_v2")) { - st.executeUpdate("DELETE FROM signed_messages_v2"); - } - } - - private static void dropDmFileTables(Statement st) throws SQLException { - st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_login"); - st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message"); - st.executeUpdate("DROP TABLE IF EXISTS dm_message_file_links"); - st.executeUpdate("DROP TABLE IF EXISTS dm_files"); - } - - private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException { - try (Statement probe = c.createStatement(); - ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) { - while (rs.next()) { - if (columnName.equalsIgnoreCase(rs.getString("name"))) { - return true; - } - } - return false; - } - } - - private static void setSchemaVersion(Connection c, int version) throws SQLException { - try (var ps = c.prepareStatement(""" - INSERT INTO db_schema_version (id, schema_version, updated_at_ms) - VALUES (1, ?, CAST(strftime('%s','now') AS INTEGER) * 1000) - ON CONFLICT(id) DO UPDATE SET - schema_version = excluded.schema_version, - updated_at_ms = excluded.updated_at_ms - """)) { - ps.setInt(1, version); - ps.executeUpdate(); - } - } - - private static void ensureReactionsStateTable(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS reactions_state ( - from_login TEXT NOT NULL, - from_bch_name TEXT NOT NULL, - reaction_type INTEGER NOT NULL, - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER NOT NULL, - to_block_hash BLOB NOT NULL, - last_sub_type INTEGER NOT NULL, - UNIQUE ( - from_login, - from_bch_name, - reaction_type, - to_login, - to_bch_name, - to_block_number, - to_block_hash - ) - ); - """); - } - - private static void ensureTestFreeAvatarUploadsTable(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS test_free_avatar_uploads ( - login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - used_count INTEGER NOT NULL DEFAULT 0, - updated_at_ms INTEGER NOT NULL, - last_tx_id TEXT NOT NULL DEFAULT '', - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated - ON test_free_avatar_uploads (updated_at_ms); - """); - } - - private static void ensureSyncServersTable(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS sync_servers ( - login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - server_address TEXT NOT NULL DEFAULT '', - updated_at_ms INTEGER NOT NULL - ); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_sync_servers_updated - ON sync_servers (updated_at_ms); - """); - } - - - private static void createConnectionsStateTable(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS connections_state ( - login TEXT NOT NULL, - rel_type INTEGER NOT NULL, - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER NOT NULL, - to_block_hash BLOB NOT NULL, - FOREIGN KEY (login) REFERENCES solana_users(login), - UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) - ); - """); - } - - private static void ensureConnectionsIndexes(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_login - ON connections_state (login); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_to_login - ON connections_state (to_login); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_pair - ON connections_state (login, to_login); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_target - ON connections_state (login, rel_type, to_bch_name, to_block_number); - """); - } - - private static void ensureReactionsIndexes(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_reactions_state_target - ON reactions_state (to_bch_name, to_block_number, to_block_hash); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_reactions_state_actor - ON reactions_state (from_login, from_bch_name, reaction_type); - """); - } - - private static void ensureChannelNamesStateTable(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS channel_names_state ( - slug TEXT NOT NULL, - display_name TEXT NOT NULL, - channel_description TEXT NOT NULL DEFAULT '', - owner_login TEXT NOT NULL, - owner_bch_name TEXT NOT NULL, - channel_type_code INTEGER NOT NULL DEFAULT 1, - channel_type_version INTEGER NOT NULL DEFAULT 1, - channel_root_block_number INTEGER NOT NULL, - channel_root_block_hash BLOB NOT NULL, - created_at_ms INTEGER NOT NULL - ); - """); - } - - private static void ensureChannelNamesDescriptionColumn(Connection c, Statement st) throws SQLException { - boolean hasDescription = false; - try (Statement probe = c.createStatement(); - ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) { - while (rs.next()) { - String name = rs.getString("name"); - if ("channel_description".equalsIgnoreCase(name)) { - hasDescription = true; - break; - } - } - } - - if (!hasDescription) { - st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_description TEXT NOT NULL DEFAULT ''"); - } - } - - private static void ensureChannelNamesTypeColumns(Connection c, Statement st) throws SQLException { - boolean hasTypeCode = false; - boolean hasTypeVersion = false; - - try (Statement probe = c.createStatement(); - ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) { - while (rs.next()) { - String name = rs.getString("name"); - if ("channel_type_code".equalsIgnoreCase(name)) hasTypeCode = true; - if ("channel_type_version".equalsIgnoreCase(name)) hasTypeVersion = true; - } - } - - if (!hasTypeCode) { - st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_code INTEGER NOT NULL DEFAULT 1"); - } - if (!hasTypeVersion) { - st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_version INTEGER NOT NULL DEFAULT 1"); - } - } - - private static void ensureChannelNamesIndexes(Statement st) throws SQLException { - st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_slug"); - st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_owner_slug"); - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug - ON channel_names_state (owner_bch_name, channel_type_code, slug); - """); - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target - ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner - ON channel_names_state (owner_login, owner_bch_name); - """); - } - - private static void ensureSignedMessageReceiptUniq(Connection c, Statement st) throws SQLException { - if (!tableExists(c, "signed_messages_v2")) return; - - if (tableExists(c, "signed_message_session_delivery")) { - st.executeUpdate(""" - DELETE FROM signed_message_session_delivery - WHERE message_key IN ( - SELECT message_key - FROM signed_messages_v2 - WHERE message_type IN (3, 4) - AND receipt_ref_base_key IS NOT NULL - AND rowid NOT IN ( - SELECT MIN(rowid) - FROM signed_messages_v2 - WHERE message_type IN (3, 4) - AND receipt_ref_base_key IS NOT NULL - GROUP BY target_login, message_type, receipt_ref_base_key - ) - ); - """); - } - - st.executeUpdate(""" - DELETE FROM signed_messages_v2 - WHERE message_type IN (3, 4) - AND receipt_ref_base_key IS NOT NULL - AND rowid NOT IN ( - SELECT MIN(rowid) - FROM signed_messages_v2 - WHERE message_type IN (3, 4) - AND receipt_ref_base_key IS NOT NULL - GROUP BY target_login, message_type, receipt_ref_base_key - ); - """); - - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming - ON signed_messages_v2 (target_login, receipt_ref_base_key) - WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL; - """); - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing - ON signed_messages_v2 (target_login, receipt_ref_base_key) - WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL; - """); - } - - private static void rebuildConnectionsStateTable(Statement st) throws SQLException { - st.executeUpdate("DROP TABLE IF EXISTS connections_state_v2"); - st.executeUpdate(""" - CREATE TABLE connections_state_v2 ( - login TEXT NOT NULL, - rel_type INTEGER NOT NULL, - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER NOT NULL, - to_block_hash BLOB NOT NULL, - FOREIGN KEY (login) REFERENCES solana_users(login), - UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) - ); - """); - - st.executeUpdate(""" - INSERT OR IGNORE INTO connections_state_v2 - (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) - SELECT - login, - rel_type, - to_login, - to_bch_name, - COALESCE(to_block_number, 0), - COALESCE(to_block_hash, zeroblob(32)) - FROM connections_state - WHERE login IS NOT NULL - AND to_login IS NOT NULL - AND to_bch_name IS NOT NULL; - """); - - st.executeUpdate("DROP TABLE connections_state"); - st.executeUpdate("ALTER TABLE connections_state_v2 RENAME TO connections_state"); - } - - private static boolean tableExists(Connection c, String tableName) throws SQLException { - String sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1"; - try (var ps = c.prepareStatement(sql)) { - ps.setString(1, tableName); - try (ResultSet rs = ps.executeQuery()) { - return rs.next(); - } - } - } - - private static boolean needsConnectionsStateUpgrade(Connection c) throws SQLException { - boolean toBlockNumberNotNull = false; - boolean toBlockHashNotNull = false; - - try (Statement st = c.createStatement(); - ResultSet rs = st.executeQuery("PRAGMA table_info(connections_state)")) { - while (rs.next()) { - String name = rs.getString("name"); - int notNull = rs.getInt("notnull"); - if ("to_block_number".equalsIgnoreCase(name)) { - toBlockNumberNotNull = notNull == 1; - } - if ("to_block_hash".equalsIgnoreCase(name)) { - toBlockHashNotNull = notNull == 1; - } - } - } - - return !toBlockNumberNotNull || !toBlockHashNotNull; - } -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/channels/ChannelNameRules.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/channels/ChannelNameRules.java index ee5aa2e9..84bc7807 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/channels/ChannelNameRules.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/channels/ChannelNameRules.java @@ -8,6 +8,8 @@ public final class ChannelNameRules { private static final int MAX_DISPLAY_NAME_LENGTH = 32; private static final Pattern DISPLAY_ALLOWED_PATTERN = Pattern.compile("^[A-Za-z0-9_-]+$"); + private static final Pattern PUBLIC_CHANNEL_ALLOWED_PATTERN = + Pattern.compile("^[A-Za-z0-9_-]+$"); private ChannelNameRules() {} @@ -34,6 +36,14 @@ public final class ChannelNameRules { return normalized; } + public static String requireValidPublicDisplayNameForCreate(String rawName) { + String normalized = requireValidDisplayNameForCreate(rawName); + if (!PUBLIC_CHANNEL_ALLOWED_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("channelName contains unsupported characters"); + } + return normalized; + } + public static String toCanonicalSlug(String rawName) { String normalized = normalizeDisplayName(rawName); if (normalized.isEmpty()) { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/ConnectionFactory.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/ConnectionFactory.java new file mode 100644 index 00000000..17a05cca --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/ConnectionFactory.java @@ -0,0 +1,9 @@ +package shine.db.connection; + +import java.sql.Connection; +import java.sql.SQLException; + +@FunctionalInterface +public interface ConnectionFactory { + Connection getConnection() throws SQLException; +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/DbProvider.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/DbProvider.java new file mode 100644 index 00000000..c8a0f3e1 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/DbProvider.java @@ -0,0 +1,21 @@ +package shine.db.connection; + +import java.sql.Connection; +import java.sql.SQLException; + +/** + * Нейтральная точка доступа к JDBC-соединениям без привязки к конкретной БД. + * + * На первом этапе переносов этот интерфейс нужен как новая опора для DAO, + * чтобы постепенно убрать прямую зависимость от DbController. + */ +public interface DbProvider extends ConnectionFactory, AutoCloseable { + + @Override + Connection getConnection() throws SQLException; + + @Override + default void close() throws Exception { + // По умолчанию ресурсов на уровне provider нет. + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/DriverManagerDbProvider.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/DriverManagerDbProvider.java new file mode 100644 index 00000000..ec90a154 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/DriverManagerDbProvider.java @@ -0,0 +1,58 @@ +package shine.db.connection; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +/** + * Базовый JDBC provider поверх DriverManager. + * + * Не знает ничего про конкретный доменный режим БД: + * конкретные параметры и post-connect инициализация задаются снаружи. + */ +public final class DriverManagerDbProvider implements DbProvider { + + private final String jdbcUrl; + private final String user; + private final String password; + private final ConnectionInitializer initializer; + + public DriverManagerDbProvider(String jdbcUrl, + String user, + String password, + ConnectionInitializer initializer) { + if (jdbcUrl == null || jdbcUrl.isBlank()) { + throw new IllegalArgumentException("jdbcUrl is blank"); + } + this.jdbcUrl = jdbcUrl; + this.user = blankToNull(user); + this.password = blankToNull(password); + this.initializer = initializer; + } + + @Override + public Connection getConnection() throws SQLException { + Connection connection; + if (user == null) { + connection = DriverManager.getConnection(jdbcUrl); + } else { + connection = DriverManager.getConnection(jdbcUrl, user, password == null ? "" : password); + } + + if (initializer != null) { + initializer.initialize(connection); + } + return connection; + } + + private static String blankToNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + @FunctionalInterface + public interface ConnectionInitializer { + void initialize(Connection connection) throws SQLException; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java index 885dc8e5..cb2e0e88 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.ActiveSessionEntry; import java.sql.*; @@ -17,7 +17,7 @@ import java.util.List; public final class ActiveSessionsDAO { private static volatile ActiveSessionsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private ActiveSessionsDAO() { } @@ -137,7 +137,7 @@ public final class ActiveSessionsDAO { client_platform, user_language FROM active_sessions - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) """; List result = new ArrayList<>(); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java index 9fe04aee..e11a5fff 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java @@ -1,7 +1,7 @@ package shine.db.dao; import shine.db.DatabaseInitializer; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.Connection; import java.sql.PreparedStatement; @@ -24,12 +24,12 @@ import java.sql.SQLException; * - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам; * - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии; * - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию: - * SQLite не может атомарно закоммитить и SQL, и файловую систему сразу; + * SQL-транзакция и файловая система не коммитятся атомарно вместе; * поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом * отдельным recovery/resync-слоем после успешного commit. * * Важный смысл текущей реализации: - * - мы НЕ трогаем identity-слой (`solana_users`) и НЕ трогаем DM-таблицы; + * - мы НЕ трогаем current users слой (`solana_user_pda_current`) и НЕ трогаем DM-таблицы; * - мы очищаем только блокчейн пользователя и derived-state, который строится из неё; * - висячие cross-chain ссылки в чужих blocks допускаются как нормальное поведение системы. */ @@ -39,7 +39,7 @@ public final class BlockchainResyncCleanupDAO { private static volatile BlockchainResyncCleanupDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private BlockchainResyncCleanupDAO() {} @@ -307,7 +307,7 @@ public final class BlockchainResyncCleanupDAO { private int deleteConnectionsStateForLogin(Connection c, String login) throws SQLException { return executeDelete(c, """ DELETE FROM connections_state - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) """, login); } @@ -317,7 +317,7 @@ public final class BlockchainResyncCleanupDAO { private int deleteUsersParamsForLogin(Connection c, String login) throws SQLException { return executeDelete(c, """ DELETE FROM users_params - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) """, login); } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java index 4087e5a7..e2ad176a 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.BlockchainStateEntry; import java.sql.*; @@ -10,7 +10,7 @@ import java.util.List; public final class BlockchainStateDAO { private static volatile BlockchainStateDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private BlockchainStateDAO() {} @@ -75,7 +75,7 @@ public final class BlockchainStateDAO { last_block_hash, updated_at_ms FROM blockchain_state - ORDER BY blockchain_name COLLATE NOCASE + ORDER BY LOWER(blockchain_name) """; List result = new ArrayList<>(); @@ -142,7 +142,7 @@ public final class BlockchainStateDAO { * Строгая вставка state только если записи ещё нет. * * Нужна для recovery / resync: - * - identity пользователя уже может существовать в solana_users; + * - runtime-проекция пользователя уже может существовать в current users слое; * - в таком случае нам надо восстановить только blockchain_state; * - если запись уже есть, метод просто ничего не меняет. */ @@ -229,7 +229,7 @@ public final class BlockchainStateDAO { private static void setBytesNullable(PreparedStatement ps, int index, byte[] b) throws SQLException { if (b != null) ps.setBytes(index, b); - else ps.setNull(index, Types.BLOB); + else ps.setNull(index, Types.BINARY); } private static String nn(String s) { return s == null ? "" : s; } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java index 6291e4cc..2719c31c 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java @@ -2,7 +2,7 @@ package shine.db.dao; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.BlockEntry; import java.sql.*; @@ -22,7 +22,7 @@ import java.util.List; public final class BlocksDAO { private static volatile BlocksDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class); private BlocksDAO() { } @@ -90,7 +90,7 @@ public final class BlocksDAO { else ps.setNull(i++, Types.INTEGER); if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash()); - else ps.setNull(i++, Types.BLOB); + else ps.setNull(i++, Types.BINARY); ps.setBytes(i++, e.getBlockHash()); ps.setBytes(i++, e.getBlockSignature()); @@ -106,7 +106,7 @@ public final class BlocksDAO { else ps.setNull(i++, Types.INTEGER); if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash()); - else ps.setNull(i++, Types.BLOB); + else ps.setNull(i++, Types.BINARY); if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber()); else ps.setNull(i++, Types.INTEGER); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java index a0b07278..61ee579a 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.ChannelNameStateEntry; import java.sql.Connection; @@ -11,7 +11,7 @@ import java.util.List; public final class ChannelNameStateDAO { private static volatile ChannelNameStateDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private ChannelNameStateDAO() {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java index 3de5aa9b..e01fdf52 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java @@ -1,6 +1,7 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; +import shine.db.sql.CurrentUsersSql; import java.sql.Connection; import java.sql.PreparedStatement; @@ -13,16 +14,14 @@ import java.util.List; * ConnectionsStateDAO — чтение текущего состояния связей из connections_state. * * ВАЖНО: - * - login в запросах может быть в любом регистре, поэтому в WHERE используем COLLATE NOCASE - * - в ответах возвращаем логины в каноническом регистре через JOIN на solana_users - * - * ПРИМЕЧАНИЕ: - * Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй в SQL. + * - login в запросах может быть в любом регистре; + * - в ответах возвращаем логины в каноническом регистре через прямой lookup + * по актуальным PDA-данным и manual fallback. */ public final class ConnectionsStateDAO { private static volatile ConnectionsStateDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private ConnectionsStateDAO() {} @@ -42,14 +41,17 @@ public final class ConnectionsStateDAO { String sql = """ SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login FROM connections_state cs - LEFT JOIN solana_users u_login - ON u_login.login = cs.to_login COLLATE NOCASE - LEFT JOIN solana_users u_bch - ON u_bch.blockchain_name = cs.to_bch_name COLLATE NOCASE - WHERE cs.login = ? COLLATE NOCASE + LEFT JOIN %s + ON LOWER(u_login.login) = LOWER(cs.to_login) + LEFT JOIN %s + ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name) + WHERE LOWER(cs.login) = LOWER(?) AND cs.rel_type = ? ORDER BY friend_login - """; + """.formatted( + CurrentUsersSql.usersSubquery("u_login"), + CurrentUsersSql.usersSubquery("u_bch") + ); List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -72,17 +74,20 @@ public final class ConnectionsStateDAO { String sql = """ SELECT COALESCE(u_actor.login, cs.login) AS friend_login FROM connections_state cs - LEFT JOIN solana_users u_actor - ON u_actor.login = cs.login COLLATE NOCASE - LEFT JOIN solana_users u_target - ON u_target.login = ? COLLATE NOCASE + LEFT JOIN %s + ON LOWER(u_actor.login) = LOWER(cs.login) + LEFT JOIN %s + ON LOWER(u_target.login) = LOWER(?) WHERE ( - cs.to_login = ? COLLATE NOCASE - OR (u_target.blockchain_name IS NOT NULL AND cs.to_bch_name = u_target.blockchain_name COLLATE NOCASE) + LOWER(cs.to_login) = LOWER(?) + OR (u_target.blockchain_name IS NOT NULL AND LOWER(cs.to_bch_name) = LOWER(u_target.blockchain_name)) ) AND cs.rel_type = ? ORDER BY friend_login - """; + """.formatted( + CurrentUsersSql.usersSubquery("u_actor"), + CurrentUsersSql.usersSubquery("u_target") + ); List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -106,19 +111,19 @@ public final class ConnectionsStateDAO { String sql = """ SELECT u.login AS friend_login FROM connections_state a - JOIN solana_users u - ON u.login = a.to_login COLLATE NOCASE - WHERE a.login = ? COLLATE NOCASE + JOIN %s + ON LOWER(u.login) = LOWER(a.to_login) + WHERE LOWER(a.login) = LOWER(?) AND a.rel_type = ? AND EXISTS ( SELECT 1 FROM connections_state b - WHERE b.login = a.to_login COLLATE NOCASE - AND b.to_login = a.login COLLATE NOCASE + WHERE LOWER(b.login) = LOWER(a.to_login) + AND LOWER(b.to_login) = LOWER(a.login) AND b.rel_type = a.rel_type ) ORDER BY u.login - """; + """.formatted(CurrentUsersSql.usersSubquery("u")); List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -141,10 +146,22 @@ public final class ConnectionsStateDAO { String toBchName, Integer toBlockNumber, byte[] toBlockHash) throws SQLException { + try (PreparedStatement deletePs = c.prepareStatement(""" + DELETE FROM connections_state + WHERE LOWER(login) = LOWER(?) + AND rel_type = ? + AND LOWER(to_login) = LOWER(?) + """)) { + deletePs.setString(1, login); + deletePs.setInt(2, relType); + deletePs.setString(3, toLogin); + deletePs.executeUpdate(); + } + String sql = """ INSERT INTO connections_state (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(login, rel_type, to_login) DO UPDATE SET + ON CONFLICT(login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) DO UPDATE SET to_bch_name=excluded.to_bch_name, to_block_number=excluded.to_block_number, to_block_hash=excluded.to_block_hash diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUsersDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/CurrentUsersDAO.java similarity index 63% rename from SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUsersDAO.java rename to SHiNE-server/shine-server-db/src/main/java/shine/db/dao/CurrentUsersDAO.java index c9db7db7..ec61b084 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUsersDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/CurrentUsersDAO.java @@ -1,19 +1,22 @@ package shine.db.dao; -import shine.db.SqliteDbController; -import shine.db.entities.SolanaUserEntry; +import shine.db.DbController; +import shine.db.KeyEncodingUtil; +import shine.db.entities.CurrentUserEntry; +import shine.db.sql.CurrentUsersSql; import java.sql.*; import java.util.ArrayList; import java.util.List; /** - * SolanaUsersDAO — локальная таблица пользователей из Solana. + * CurrentUsersDAO — совместимый runtime-доступ к текущему срезу пользователей. * - * Таблица: solana_users + * Источник: + * - solana_user_pda_current * * Колонки: - * - login TEXT PRIMARY KEY (COLLATE NOCASE) + * - login TEXT PRIMARY KEY * - blockchain_name TEXT NOT NULL * - solana_key TEXT NOT NULL * - blockchain_key TEXT NOT NULL @@ -23,59 +26,32 @@ import java.util.List; * - методы с Connection НЕ закрывают соединение * - методы без Connection сами открывают и закрывают соединение */ -public final class SolanaUsersDAO { +public final class CurrentUsersDAO { - private static volatile SolanaUsersDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private static volatile CurrentUsersDAO instance; + private final DbController db = DbController.getInstance(); - private SolanaUsersDAO() {} + private CurrentUsersDAO() {} - public static SolanaUsersDAO getInstance() { + public static CurrentUsersDAO getInstance() { if (instance == null) { - synchronized (SolanaUsersDAO.class) { - if (instance == null) instance = new SolanaUsersDAO(); + synchronized (CurrentUsersDAO.class) { + if (instance == null) instance = new CurrentUsersDAO(); } } return instance; } - // -------------------- INSERT -------------------- - - /** Вставка с внешним соединением. Соединение НЕ закрывает. */ - public void insert(Connection c, SolanaUserEntry user) throws SQLException { - String sql = """ - INSERT INTO solana_users ( - login, blockchain_name, solana_key, blockchain_key, client_key - ) VALUES (?, ?, ?, ?, ?) - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, user.getLogin()); - ps.setString(2, user.getBlockchainName()); - ps.setString(3, user.getSolanaKey()); - ps.setString(4, user.getBlockchainKey()); - ps.setString(5, user.getClientKey()); - ps.executeUpdate(); - } - } - - /** Вставка без внешнего соединения. Сам открывает/закрывает. */ - public void insert(SolanaUserEntry user) throws SQLException { - try (Connection c = db.getConnection()) { - insert(c, user); - } - } - // -------------------- EXISTS -------------------- /** Проверка существования по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */ public boolean existsByLogin(Connection c, String login) throws SQLException { String sql = """ SELECT 1 - FROM solana_users + FROM %s WHERE LOWER(login) = LOWER(?) LIMIT 1 - """; + """.formatted(CurrentUsersSql.usersSubquery("su")); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, login); @@ -96,10 +72,10 @@ public final class SolanaUsersDAO { public boolean existsByBlockchainName(Connection c, String blockchainName) throws SQLException { String sql = """ SELECT 1 - FROM solana_users + FROM %s WHERE blockchain_name = ? LIMIT 1 - """; + """.formatted(CurrentUsersSql.usersSubquery("su")); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, blockchainName); @@ -119,7 +95,7 @@ public final class SolanaUsersDAO { // -------------------- SELECT -------------------- /** Получить по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */ - public SolanaUserEntry getByLogin(Connection c, String login) throws SQLException { + public CurrentUserEntry getByLogin(Connection c, String login) throws SQLException { String sql = """ SELECT login, @@ -127,9 +103,9 @@ public final class SolanaUsersDAO { solana_key, blockchain_key, client_key - FROM solana_users + FROM %s WHERE LOWER(login) = LOWER(?) - """; + """.formatted(CurrentUsersSql.usersSubquery("su")); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, login); @@ -141,14 +117,14 @@ public final class SolanaUsersDAO { } /** Получить по login (case-insensitive) без внешнего соединения. Сам открывает/закрывает. */ - public SolanaUserEntry getByLogin(String login) throws SQLException { + public CurrentUserEntry getByLogin(String login) throws SQLException { try (Connection c = db.getConnection()) { return getByLogin(c, login); } } /** Получить по blockchain_name (case-sensitive) с внешним соединением. Соединение НЕ закрывает. */ - public SolanaUserEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException { + public CurrentUserEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException { String sql = """ SELECT login, @@ -156,9 +132,9 @@ public final class SolanaUsersDAO { solana_key, blockchain_key, client_key - FROM solana_users + FROM %s WHERE blockchain_name = ? - """; + """.formatted(CurrentUsersSql.usersSubquery("su")); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, blockchainName); @@ -170,14 +146,19 @@ public final class SolanaUsersDAO { } /** Получить по blockchain_name без внешнего соединения. */ - public SolanaUserEntry getByBlockchainName(String blockchainName) throws SQLException { + public CurrentUserEntry getByBlockchainName(String blockchainName) throws SQLException { try (Connection c = db.getConnection()) { return getByBlockchainName(c, blockchainName); } } /** Поиск по префиксу с внешним соединением. Соединение НЕ закрывает. */ - public List searchByLoginPrefix(Connection c, String prefix) throws SQLException { + public List searchByLoginPrefix(Connection c, String prefix) throws SQLException { + return searchByLoginPrefix(c, prefix, null); + } + + /** Поиск по префиксу с optional-фильтром server PDA. Соединение НЕ закрывает. */ + public List searchByLoginPrefix(Connection c, String prefix, Boolean isServer) throws SQLException { String sql = """ SELECT login, @@ -185,16 +166,24 @@ public final class SolanaUsersDAO { solana_key, blockchain_key, client_key - FROM solana_users + FROM %s WHERE LOWER(login) LIKE ? + AND (? IS NULL OR is_server = ?) ORDER BY login LIMIT 5 - """; + """.formatted(CurrentUsersSql.usersSubquery("su")); - List result = new ArrayList<>(); + List result = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, prefix.toLowerCase() + "%"); + if (isServer == null) { + ps.setNull(2, Types.BOOLEAN); + ps.setNull(3, Types.BOOLEAN); + } else { + ps.setBoolean(2, isServer); + ps.setBoolean(3, isServer); + } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) result.add(mapRow(rs)); } @@ -204,23 +193,28 @@ public final class SolanaUsersDAO { } /** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */ - public List searchByLoginPrefix(String prefix) throws SQLException { + public List searchByLoginPrefix(String prefix) throws SQLException { + return searchByLoginPrefix(prefix, null); + } + + /** Поиск по префиксу без внешнего соединения с optional-фильтром server PDA. */ + public List searchByLoginPrefix(String prefix, Boolean isServer) throws SQLException { try (Connection c = db.getConnection()) { - return searchByLoginPrefix(c, prefix); + return searchByLoginPrefix(c, prefix, isServer); } } // -------------------- MAPPER -------------------- - private SolanaUserEntry mapRow(ResultSet rs) throws SQLException { - SolanaUserEntry e = new SolanaUserEntry(); + private CurrentUserEntry mapRow(ResultSet rs) throws SQLException { + CurrentUserEntry e = new CurrentUserEntry(); e.setLogin(rs.getString("login")); e.setBlockchainName(rs.getString("blockchain_name")); - e.setSolanaKey(rs.getString("solana_key")); - e.setBlockchainKey(rs.getString("blockchain_key")); - e.setClientKey(rs.getString("client_key")); + e.setSolanaKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("solana_key"))); + e.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key"))); + e.setClientKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("client_key"))); return e; } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java index 5d2b4c56..ae063da4 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.DirectMessageEntry; import java.sql.Connection; @@ -8,7 +8,7 @@ import java.sql.PreparedStatement; public final class DirectMessagesDAO { private static volatile DirectMessagesDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private DirectMessagesDAO() {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmSyncPeerStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmSyncPeerStateDAO.java new file mode 100644 index 00000000..b5fe6410 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmSyncPeerStateDAO.java @@ -0,0 +1,173 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.entities.DmSyncPeerStateEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +public final class DmSyncPeerStateDAO { + private static volatile DmSyncPeerStateDAO instance; + private final DbController db = DbController.getInstance(); + + private DmSyncPeerStateDAO() {} + + public static DmSyncPeerStateDAO getInstance() { + if (instance == null) { + synchronized (DmSyncPeerStateDAO.class) { + if (instance == null) instance = new DmSyncPeerStateDAO(); + } + } + return instance; + } + + public DmSyncPeerStateEntry getOrCreate(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException { + DmSyncPeerStateEntry existing = get(ownerLogin, remoteServerLogin); + if (existing != null) { + if (remoteServerUrl != null && !remoteServerUrl.isBlank() + && !remoteServerUrl.equals(existing.getRemoteServerUrl())) { + touchRemoteUrl(ownerLogin, remoteServerLogin, remoteServerUrl); + existing.setRemoteServerUrl(remoteServerUrl); + } + return existing; + } + + long nowMs = System.currentTimeMillis(); + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + INSERT INTO dm_sync_peer_state ( + owner_login, remote_server_login, remote_server_url, + cursor_stored_at_ms, cursor_message_key, bootstrap_completed, + last_sync_at_ms, last_error, updated_at_ms + ) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?) + ON CONFLICT DO NOTHING + """)) { + ps.setString(1, ownerLogin); + ps.setString(2, remoteServerLogin); + ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl); + ps.setLong(4, nowMs); + ps.executeUpdate(); + } + existing = get(ownerLogin, remoteServerLogin); + if (existing == null) { + throw new SQLException("Failed to create dm_sync_peer_state row"); + } + return existing; + } + + public void updateSuccess( + String ownerLogin, + String remoteServerLogin, + String remoteServerUrl, + long cursorStoredAtMs, + String cursorMessageKey, + boolean bootstrapCompleted + ) throws SQLException { + long nowMs = System.currentTimeMillis(); + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + INSERT INTO dm_sync_peer_state ( + owner_login, remote_server_login, remote_server_url, + cursor_stored_at_ms, cursor_message_key, bootstrap_completed, + last_sync_at_ms, last_error, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?) + ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET + remote_server_url = EXCLUDED.remote_server_url, + cursor_stored_at_ms = EXCLUDED.cursor_stored_at_ms, + cursor_message_key = EXCLUDED.cursor_message_key, + bootstrap_completed = EXCLUDED.bootstrap_completed, + last_sync_at_ms = EXCLUDED.last_sync_at_ms, + last_error = NULL, + updated_at_ms = EXCLUDED.updated_at_ms + """)) { + ps.setString(1, ownerLogin); + ps.setString(2, remoteServerLogin); + ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl); + ps.setLong(4, Math.max(0L, cursorStoredAtMs)); + ps.setString(5, cursorMessageKey == null ? "" : cursorMessageKey); + ps.setBoolean(6, bootstrapCompleted); + ps.setLong(7, nowMs); + ps.setLong(8, nowMs); + ps.executeUpdate(); + } + } + + public void updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException { + long nowMs = System.currentTimeMillis(); + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + INSERT INTO dm_sync_peer_state ( + owner_login, remote_server_login, remote_server_url, + cursor_stored_at_ms, cursor_message_key, bootstrap_completed, + last_sync_at_ms, last_error, updated_at_ms + ) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?) + ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET + remote_server_url = EXCLUDED.remote_server_url, + last_error = EXCLUDED.last_error, + updated_at_ms = EXCLUDED.updated_at_ms + """)) { + ps.setString(1, ownerLogin); + ps.setString(2, remoteServerLogin); + ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl); + ps.setString(4, truncate(error)); + ps.setLong(5, nowMs); + ps.executeUpdate(); + } + } + + private DmSyncPeerStateEntry get(String ownerLogin, String remoteServerLogin) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + SELECT owner_login, remote_server_login, remote_server_url, + cursor_stored_at_ms, cursor_message_key, bootstrap_completed, + last_sync_at_ms, last_error, updated_at_ms + FROM dm_sync_peer_state + WHERE LOWER(owner_login) = LOWER(?) + AND LOWER(remote_server_login) = LOWER(?) + LIMIT 1 + """)) { + ps.setString(1, ownerLogin); + ps.setString(2, remoteServerLogin); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + DmSyncPeerStateEntry e = new DmSyncPeerStateEntry(); + e.setOwnerLogin(rs.getString("owner_login")); + e.setRemoteServerLogin(rs.getString("remote_server_login")); + e.setRemoteServerUrl(rs.getString("remote_server_url")); + e.setCursorStoredAtMs(rs.getLong("cursor_stored_at_ms")); + e.setCursorMessageKey(rs.getString("cursor_message_key")); + e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed")); + long lastSyncAt = rs.getLong("last_sync_at_ms"); + e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAt); + e.setLastError(rs.getString("last_error")); + e.setUpdatedAtMs(rs.getLong("updated_at_ms")); + return e; + } + } + } + + private void touchRemoteUrl(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + UPDATE dm_sync_peer_state + SET remote_server_url = ?, updated_at_ms = ? + WHERE LOWER(owner_login) = LOWER(?) + AND LOWER(remote_server_login) = LOWER(?) + """)) { + ps.setString(1, remoteServerUrl); + ps.setLong(2, System.currentTimeMillis()); + ps.setString(3, ownerLogin); + ps.setString(4, remoteServerLogin); + ps.executeUpdate(); + } + } + + private static String truncate(String value) { + if (value == null) return null; + String clean = value.trim(); + if (clean.length() <= 500) return clean; + return clean.substring(0, 500); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java index 9c90c975..3ef4764a 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.EspPairingRequestEntry; import java.sql.Connection; @@ -13,7 +13,7 @@ import java.util.List; public final class EspPairingRequestsDAO { private static volatile EspPairingRequestsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private EspPairingRequestsDAO() { } @@ -110,7 +110,7 @@ public final class EspPairingRequestsDAO { payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason, approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver FROM esp_pairing_requests - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) AND expires_at_ms > ? AND status = 'created' ORDER BY created_at_ms DESC @@ -131,7 +131,7 @@ public final class EspPairingRequestsDAO { StringBuilder sql = new StringBuilder(""" SELECT COUNT(*) FROM esp_pairing_requests - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) AND created_at_ms >= ? AND status IN ( """); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java index 873ba7dd..cc86a954 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.EspPairingSettingsEntry; import java.sql.Connection; @@ -11,7 +11,7 @@ import java.sql.SQLException; public final class EspPairingSettingsDAO { private static volatile EspPairingSettingsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private EspPairingSettingsDAO() { } @@ -74,7 +74,7 @@ public final class EspPairingSettingsDAO { String sql = """ SELECT login, enabled, password_hash, ttl_seconds, failed_attempts, first_failed_at_ms, blocked_until_ms, updated_at_ms FROM esp_pairing_settings - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) LIMIT 1 """; try (PreparedStatement ps = c.prepareStatement(sql)) { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/IpGeoCacheDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/IpGeoCacheDAO.java index db9d11d7..1b16a24e 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/IpGeoCacheDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/IpGeoCacheDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.IpGeoCacheEntry; import java.sql.*; @@ -20,7 +20,7 @@ import java.sql.*; public final class IpGeoCacheDAO { private static volatile IpGeoCacheDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private IpGeoCacheDAO() { } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/PushTokensDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/PushTokensDAO.java index 1fc3ef30..8586c01c 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/PushTokensDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/PushTokensDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.PushTokenEntry; import java.sql.Connection; @@ -11,7 +11,7 @@ import java.util.List; public final class PushTokensDAO { private static volatile PushTokensDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private PushTokensDAO() {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDirectMessagesHistoryDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDirectMessagesHistoryDAO.java index 6cea2249..0c9f9064 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDirectMessagesHistoryDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDirectMessagesHistoryDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.SignedDirectMessageHistoryEntry; import java.sql.Connection; @@ -8,7 +8,7 @@ import java.sql.PreparedStatement; public final class SignedDirectMessagesHistoryDAO { private static volatile SignedDirectMessagesHistoryDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private SignedDirectMessagesHistoryDAO() {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDmReplayDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDmReplayDAO.java index 3144dffe..9d11354b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDmReplayDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDmReplayDAO.java @@ -1,13 +1,13 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.Connection; import java.sql.PreparedStatement; public final class SignedDmReplayDAO { private static volatile SignedDmReplayDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private SignedDmReplayDAO() {} @@ -24,10 +24,11 @@ public final class SignedDmReplayDAO { cleanupExpired(nowMs - 15L * 60L * 1000L); try (Connection c = db.getConnection()) { String sql = """ - INSERT OR IGNORE INTO signed_direct_message_replay ( - from_login, time_ms, nonce, created_at_ms - ) VALUES (?, ?, ?, ?) - """; + INSERT INTO signed_direct_message_replay ( + from_login, time_ms, nonce, created_at_ms + ) VALUES (?, ?, ?, ?) + ON CONFLICT DO NOTHING + """; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, fromLogin); ps.setLong(2, timeMs); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesDAO.java similarity index 78% rename from SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java rename to SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesDAO.java index 85af6e05..e8d11c6f 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesDAO.java @@ -1,7 +1,7 @@ package shine.db.dao; -import shine.db.SqliteDbController; -import shine.db.entities.SignedMessageV2Entry; +import shine.db.DbController; +import shine.db.entities.SignedMessageEntry; import java.sql.Connection; import java.sql.PreparedStatement; @@ -11,9 +11,7 @@ import java.sql.Statement; import java.util.ArrayList; import java.util.List; -public final class SignedMessagesV2DAO { - private static final int SQLITE_BUSY_MAX_RETRIES = 6; - private static final long SQLITE_BUSY_RETRY_BASE_DELAY_MS = 40L; +public final class SignedMessagesDAO { public enum ApplyStatus { APPLIED, @@ -26,33 +24,34 @@ public final class SignedMessagesV2DAO { } } - private static volatile SignedMessagesV2DAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private static volatile SignedMessagesDAO instance; + private final DbController db = DbController.getInstance(); - private SignedMessagesV2DAO() {} + private SignedMessagesDAO() {} - public static SignedMessagesV2DAO getInstance() { + public static SignedMessagesDAO getInstance() { if (instance == null) { - synchronized (SignedMessagesV2DAO.class) { - if (instance == null) instance = new SignedMessagesV2DAO(); + synchronized (SignedMessagesDAO.class) { + if (instance == null) instance = new SignedMessagesDAO(); } } return instance; } - public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception { + public ApplyStatus insertIfAbsent(SignedMessageEntry e) throws Exception { return withBusyRetry(() -> { try (Connection c = db.getConnection()) { if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) { return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; } String sql = """ - INSERT OR IGNORE INTO signed_messages_v2 ( + INSERT INTO signed_messages ( message_key, base_key, target_login, from_login, to_login, time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, raw_block, created_at_ms, source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type, read_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING """; try (PreparedStatement ps = c.prepareStatement(sql)) { bindSignedMessage(ps, e); @@ -66,7 +65,7 @@ public final class SignedMessagesV2DAO { }); } - public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception { + public boolean insertPairBothOrNothing(SignedMessageEntry first, SignedMessageEntry second) throws Exception { return withBusyRetry(() -> { try (Connection c = db.getConnection()) { boolean prevAutoCommit = c.getAutoCommit(); @@ -95,7 +94,7 @@ public final class SignedMessagesV2DAO { }); } - public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception { + public ApplyStatus upsertContentPair(SignedMessageEntry incoming, SignedMessageEntry outgoing) throws Exception { return withBusyRetry(() -> { try (Connection c = db.getConnection()) { boolean prevAutoCommit = c.getAutoCommit(); @@ -136,7 +135,7 @@ public final class SignedMessagesV2DAO { }); } - public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception { + public ApplyStatus upsertIncomingCopy(SignedMessageEntry incoming) throws Exception { return withBusyRetry(() -> { try (Connection c = db.getConnection()) { boolean prevAutoCommit = c.getAutoCommit(); @@ -173,7 +172,7 @@ public final class SignedMessagesV2DAO { }); } - public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception { + public ApplyStatus applyDeleteMessage(SignedMessageEntry tombstone) throws Exception { return withBusyRetry(() -> { try (Connection c = db.getConnection()) { boolean prevAutoCommit = c.getAutoCommit(); @@ -204,7 +203,7 @@ public final class SignedMessagesV2DAO { }); } - public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception { + public ApplyStatus applyDeleteConversation(SignedMessageEntry tombstone) throws Exception { return withBusyRetry(() -> { try (Connection c = db.getConnection()) { boolean prevAutoCommit = c.getAutoCommit(); @@ -232,7 +231,7 @@ public final class SignedMessagesV2DAO { }); } - public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception { + public SignedMessageEntry getByMessageKey(String messageKey) throws Exception { try (Connection c = db.getConnection()) { String sql = """ SELECT @@ -240,9 +239,9 @@ public final class SignedMessagesV2DAO { time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, raw_block, created_at_ms, source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type, read_at_ms - FROM signed_messages_v2 + FROM %s WHERE message_key = ? - """; + """.formatted(messagesTable()); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, messageKey); try (ResultSet rs = ps.executeQuery()) { @@ -253,7 +252,7 @@ public final class SignedMessagesV2DAO { } } - public SignedMessageV2Entry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception { + public SignedMessageEntry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception { try (Connection c = db.getConnection()) { return getLatestConversationDelete(c, fromLogin, toLogin); } @@ -268,9 +267,10 @@ public final class SignedMessagesV2DAO { withBusyRetry(() -> { try (Connection c = db.getConnection()) { String sql = """ - INSERT OR IGNORE INTO signed_message_session_delivery ( + INSERT INTO signed_message_session_delivery ( message_key, session_id, delivered, delivered_at_ms, created_at_ms ) VALUES (?, ?, 0, NULL, ?) + ON CONFLICT DO NOTHING """; try (PreparedStatement ps = c.prepareStatement(sql)) { for (String sessionId : sessionIds) { @@ -314,22 +314,23 @@ public final class SignedMessagesV2DAO { }); } - public List listPendingForSession(String login, String sessionId) throws Exception { + public List listPendingForSession(String login, String sessionId) throws Exception { return withBusyRetry(() -> { try (Connection c = db.getConnection()) { String fillSql = """ - INSERT OR IGNORE INTO signed_message_session_delivery ( + INSERT INTO signed_message_session_delivery ( message_key, session_id, delivered, delivered_at_ms, created_at_ms ) SELECT m.message_key, ?, 0, NULL, ? - FROM signed_messages_v2 m + FROM %s m WHERE ( - (m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE) - OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE) + (m.message_type IN (1, 3) AND LOWER(m.to_login) = LOWER(?)) + OR (m.message_type IN (2, 4) AND LOWER(m.from_login) = LOWER(?)) OR (m.message_type IN (5, 6, 7, 8) - AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE)) + AND (LOWER(m.from_login) = LOWER(?) OR LOWER(m.to_login) = LOWER(?))) ) - """; + ON CONFLICT DO NOTHING + """.formatted(messagesTable()); long now = System.currentTimeMillis(); try (PreparedStatement ps = c.prepareStatement(fillSql)) { ps.setString(1, sessionId); @@ -347,13 +348,13 @@ public final class SignedMessagesV2DAO { m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms, m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type, m.read_at_ms - FROM signed_messages_v2 m + FROM %s m JOIN signed_message_session_delivery d ON d.message_key = m.message_key WHERE d.session_id = ? AND d.delivered = 0 ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC - """; - List out = new ArrayList<>(); + """.formatted(messagesTable()); + List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, sessionId); try (ResultSet rs = ps.executeQuery()) { @@ -365,7 +366,7 @@ public final class SignedMessagesV2DAO { }); } - public List listConversationPage( + public List listConversationPage( String login, String peerLogin, long beforeTimeMs, @@ -379,12 +380,12 @@ public final class SignedMessagesV2DAO { time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, raw_block, created_at_ms, source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type, read_at_ms - FROM signed_messages_v2 - WHERE target_login = ? COLLATE NOCASE + FROM %s + WHERE LOWER(target_login) = LOWER(?) AND message_type IN (1, 2) AND ( - (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) - OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) ) AND ( ? <= 0 @@ -393,8 +394,8 @@ public final class SignedMessagesV2DAO { ) ORDER BY time_ms DESC, message_key DESC LIMIT ? - """; - List out = new ArrayList<>(); + """.formatted(messagesTable()); + List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, login); ps.setString(2, login); @@ -415,9 +416,75 @@ public final class SignedMessagesV2DAO { } } - private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException { + public SyncBatch listSyncBatch( + String ownerLogin, + long afterStoredAtMs, + String afterMessageKey, + int limit, + int maxBytes + ) throws Exception { + int safeLimit = Math.max(1, Math.min(limit, 500)); + int sqlLimit = safeLimit + 1; + String safeAfterMessageKey = afterMessageKey == null ? "" : afterMessageKey; + try (Connection c = db.getConnection()) { + String sql = """ + SELECT + message_key, base_key, target_login, from_login, to_login, + time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, + raw_block, created_at_ms, source_api, origin_session_id, + receipt_ref_base_key, receipt_ref_type, read_at_ms + FROM %s + WHERE ( + LOWER(target_login) = LOWER(?) + OR ( + message_type IN (5, 6, 7, 8) + AND (LOWER(from_login) = LOWER(?) OR LOWER(to_login) = LOWER(?)) + ) + ) + AND ( + created_at_ms > ? + OR (created_at_ms = ? AND (? = '' OR message_key > ?)) + ) + ORDER BY created_at_ms ASC, message_key ASC + LIMIT ? + """.formatted(messagesTable()); + List out = new ArrayList<>(); + boolean hasMore = false; + int usedBytes = 0; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerLogin); + ps.setString(2, ownerLogin); + ps.setString(3, ownerLogin); + ps.setLong(4, Math.max(0L, afterStoredAtMs)); + ps.setLong(5, Math.max(0L, afterStoredAtMs)); + ps.setString(6, safeAfterMessageKey); + ps.setString(7, safeAfterMessageKey); + ps.setInt(8, sqlLimit); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + SignedMessageEntry row = mapRow(rs); + byte[] raw = row.getRawBlock(); + int rowBytes = raw == null ? 0 : raw.length; + if (out.size() >= safeLimit) { + hasMore = true; + break; + } + if (!out.isEmpty() && maxBytes > 0 && usedBytes + rowBytes > maxBytes) { + hasMore = true; + break; + } + out.add(row); + usedBytes += rowBytes; + } + } + } + return new SyncBatch(out, hasMore, usedBytes); + } + } + + private void upsertMessage(Connection c, SignedMessageEntry e) throws SQLException { String sql = """ - INSERT INTO signed_messages_v2 ( + INSERT INTO %s ( message_key, base_key, target_login, from_login, to_login, time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, raw_block, created_at_ms, source_api, origin_session_id, @@ -439,15 +506,15 @@ public final class SignedMessagesV2DAO { origin_session_id = excluded.origin_session_id, receipt_ref_base_key = excluded.receipt_ref_base_key, receipt_ref_type = excluded.receipt_ref_type, - read_at_ms = COALESCE(signed_messages_v2.read_at_ms, excluded.read_at_ms) - """; + read_at_ms = COALESCE(%s.read_at_ms, excluded.read_at_ms) + """.formatted(messagesTable(), messagesTable()); try (PreparedStatement ps = c.prepareStatement(sql)) { bindSignedMessage(ps, e); ps.executeUpdate(); } } - private void markMessageReadByReceipt(Connection c, SignedMessageV2Entry entry) throws SQLException { + private void markMessageReadByReceipt(Connection c, SignedMessageEntry entry) throws SQLException { if (entry == null) return; int messageType = entry.getMessageType(); if (messageType != 3 && messageType != 4) return; @@ -456,7 +523,7 @@ public final class SignedMessagesV2DAO { long readAtMs = entry.getTimeMs(); if (readAtMs <= 0) return; try (PreparedStatement ps = c.prepareStatement(""" - UPDATE signed_messages_v2 + UPDATE %s SET read_at_ms = CASE WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ? WHEN read_at_ms > ? THEN ? @@ -464,7 +531,7 @@ public final class SignedMessagesV2DAO { END WHERE base_key = ? AND message_type IN (1, 2) - """)) { + """.formatted(messagesTable()))) { ps.setLong(1, readAtMs); ps.setLong(2, readAtMs); ps.setLong(3, readAtMs); @@ -476,10 +543,10 @@ public final class SignedMessagesV2DAO { private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException { String sql = """ SELECT revision_time_ms, reencrypted_at_ms - FROM signed_messages_v2 + FROM %s WHERE message_key = ? LIMIT 1 - """; + """.formatted(messagesTable()); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, messageKey); try (ResultSet rs = ps.executeQuery()) { @@ -492,12 +559,12 @@ public final class SignedMessagesV2DAO { private RevisionMarker getCurrentContentMarker(Connection c, String baseKey) throws SQLException { String sql = """ SELECT revision_time_ms, reencrypted_at_ms - FROM signed_messages_v2 + FROM %s WHERE base_key = ? AND message_type IN (1, 2) ORDER BY revision_time_ms DESC, reencrypted_at_ms DESC LIMIT 1 - """; + """.formatted(messagesTable()); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, baseKey); try (ResultSet rs = ps.executeQuery()) { @@ -510,11 +577,11 @@ public final class SignedMessagesV2DAO { private boolean hasMessageDeleteTombstone(Connection c, String baseKey) throws SQLException { String sql = """ SELECT 1 - FROM signed_messages_v2 + FROM %s WHERE base_key = ? AND message_type IN (5, 6) LIMIT 1 - """; + """.formatted(messagesTable()); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, baseKey); try (ResultSet rs = ps.executeQuery()) { @@ -531,13 +598,13 @@ public final class SignedMessagesV2DAO { private Long getLatestConversationDeleteBoundary(Connection c, String fromLogin, String toLogin) throws SQLException { String sql = """ SELECT MAX(time_ms) - FROM signed_messages_v2 + FROM %s WHERE message_type IN (7, 8) AND ( - (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) - OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) ) - """; + """.formatted(messagesTable()); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, fromLogin); ps.setString(2, toLogin); @@ -551,22 +618,22 @@ public final class SignedMessagesV2DAO { } } - private SignedMessageV2Entry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception { + private SignedMessageEntry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception { String sql = """ SELECT message_key, base_key, target_login, from_login, to_login, time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, raw_block, created_at_ms, source_api, origin_session_id, - receipt_ref_base_key, receipt_ref_type - FROM signed_messages_v2 + receipt_ref_base_key, receipt_ref_type, read_at_ms + FROM %s WHERE message_type IN (7, 8) AND ( - (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) - OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) ) ORDER BY time_ms DESC LIMIT 1 - """; + """.formatted(messagesTable()); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, fromLogin); ps.setString(2, toLogin); @@ -582,16 +649,16 @@ public final class SignedMessagesV2DAO { private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException { deleteDeliveryRowsByMessageSelection(c, """ SELECT message_key - FROM signed_messages_v2 + FROM %s WHERE (base_key = ? AND message_type IN (1, 2)) OR (receipt_ref_base_key = ? AND message_type IN (3, 4)) - """, baseKey, baseKey); + """.formatted(messagesTable()), baseKey, baseKey); try (PreparedStatement ps = c.prepareStatement(""" - DELETE FROM signed_messages_v2 + DELETE FROM %s WHERE (base_key = ? AND message_type IN (1, 2)) OR (receipt_ref_base_key = ? AND message_type IN (3, 4)) - """)) { + """.formatted(messagesTable()))) { ps.setString(1, baseKey); ps.setString(2, baseKey); ps.executeUpdate(); @@ -601,22 +668,22 @@ public final class SignedMessagesV2DAO { private void deleteConversationHistoryBefore(Connection c, String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException { deleteDeliveryRowsByMessageSelection(c, """ SELECT message_key - FROM signed_messages_v2 + FROM %s WHERE time_ms < ? AND ( - (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) - OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) ) - """, boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin); + """.formatted(messagesTable()), boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin); try (PreparedStatement ps = c.prepareStatement(""" - DELETE FROM signed_messages_v2 + DELETE FROM %s WHERE time_ms < ? AND ( - (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) - OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) ) - """)) { + """.formatted(messagesTable()))) { ps.setLong(1, boundaryTimeMs); ps.setString(2, fromLogin); ps.setString(3, toLogin); @@ -645,22 +712,22 @@ public final class SignedMessagesV2DAO { } } - private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException { + private int insertStrict(Connection c, SignedMessageEntry e) throws SQLException { String sql = """ - INSERT INTO signed_messages_v2 ( + INSERT INTO %s ( message_key, base_key, target_login, from_login, to_login, time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, raw_block, created_at_ms, source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type, read_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """; + """.formatted(messagesTable()); try (PreparedStatement ps = c.prepareStatement(sql)) { bindSignedMessage(ps, e); return ps.executeUpdate(); } } - private void bindSignedMessage(PreparedStatement ps, SignedMessageV2Entry e) throws SQLException { + private void bindSignedMessage(PreparedStatement ps, SignedMessageEntry e) throws SQLException { ps.setString(1, e.getMessageKey()); ps.setString(2, e.getBaseKey()); ps.setString(3, e.getTargetLogin()); @@ -703,43 +770,8 @@ public final class SignedMessagesV2DAO { return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key"); } - private boolean isBusyLock(SQLException ex) { - Throwable current = ex; - while (current != null) { - String msg = String.valueOf(current.getMessage()).toLowerCase(); - if (msg.contains("sqlite_busy") || msg.contains("database is locked") || msg.contains("database table is locked")) { - return true; - } - current = current.getCause(); - } - return false; - } - - private void sleepBeforeBusyRetry(int attempt) throws SQLException { - long delayMs = SQLITE_BUSY_RETRY_BASE_DELAY_MS * (1L << Math.min(attempt, 4)); - try { - Thread.sleep(delayMs); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - SQLException sqlEx = new SQLException("Interrupted while retrying SQLite busy lock", ie); - throw sqlEx; - } - } - private T withBusyRetry(SqlWork work) throws Exception { - SQLException lastBusy = null; - for (int attempt = 0; attempt < SQLITE_BUSY_MAX_RETRIES; attempt++) { - try { - return work.run(); - } catch (SQLException ex) { - if (!isBusyLock(ex) || attempt >= SQLITE_BUSY_MAX_RETRIES - 1) { - throw ex; - } - lastBusy = ex; - sleepBeforeBusyRetry(attempt); - } - } - throw lastBusy == null ? new SQLException("SQLite busy retry failed") : lastBusy; + return work.run(); } private int compareMarkers(RevisionMarker left, RevisionMarker right) { @@ -748,8 +780,12 @@ public final class SignedMessagesV2DAO { return Long.compare(left.reencryptedAtMs, right.reencryptedAtMs); } - private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception { - SignedMessageV2Entry e = new SignedMessageV2Entry(); + private String messagesTable() { + return "signed_messages"; + } + + private SignedMessageEntry mapRow(ResultSet rs) throws Exception { + SignedMessageEntry e = new SignedMessageEntry(); e.setMessageKey(rs.getString("message_key")); e.setBaseKey(rs.getString("base_key")); e.setTargetLogin(rs.getString("target_login")); @@ -773,11 +809,13 @@ public final class SignedMessagesV2DAO { } private record RevisionMarker(long revisionTimeMs, long reencryptedAtMs) { - private static RevisionMarker of(SignedMessageV2Entry entry) { + private static RevisionMarker of(SignedMessageEntry entry) { return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs()); } } + public record SyncBatch(List items, boolean hasMore, int rawBytes) {} + @FunctionalInterface private interface SqlWork { T run() throws Exception; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java new file mode 100644 index 00000000..923bff29 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java @@ -0,0 +1,71 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.KeyEncodingUtil; +import shine.db.entities.SolanaUserPdaCurrentEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем. + */ +public final class SolanaUserPdaCurrentDAO { + + private static volatile SolanaUserPdaCurrentDAO instance; + + private final DbController db = DbController.getInstance(); + + private SolanaUserPdaCurrentDAO() {} + + public static SolanaUserPdaCurrentDAO getInstance() { + if (instance == null) { + synchronized (SolanaUserPdaCurrentDAO.class) { + if (instance == null) { + instance = new SolanaUserPdaCurrentDAO(); + } + } + } + return instance; + } + + public SolanaUserPdaCurrentEntry getByBlockchainName(String blockchainName) throws SQLException { + try (Connection c = db.getConnection()) { + return getByBlockchainName(c, blockchainName); + } + } + + public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException { + String sql = """ + SELECT + login, + blockchain_name, + blockchain_key, + paid_limit_bytes + FROM solana_user_pda_current + WHERE blockchain_name = ? + LIMIT 1 + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, blockchainName); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + return null; + } + return mapRow(rs); + } + } + } + + private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException { + SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry(); + entry.setLogin(rs.getString("login")); + entry.setBlockchainName(rs.getString("blockchain_name")); + entry.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key"))); + entry.setPaidLimitBytes(rs.getLong("paid_limit_bytes")); + return entry; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java index 3fe7b0cc..cd089db4 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java @@ -1,7 +1,7 @@ package shine.db.dao; import shine.db.MsgSubType; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.*; import java.util.ArrayList; @@ -25,7 +25,7 @@ import java.util.List; public final class SubscriptionsDAO { private static volatile SubscriptionsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private SubscriptionsDAO() {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java index edeeb9b9..bbf9b71c 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.SyncServerEntry; import java.sql.Connection; @@ -17,7 +17,7 @@ import java.util.List; public final class SyncServersDAO { private static volatile SyncServersDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private SyncServersDAO() {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java index 73571402..5ce4d44f 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.TestFreeAvatarUploadEntry; import java.sql.Connection; @@ -11,7 +11,7 @@ import java.sql.SQLException; public final class TestFreeAvatarUploadsDAO { private static volatile TestFreeAvatarUploadsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private TestFreeAvatarUploadsDAO() { } @@ -29,7 +29,7 @@ public final class TestFreeAvatarUploadsDAO { String sql = """ SELECT login, used_count, updated_at_ms, last_tx_id FROM test_free_avatar_uploads - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) LIMIT 1 """; try (PreparedStatement ps = c.prepareStatement(sql)) { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java new file mode 100644 index 00000000..e95a6df4 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java @@ -0,0 +1,86 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.KeyEncodingUtil; +import shine.db.entities.UserAccessServerRouteEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +/** + * DAO локальной routing-проекции access servers пользователей. + */ +public final class UserAccessServersCurrentDAO { + + private static volatile UserAccessServersCurrentDAO instance; + private final DbController db = DbController.getInstance(); + + private UserAccessServersCurrentDAO() {} + + public static UserAccessServersCurrentDAO getInstance() { + if (instance == null) { + synchronized (UserAccessServersCurrentDAO.class) { + if (instance == null) instance = new UserAccessServersCurrentDAO(); + } + } + return instance; + } + + public List listByUserLogin(String userLogin) throws SQLException { + try (Connection c = db.getConnection()) { + return listByUserLogin(c, userLogin); + } + } + + public List listByUserLogin(Connection c, String userLogin) throws SQLException { + String sql = """ + SELECT user_login, server_login, server_url, server_client_key + FROM user_access_servers_current + WHERE LOWER(user_login) = LOWER(?) + ORDER BY server_login + """; + List result = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, userLogin); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(mapRow(rs)); + } + } + } + return result; + } + + public List listUserLoginsByServerLogin(String serverLogin) throws SQLException { + String sql = """ + SELECT user_login + FROM user_access_servers_current + WHERE LOWER(server_login) = LOWER(?) + ORDER BY user_login + """; + List result = new ArrayList<>(); + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, serverLogin); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(rs.getString("user_login")); + } + } + } + return result; + } + + private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException { + UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry(); + entry.setUserLogin(rs.getString("user_login")); + entry.setServerLogin(rs.getString("server_login")); + entry.setServerUrl(rs.getString("server_url")); + entry.setServerClientKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("server_client_key"))); + return entry; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserCreateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserCreateDAO.java deleted file mode 100644 index 8476f3a0..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserCreateDAO.java +++ /dev/null @@ -1,128 +0,0 @@ -package shine.db.dao; - -import shine.db.SqliteDbController; -import shine.db.entities.SolanaUserEntry; - -import java.sql.*; - -/** - * UserCreateDAO — атомарное добавление пользователя: - * - solana_users (login, blockchain_name, solana_key, blockchain_key, client_key) - * - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...) - * - * ВАЖНО: - * - только INSERT (без перезаписи существующих записей) - * - если login или blockchainName заняты — возвращаем false (пользователь уже есть/занято) - */ -public final class UserCreateDAO { - - private static volatile UserCreateDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); - private final SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance(); - - private UserCreateDAO() {} - - public static UserCreateDAO getInstance() { - if (instance == null) { - synchronized (UserCreateDAO.class) { - if (instance == null) instance = new UserCreateDAO(); - } - } - return instance; - } - - /** - * @return true если добавили; false если занято (login уже есть или blockchainName уже существует). - */ - public boolean insertUserWithBlockchain( - String login, - String blockchainName, - String solanaKey, - String blockchainKey, - String clientKey, - long sizeLimit, - long nowMs - ) throws SQLException { - - try (Connection c = db.getConnection()) { - boolean oldAuto = c.getAutoCommit(); - c.setAutoCommit(false); - - try { - // 1) solana_users - SolanaUserEntry u = new SolanaUserEntry(); - u.setLogin(login); - u.setBlockchainName(blockchainName); - u.setSolanaKey(solanaKey); - u.setBlockchainKey(blockchainKey); - u.setClientKey(clientKey); - - usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint - - // 2) blockchain_state — строго INSERT, без UPSERT (иначе можно перезаписать существующую цепочку) - insertBlockchainStateStrict( - c, - blockchainName, - login, - blockchainKey, - sizeLimit, - nowMs - ); - - c.commit(); - return true; - - } catch (SQLException e) { - c.rollback(); - - String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(); - if (msg.contains("constraint")) { - return false; - } - throw e; - - } finally { - c.setAutoCommit(oldAuto); - } - } - } - - private static void insertBlockchainStateStrict( - Connection c, - String blockchainName, - String login, - String blockchainKey, - long sizeLimit, - long nowMs - ) throws SQLException { - - String sql = """ - INSERT INTO blockchain_state ( - blockchain_name, - login, - blockchain_key, - size_limit, - file_size_bytes, - last_block_number, - last_block_hash, - updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - int i = 1; - ps.setString(i++, blockchainName); - ps.setString(i++, login); - ps.setString(i++, blockchainKey); - - ps.setLong(i++, sizeLimit); - ps.setLong(i++, 0L); - - ps.setInt(i++, -1); - ps.setNull(i++, Types.BLOB); // старт: блоков ещё нет - ps.setLong(i++, nowMs); - - ps.executeUpdate(); // если blockchainName занят -> constraint (PK) - } - } -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java index fe20aeb4..9166759b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.UserParamEntry; import java.sql.*; @@ -21,7 +21,7 @@ import java.util.List; public final class UserParamsDAO { private static volatile UserParamsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private UserParamsDAO() { } @@ -89,7 +89,7 @@ public final class UserParamsDAO { client_key, signature FROM users_params - WHERE login = ? COLLATE NOCASE AND param = ? + WHERE LOWER(login) = LOWER(?) AND param = ? LIMIT 1 """; @@ -120,7 +120,7 @@ public final class UserParamsDAO { client_key, signature FROM users_params - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) ORDER BY time_ms DESC """; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/CurrentUserEntry.java similarity index 88% rename from SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserEntry.java rename to SHiNE-server/shine-server-db/src/main/java/shine/db/entities/CurrentUserEntry.java index 7552faf1..8a14051b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/CurrentUserEntry.java @@ -3,18 +3,19 @@ package shine.db.entities; import java.util.Base64; /** - * SolanaUserEntry — локальная запись пользователя из Solana. + * CurrentUserEntry — локальная runtime-проекция текущего состояния пользователя. * - * Таблица: solana_users + * Источник: + * - solana_user_pda_current * * Поля: - * - login — PRIMARY KEY (TEXT) (case-insensitive на уровне COLLATE NOCASE) + * - login — PRIMARY KEY (TEXT) * - blockchain_name — TEXT NOT NULL * - solana_key — TEXT NOT NULL * - blockchain_key — TEXT NOT NULL * - client_key — TEXT NOT NULL */ -public class SolanaUserEntry { +public class CurrentUserEntry { private String login; @@ -29,9 +30,9 @@ public class SolanaUserEntry { /** Ключ устройства (публичный ключ устройства) */ private String clientKey; - public SolanaUserEntry() {} + public CurrentUserEntry() {} - public SolanaUserEntry(String login, + public CurrentUserEntry(String login, String blockchainName, String solanaKey, String blockchainKey, @@ -81,4 +82,4 @@ public class SolanaUserEntry { return null; } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmSyncPeerStateEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmSyncPeerStateEntry.java new file mode 100644 index 00000000..7730e349 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmSyncPeerStateEntry.java @@ -0,0 +1,32 @@ +package shine.db.entities; + +public class DmSyncPeerStateEntry { + private String ownerLogin; + private String remoteServerLogin; + private String remoteServerUrl; + private long cursorStoredAtMs; + private String cursorMessageKey; + private boolean bootstrapCompleted; + private Long lastSyncAtMs; + private String lastError; + private long updatedAtMs; + + public String getOwnerLogin() { return ownerLogin; } + public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; } + public String getRemoteServerLogin() { return remoteServerLogin; } + public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; } + public String getRemoteServerUrl() { return remoteServerUrl; } + public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; } + public long getCursorStoredAtMs() { return cursorStoredAtMs; } + public void setCursorStoredAtMs(long cursorStoredAtMs) { this.cursorStoredAtMs = cursorStoredAtMs; } + public String getCursorMessageKey() { return cursorMessageKey; } + public void setCursorMessageKey(String cursorMessageKey) { this.cursorMessageKey = cursorMessageKey; } + public boolean isBootstrapCompleted() { return bootstrapCompleted; } + public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; } + public Long getLastSyncAtMs() { return lastSyncAtMs; } + public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; } + public String getLastError() { return lastError; } + public void setLastError(String lastError) { this.lastError = lastError; } + public long getUpdatedAtMs() { return updatedAtMs; } + public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageEntry.java similarity index 98% rename from SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java rename to SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageEntry.java index d19f2d63..207fdb7c 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageEntry.java @@ -1,6 +1,6 @@ package shine.db.entities; -public class SignedMessageV2Entry { +public class SignedMessageEntry { private String messageKey; private String baseKey; private String targetLogin; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserPdaCurrentEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserPdaCurrentEntry.java new file mode 100644 index 00000000..33fcce29 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserPdaCurrentEntry.java @@ -0,0 +1,46 @@ +package shine.db.entities; + +/** + * Минимальный срез текущей записи пользователя из PostgreSQL sync-таблицы. + * + * Источник: solana_user_pda_current + */ +public final class SolanaUserPdaCurrentEntry { + + private String login; + private String blockchainName; + private String blockchainKey; + private long paidLimitBytes; + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getBlockchainName() { + return blockchainName; + } + + public void setBlockchainName(String blockchainName) { + this.blockchainName = blockchainName; + } + + public String getBlockchainKey() { + return blockchainKey; + } + + public void setBlockchainKey(String blockchainKey) { + this.blockchainKey = blockchainKey; + } + + public long getPaidLimitBytes() { + return paidLimitBytes; + } + + public void setPaidLimitBytes(long paidLimitBytes) { + this.paidLimitBytes = paidLimitBytes; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserAccessServerRouteEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserAccessServerRouteEntry.java new file mode 100644 index 00000000..ac4f042d --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserAccessServerRouteEntry.java @@ -0,0 +1,27 @@ +package shine.db.entities; + +/** + * Локальная routing-проекция access server пользователя. + * + * Источник: + * - user_access_servers_current + */ +public class UserAccessServerRouteEntry { + + private String userLogin; + private String serverLogin; + private String serverUrl; + private String serverClientKey; + + public String getUserLogin() { return userLogin; } + public void setUserLogin(String userLogin) { this.userLogin = userLogin; } + + public String getServerLogin() { return serverLogin; } + public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; } + + public String getServerUrl() { return serverUrl; } + public void setServerUrl(String serverUrl) { this.serverUrl = serverUrl; } + + public String getServerClientKey() { return serverClientKey; } + public void setServerClientKey(String serverClientKey) { this.serverClientKey = serverClientKey; } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/sql/CurrentUsersSql.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/sql/CurrentUsersSql.java new file mode 100644 index 00000000..837e5aec --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/sql/CurrentUsersSql.java @@ -0,0 +1,24 @@ +package shine.db.sql; + +public final class CurrentUsersSql { + + private CurrentUsersSql() {} + + public static String usersSubquery(String alias) { + if (alias == null || alias.isBlank()) { + throw new IllegalArgumentException("alias is blank"); + } + return """ + ( + SELECT + current_users.login AS login, + current_users.blockchain_name AS blockchain_name, + current_users.client_key AS solana_key, + current_users.blockchain_key AS blockchain_key, + current_users.client_key AS client_key, + current_users.is_server AS is_server + FROM solana_user_pda_current current_users + ) %s + """.formatted(alias); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v2.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v2.sql new file mode 100644 index 00000000..172ea9f3 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v2.sql @@ -0,0 +1,178 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS user_access_servers_current ( + user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, + server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, + server_url TEXT NOT NULL, + server_client_key TEXT NOT NULL, + user_record_number INTEGER NOT NULL, + user_updated_at_ms BIGINT NOT NULL, + server_record_number INTEGER NOT NULL, + server_updated_at_ms BIGINT NOT NULL, + refreshed_at_ms BIGINT NOT NULL, + PRIMARY KEY (user_login, server_login) +); + +CREATE INDEX IF NOT EXISTS idx_user_access_servers_user + ON user_access_servers_current(user_login); + +CREATE INDEX IF NOT EXISTS idx_user_access_servers_server + ON user_access_servers_current(server_login); + +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 jsonb_array_elements_text( + CASE + WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb + ELSE u.access_servers_json::jsonb + END + ) AS access_server(login_value) + 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 jsonb_array_elements_text( + CASE + WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb + ELSE u.access_servers_json::jsonb + END + ) AS access_server(login_value) + 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; +$$; + +CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_all() +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + affected_user RECORD; +BEGIN + TRUNCATE TABLE user_access_servers_current; + + FOR affected_user IN + SELECT login + FROM solana_user_pda_current + LOOP + PERFORM shine_refresh_user_access_servers_for_user(affected_user.login); + END LOOP; +END; +$$; + +CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_row() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + PERFORM shine_refresh_user_access_servers_for_user(OLD.login); + PERFORM shine_refresh_user_access_servers_for_server(OLD.login); + RETURN OLD; + END IF; + + IF TG_OP = 'UPDATE' AND LOWER(OLD.login) <> LOWER(NEW.login) THEN + PERFORM shine_refresh_user_access_servers_for_user(OLD.login); + PERFORM shine_refresh_user_access_servers_for_server(OLD.login); + END IF; + + PERFORM shine_refresh_user_access_servers_for_user(NEW.login); + PERFORM shine_refresh_user_access_servers_for_server(NEW.login); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + TRUNCATE TABLE user_access_servers_current; + RETURN NULL; +END; +$$; + +DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_row ON solana_user_pda_current; +CREATE TRIGGER trg_refresh_user_access_servers_row +AFTER INSERT OR UPDATE OR DELETE ON solana_user_pda_current +FOR EACH ROW +EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_row(); + +DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_truncate ON solana_user_pda_current; +CREATE TRIGGER trg_refresh_user_access_servers_truncate +AFTER TRUNCATE ON solana_user_pda_current +FOR EACH STATEMENT +EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate(); + +SELECT shine_refresh_user_access_servers_all(); + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 2, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +ON CONFLICT (id) DO UPDATE SET + schema_version = EXCLUDED.schema_version, + updated_at_ms = EXCLUDED.updated_at_ms; + +COMMIT; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v3.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v3.sql new file mode 100644 index 00000000..97f55e62 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v3.sql @@ -0,0 +1,47 @@ +BEGIN; + +CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor + ON signed_messages(target_login, created_at_ms, message_key); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower + ON signed_messages(LOWER(target_login), created_at_ms, message_key); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor + ON signed_messages(from_login, created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor + ON signed_messages(to_login, created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower + ON signed_messages(LOWER(from_login), created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower + ON signed_messages(LOWER(to_login), created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +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); + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +ON CONFLICT (id) DO UPDATE SET + schema_version = EXCLUDED.schema_version, + updated_at_ms = EXCLUDED.updated_at_ms; + +COMMIT; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql new file mode 100644 index 00000000..364d5d4c --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -0,0 +1,1091 @@ +-- SHiNE PostgreSQL runtime schema v1 +-- Дата: 2026-07-28 +-- +-- Назначение: +-- - поднять пустую PostgreSQL БД сервера SHiNE с нуля; +-- - включить таблицы модуля синхронизации Solana users; +-- - включить runtime-таблицы сервера без legacy-таблиц старого runtime; +-- - основной runtime DM storage = signed_messages +-- +-- Важно: +-- - источник истины по пользователям: solana_user_pda_current; +-- - runtime table blockchain_state остаётся как локальное серверное состояние chain, +-- но не мигрируется из старого локального runtime и не считается identity-слоем; +-- - триггеры переписаны под PostgreSQL и сохраняют текущую серверную логику. + +BEGIN; + +CREATE TABLE IF NOT EXISTS db_schema_version ( + id INTEGER PRIMARY KEY, + schema_version INTEGER NOT NULL, + updated_at_ms BIGINT NOT NULL +); + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 3, 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; + +CREATE TABLE IF NOT EXISTS solana_sync_state ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + ready BOOLEAN NOT NULL, + last_poll_at_ms BIGINT, + last_successful_poll_at_ms BIGINT, + last_seen_signature TEXT, + last_seen_slot BIGINT, + last_relevant_signature TEXT, + last_relevant_slot BIGINT, + last_error TEXT, + economy_config_version INTEGER, + registration_fee_lamports BIGINT, + lamports_per_limit_step BIGINT, + start_bonus_limit BIGINT, + updated_at_ms BIGINT +); + +INSERT INTO solana_sync_state (id, status, ready) +VALUES (1, 'EMPTY', FALSE) +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS solana_sync_tx_history ( + id BIGSERIAL PRIMARY KEY, + signature TEXT NOT NULL UNIQUE, + slot BIGINT NOT NULL, + block_time BIGINT, + tx_kind TEXT NOT NULL, + is_relevant BOOLEAN NOT NULL, + affected_pda_address TEXT, + affected_login TEXT, + raw_summary_json TEXT NOT NULL, + processed_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sync_tx_history_slot + ON solana_sync_tx_history(slot); + +CREATE INDEX IF NOT EXISTS idx_sync_tx_history_relevant + ON solana_sync_tx_history(is_relevant); + +CREATE INDEX IF NOT EXISTS idx_sync_tx_history_login + ON solana_sync_tx_history(affected_login); + +CREATE TABLE IF NOT EXISTS solana_user_pda_current ( + pda_address TEXT PRIMARY KEY, + login TEXT NOT NULL UNIQUE, + record_number INTEGER NOT NULL, + slot BIGINT NOT NULL, + last_tx_signature TEXT NOT NULL, + recovery_key TEXT NOT NULL, + root_key TEXT NOT NULL, + client_key TEXT NOT NULL, + blockchain_name TEXT NOT NULL, + blockchain_key TEXT NOT NULL, + paid_limit_bytes BIGINT NOT NULL, + used_bytes BIGINT NOT NULL, + last_block_number INTEGER NOT NULL, + last_block_hash TEXT NOT NULL, + last_block_signature TEXT NOT NULL, + arweave_tx_id TEXT NOT NULL, + is_server BOOLEAN NOT NULL, + address_format_type INTEGER NOT NULL, + address_format_version INTEGER NOT NULL, + server_address TEXT NOT NULL, + sync_servers_json TEXT NOT NULL, + access_servers_json TEXT NOT NULL, + sessions_mode INTEGER NOT NULL, + sessions_json TEXT NOT NULL, + trusted_count INTEGER NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + prev_record_hash TEXT NOT NULL, + record_signature TEXT NOT NULL, + raw_data_base64 TEXT NOT NULL, + first_seen_at_ms BIGINT NOT NULL, + last_synced_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot + ON solana_user_pda_current(slot); + +CREATE TABLE IF NOT EXISTS user_access_servers_current ( + user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, + server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, + server_url TEXT NOT NULL, + server_client_key TEXT NOT NULL, + user_record_number INTEGER NOT NULL, + user_updated_at_ms BIGINT NOT NULL, + server_record_number INTEGER NOT NULL, + server_updated_at_ms BIGINT NOT NULL, + refreshed_at_ms BIGINT NOT NULL, + PRIMARY KEY (user_login, server_login) +); + +CREATE INDEX IF NOT EXISTS idx_user_access_servers_user + ON user_access_servers_current(user_login); + +CREATE INDEX IF NOT EXISTS idx_user_access_servers_server + ON user_access_servers_current(server_login); + +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 jsonb_array_elements_text( + CASE + WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb + ELSE u.access_servers_json::jsonb + END + ) AS access_server(login_value) + 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 jsonb_array_elements_text( + CASE + WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb + ELSE u.access_servers_json::jsonb + END + ) AS access_server(login_value) + 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; +$$; + +CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_all() +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + affected_user RECORD; +BEGIN + TRUNCATE TABLE user_access_servers_current; + + FOR affected_user IN + SELECT login + FROM solana_user_pda_current + LOOP + PERFORM shine_refresh_user_access_servers_for_user(affected_user.login); + END LOOP; +END; +$$; + +CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_row() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + PERFORM shine_refresh_user_access_servers_for_user(OLD.login); + PERFORM shine_refresh_user_access_servers_for_server(OLD.login); + RETURN OLD; + END IF; + + IF TG_OP = 'UPDATE' AND LOWER(OLD.login) <> LOWER(NEW.login) THEN + PERFORM shine_refresh_user_access_servers_for_user(OLD.login); + PERFORM shine_refresh_user_access_servers_for_server(OLD.login); + END IF; + + PERFORM shine_refresh_user_access_servers_for_user(NEW.login); + PERFORM shine_refresh_user_access_servers_for_server(NEW.login); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + TRUNCATE TABLE user_access_servers_current; + RETURN NULL; +END; +$$; + +DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_row ON solana_user_pda_current; +CREATE TRIGGER trg_refresh_user_access_servers_row +AFTER INSERT OR UPDATE OR DELETE ON solana_user_pda_current +FOR EACH ROW +EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_row(); + +DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_truncate ON solana_user_pda_current; +CREATE TRIGGER trg_refresh_user_access_servers_truncate +AFTER TRUNCATE ON solana_user_pda_current +FOR EACH STATEMENT +EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate(); + +CREATE TABLE IF NOT EXISTS solana_user_pda_history ( + id BIGSERIAL PRIMARY KEY, + tx_signature TEXT NOT NULL, + slot BIGINT NOT NULL, + block_time BIGINT, + pda_address TEXT NOT NULL, + login TEXT NOT NULL, + record_number INTEGER NOT NULL, + recovery_key TEXT NOT NULL, + root_key TEXT NOT NULL, + client_key TEXT NOT NULL, + blockchain_name TEXT NOT NULL, + blockchain_key TEXT NOT NULL, + paid_limit_bytes BIGINT NOT NULL, + used_bytes BIGINT NOT NULL, + last_block_number INTEGER NOT NULL, + last_block_hash TEXT NOT NULL, + last_block_signature TEXT NOT NULL, + arweave_tx_id TEXT NOT NULL, + is_server BOOLEAN NOT NULL, + address_format_type INTEGER NOT NULL, + address_format_version INTEGER NOT NULL, + server_address TEXT NOT NULL, + sync_servers_json TEXT NOT NULL, + access_servers_json TEXT NOT NULL, + sessions_mode INTEGER NOT NULL, + sessions_json TEXT NOT NULL, + trusted_count INTEGER NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + prev_record_hash TEXT NOT NULL, + record_signature TEXT NOT NULL, + raw_data_base64 TEXT NOT NULL, + saved_at_ms BIGINT NOT NULL, + UNIQUE (pda_address, record_number) +); + +CREATE INDEX IF NOT EXISTS idx_user_pda_history_login + ON solana_user_pda_history(login); + +CREATE INDEX IF NOT EXISTS idx_user_pda_history_slot + ON solana_user_pda_history(slot); + +CREATE TABLE IF NOT EXISTS active_sessions ( + session_id TEXT PRIMARY KEY, + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + session_key TEXT NOT NULL, + storage_pwd TEXT NOT NULL, + session_created_at_ms BIGINT NOT NULL, + last_authirificated_at_ms BIGINT NOT NULL, + push_endpoint TEXT, + push_p256dh_key TEXT, + push_auth_key TEXT, + client_ip TEXT, + client_info_from_client TEXT, + client_info_from_request TEXT, + session_type INTEGER NOT NULL DEFAULT 1, + client_platform TEXT NOT NULL DEFAULT '', + user_language TEXT +); + +CREATE INDEX IF NOT EXISTS idx_active_sessions_login + ON active_sessions(login); + +CREATE TABLE IF NOT EXISTS esp_pairing_settings ( + login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login), + enabled INTEGER NOT NULL DEFAULT 0, + password_hash TEXT NOT NULL DEFAULT '', + ttl_seconds INTEGER NOT NULL DEFAULT 300, + failed_attempts INTEGER NOT NULL DEFAULT 0, + first_failed_at_ms BIGINT NOT NULL DEFAULT 0, + blocked_until_ms BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS esp_pairing_requests ( + pairing_id TEXT PRIMARY KEY, + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + requester_session_key TEXT NOT NULL, + requester_session_type INTEGER NOT NULL DEFAULT 1, + requester_client_platform TEXT NOT NULL DEFAULT '', + payload_type INTEGER NOT NULL, + status TEXT NOT NULL, + short_code TEXT NOT NULL, + fingerprint_b58 TEXT NOT NULL, + encrypted_payload TEXT, + reject_reason TEXT, + approved_by_session_id TEXT, + created_at_ms BIGINT NOT NULL, + expires_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + delivered_to_homeserver INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status + ON esp_pairing_requests(login, status, expires_at_ms); + +CREATE TABLE IF NOT EXISTS users_params ( + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + param TEXT NOT NULL, + time_ms BIGINT NOT NULL, + value TEXT NOT NULL, + client_key TEXT, + signature TEXT, + UNIQUE (login, param) +); + +CREATE INDEX IF NOT EXISTS idx_users_params_login + ON users_params(login); + +CREATE TABLE IF NOT EXISTS ip_geo_cache ( + ip TEXT PRIMARY KEY, + geo TEXT, + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_ip_geo_cache_updated_at + ON ip_geo_cache(updated_at_ms); + +CREATE TABLE IF NOT EXISTS test_free_avatar_uploads ( + login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login), + used_count INTEGER NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL, + last_tx_id TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated + ON test_free_avatar_uploads(updated_at_ms); + +CREATE TABLE IF NOT EXISTS sync_servers ( + login TEXT PRIMARY KEY, + server_address TEXT NOT NULL DEFAULT '', + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sync_servers_updated + ON sync_servers(updated_at_ms); + +CREATE TABLE IF NOT EXISTS blockchain_state ( + blockchain_name TEXT PRIMARY KEY, + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + blockchain_key TEXT NOT NULL, + size_limit BIGINT NOT NULL, + file_size_bytes BIGINT NOT NULL, + last_block_number INTEGER NOT NULL, + last_block_hash BYTEA, + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_blockchain_state_login + ON blockchain_state(login); + +CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at + ON blockchain_state(updated_at_ms); + +CREATE TABLE IF NOT EXISTS blocks ( + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + bch_name TEXT NOT NULL REFERENCES blockchain_state(blockchain_name), + block_number INTEGER NOT NULL CHECK (block_number >= 0), + msg_type INTEGER NOT NULL, + msg_sub_type INTEGER NOT NULL, + block_bytes BYTEA NOT NULL, + to_login TEXT, + to_bch_name TEXT, + to_block_number INTEGER CHECK (to_block_number IS NULL OR to_block_number >= 0), + to_block_hash BYTEA, + block_hash BYTEA NOT NULL, + block_signature BYTEA NOT NULL, + edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0), + line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0), + prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0), + prev_line_hash BYTEA, + this_line_number INTEGER CHECK (this_line_number IS NULL OR this_line_number >= 0), + UNIQUE (bch_name, block_number) +); + +CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number + ON blocks (bch_name, block_number); + +CREATE INDEX IF NOT EXISTS idx_blocks_to_target + ON blocks (to_login, to_bch_name, to_block_number); + +CREATE INDEX IF NOT EXISTS idx_blocks_by_line + ON blocks (bch_name, line_code, this_line_number); + +CREATE TABLE IF NOT EXISTS connections_state ( + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + rel_type INTEGER NOT NULL, + to_login TEXT NOT NULL, + to_bch_name TEXT NOT NULL, + to_block_number INTEGER NOT NULL, + to_block_hash BYTEA NOT NULL, + UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) +); + +CREATE INDEX IF NOT EXISTS idx_connections_state_login + ON connections_state(login); + +CREATE INDEX IF NOT EXISTS idx_connections_state_to_login + ON connections_state(to_login); + +CREATE INDEX IF NOT EXISTS idx_connections_state_pair + ON connections_state(login, to_login); + +CREATE INDEX IF NOT EXISTS idx_connections_state_target + ON connections_state(login, rel_type, to_bch_name, to_block_number); + +CREATE TABLE IF NOT EXISTS message_stats ( + to_login TEXT NOT NULL, + to_bch_name TEXT NOT NULL, + to_block_number INTEGER NOT NULL, + to_block_hash BYTEA NOT NULL, + likes_count INTEGER NOT NULL DEFAULT 0, + replies_count INTEGER NOT NULL DEFAULT 0, + edits_count INTEGER NOT NULL DEFAULT 0, + UNIQUE (to_login, to_bch_name, to_block_number, to_block_hash) +); + +CREATE INDEX IF NOT EXISTS idx_message_stats_target + ON message_stats (to_bch_name, to_block_number, to_block_hash); + +CREATE INDEX IF NOT EXISTS idx_message_stats_login + ON message_stats (to_login); + +CREATE TABLE IF NOT EXISTS reactions_state ( + from_login TEXT NOT NULL, + from_bch_name TEXT NOT NULL, + reaction_type INTEGER NOT NULL, + to_login TEXT NOT NULL, + to_bch_name TEXT NOT NULL, + to_block_number INTEGER NOT NULL, + to_block_hash BYTEA NOT NULL, + last_sub_type INTEGER NOT NULL, + UNIQUE ( + from_login, + from_bch_name, + reaction_type, + to_login, + to_bch_name, + to_block_number, + to_block_hash + ) +); + +CREATE INDEX IF NOT EXISTS idx_reactions_state_target + ON reactions_state (to_bch_name, to_block_number, to_block_hash); + +CREATE INDEX IF NOT EXISTS idx_reactions_state_actor + ON reactions_state (from_login, from_bch_name, reaction_type); + +CREATE TABLE IF NOT EXISTS channel_names_state ( + slug TEXT NOT NULL, + display_name TEXT NOT NULL, + channel_description TEXT NOT NULL DEFAULT '', + owner_login TEXT NOT NULL, + owner_bch_name TEXT NOT NULL, + channel_type_code INTEGER NOT NULL DEFAULT 1, + channel_type_version INTEGER NOT NULL DEFAULT 1, + channel_root_block_number INTEGER NOT NULL, + channel_root_block_hash BYTEA NOT NULL, + created_at_ms BIGINT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug + ON channel_names_state (owner_bch_name, channel_type_code, slug); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target + ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash); + +CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner + ON channel_names_state (owner_login, owner_bch_name); + +CREATE TABLE IF NOT EXISTS chat200_state ( + owner_login TEXT NOT NULL, + owner_bch_name TEXT NOT NULL, + channel_root_block_number INTEGER NOT NULL, + channel_root_block_hash BYTEA NOT NULL, + channel_name TEXT NOT NULL, + channel_type_version INTEGER NOT NULL, + chat_title TEXT NOT NULL DEFAULT '', + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (owner_bch_name, channel_root_block_number) +); + +CREATE INDEX IF NOT EXISTS idx_chat200_state_owner + ON chat200_state (owner_login, owner_bch_name); + +CREATE TABLE IF NOT EXISTS chat200_members_state ( + owner_bch_name TEXT NOT NULL, + channel_root_block_number INTEGER NOT NULL, + member_login TEXT NOT NULL, + member_channel_name TEXT NOT NULL, + is_active INTEGER NOT NULL, + updated_at_ms BIGINT NOT NULL, + updated_by_block_number INTEGER NOT NULL, + PRIMARY KEY ( + owner_bch_name, + channel_root_block_number, + member_login, + member_channel_name + ) +); + +CREATE INDEX IF NOT EXISTS idx_chat200_members_owner + ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active); + +CREATE TABLE IF NOT EXISTS user_push_tokens ( + token_id TEXT PRIMARY KEY, + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + session_id TEXT NOT NULL, + provider TEXT NOT NULL, + token TEXT NOT NULL, + platform TEXT, + user_agent TEXT, + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login + ON user_push_tokens(login); + +CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login_session + ON user_push_tokens(login, session_id); + +CREATE TABLE IF NOT EXISTS signed_direct_message_replay ( + from_login TEXT NOT NULL, + time_ms BIGINT NOT NULL, + nonce BIGINT NOT NULL, + created_at_ms BIGINT NOT NULL, + UNIQUE (from_login, time_ms, nonce) +); + +CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created + ON signed_direct_message_replay(created_at_ms); + +CREATE TABLE IF NOT EXISTS signed_direct_messages_history ( + message_id TEXT PRIMARY KEY, + from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + target_mode INTEGER NOT NULL, + target_session_id TEXT, + message_type INTEGER NOT NULL, + time_ms BIGINT NOT NULL, + nonce BIGINT NOT NULL, + raw_packet BYTEA NOT NULL, + created_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_signed_dm_history_to + ON signed_direct_messages_history(to_login, created_at_ms); + +CREATE TABLE IF NOT EXISTS signed_messages ( + message_key TEXT PRIMARY KEY, + base_key TEXT NOT NULL, + target_login TEXT NOT NULL, + from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + time_ms BIGINT NOT NULL, + nonce BIGINT NOT NULL, + message_type INTEGER NOT NULL, + revision_time_ms BIGINT NOT NULL DEFAULT 0, + reencrypted_at_ms BIGINT NOT NULL DEFAULT 0, + raw_block BYTEA NOT NULL, + created_at_ms BIGINT NOT NULL, + source_api TEXT NOT NULL, + origin_session_id TEXT, + receipt_ref_base_key TEXT, + receipt_ref_type INTEGER, + read_at_ms BIGINT +); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_target + ON signed_messages(target_login, time_ms, created_at_ms); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_base + ON signed_messages(base_key, message_type); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor + ON signed_messages(target_login, created_at_ms, message_key); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower + ON signed_messages(LOWER(target_login), created_at_ms, message_key); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor + ON signed_messages(from_login, created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor + ON signed_messages(to_login, created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower + ON signed_messages(LOWER(from_login), created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower + ON signed_messages(LOWER(to_login), created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_receipt_incoming + ON signed_messages(target_login, receipt_ref_base_key) + WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_receipt_outgoing + ON signed_messages(target_login, receipt_ref_base_key) + WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL; + +CREATE TABLE IF NOT EXISTS signed_message_session_delivery ( + message_key TEXT NOT NULL REFERENCES signed_messages(message_key), + session_id TEXT NOT NULL, + delivered INTEGER NOT NULL DEFAULT 0, + delivered_at_ms BIGINT, + created_at_ms BIGINT NOT NULL, + PRIMARY KEY (message_key, session_id) +); + +CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session + ON signed_message_session_delivery(session_id, delivered); + +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); + +CREATE TABLE IF NOT EXISTS message_views_state ( + viewer_login TEXT NOT NULL, + to_bch_name TEXT NOT NULL, + to_block_number INTEGER NOT NULL, + to_block_hash BYTEA NOT NULL, + first_seen_at_ms BIGINT NOT NULL, + PRIMARY KEY (viewer_login, to_bch_name, to_block_number, to_block_hash) +); + +CREATE INDEX IF NOT EXISTS idx_message_views_state_target + ON message_views_state(to_bch_name, to_block_number, to_block_hash); + +CREATE INDEX IF NOT EXISTS idx_message_views_state_viewer_channel + ON message_views_state(viewer_login, to_bch_name); + +CREATE OR REPLACE FUNCTION shine_login_from_blockchain_name(blockchain_name_in TEXT) +RETURNS TEXT +LANGUAGE plpgsql +AS $$ +DECLARE + dash_pos INTEGER; + suffix TEXT; +BEGIN + IF blockchain_name_in IS NULL OR length(blockchain_name_in) <= 4 THEN + RETURN NULL; + END IF; + + dash_pos := length(blockchain_name_in) - 3; + IF substr(blockchain_name_in, dash_pos, 1) <> '-' THEN + RETURN NULL; + END IF; + + suffix := substr(blockchain_name_in, dash_pos + 1); + IF suffix !~ '^[0-9]{3}$' THEN + RETURN NULL; + END IF; + + RETURN substr(blockchain_name_in, 1, dash_pos - 1); +END; +$$; + +CREATE OR REPLACE FUNCTION shine_resolve_login(target_login_in TEXT, target_bch_name_in TEXT) +RETURNS TEXT +LANGUAGE plpgsql +AS $$ +DECLARE + resolved_login TEXT; +BEGIN + IF target_login_in IS NOT NULL AND btrim(target_login_in) <> '' THEN + RETURN target_login_in; + END IF; + + IF target_bch_name_in IS NOT NULL AND btrim(target_bch_name_in) <> '' THEN + SELECT login + INTO resolved_login + FROM solana_user_pda_current + WHERE blockchain_name = target_bch_name_in + LIMIT 1; + + IF resolved_login IS NOT NULL AND btrim(resolved_login) <> '' THEN + RETURN resolved_login; + END IF; + END IF; + + RETURN shine_login_from_blockchain_name(target_bch_name_in); +END; +$$; + +CREATE OR REPLACE FUNCTION shine_blocks_line_integrity_bi() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + prev_block RECORD; +BEGIN + IF NEW.line_code IS NULL + AND NEW.prev_line_number IS NULL + AND NEW.prev_line_hash IS NULL + AND NEW.this_line_number IS NULL THEN + RETURN NEW; + END IF; + + IF NEW.msg_type NOT IN (0, 1, 3, 4) THEN + RAISE EXCEPTION 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE'; + END IF; + + IF NEW.line_code IS NULL + OR NEW.prev_line_number IS NULL + OR NEW.prev_line_hash IS NULL + OR NEW.this_line_number IS NULL THEN + RAISE EXCEPTION 'LINE_ERR_PARTIAL_FIELDS'; + END IF; + + SELECT * + INTO prev_block + FROM blocks + WHERE bch_name = NEW.bch_name + AND block_number = NEW.prev_line_number + LIMIT 1; + + IF NOT FOUND THEN + RAISE EXCEPTION 'LINE_ERR_NO_PREV'; + END IF; + + IF prev_block.block_hash IS DISTINCT FROM NEW.prev_line_hash THEN + RAISE EXCEPTION 'LINE_ERR_PREV_HASH_MISMATCH'; + END IF; + + IF NEW.prev_line_number <> NEW.line_code + AND prev_block.line_code IS DISTINCT FROM NEW.line_code THEN + RAISE EXCEPTION 'LINE_ERR_LINE_CODE_MISMATCH'; + END IF; + + IF NEW.prev_line_number = NEW.line_code THEN + IF NEW.this_line_number <> (CASE WHEN NEW.msg_type = 1 THEN 0 ELSE 1 END) THEN + RAISE EXCEPTION 'LINE_ERR_FIRST_STEP_BAD_THIS'; + END IF; + RETURN NEW; + END IF; + + IF prev_block.this_line_number IS NULL THEN + RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; + END IF; + + IF NEW.msg_type = 1 THEN + IF NEW.this_line_number <> prev_block.this_line_number + AND NEW.this_line_number <> prev_block.this_line_number + 1 THEN + RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; + END IF; + ELSE + IF NEW.this_line_number <> prev_block.this_line_number + 1 THEN + RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; + END IF; + END IF; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + resolved_login TEXT; + positive_rel_type INTEGER; +BEGIN + IF NEW.msg_type <> 3 THEN + RETURN NEW; + END IF; + + resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name); + + IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN + IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN + RETURN NEW; + END IF; + + DELETE FROM connections_state + WHERE login = NEW.login + AND rel_type = NEW.msg_sub_type + AND to_login = resolved_login; + + INSERT INTO connections_state ( + login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash + ) VALUES ( + NEW.login, + NEW.msg_sub_type, + resolved_login, + NEW.to_bch_name, + COALESCE(NEW.to_block_number, 0), + COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex')) + ); + + RETURN NEW; + END IF; + + positive_rel_type := CASE NEW.msg_sub_type + WHEN 11 THEN 10 + WHEN 21 THEN 20 + WHEN 31 THEN 30 + WHEN 41 THEN 40 + WHEN 51 THEN 50 + WHEN 53 THEN 52 + WHEN 55 THEN 54 + WHEN 61 THEN 60 + WHEN 71 THEN 70 + WHEN 75 THEN 74 + ELSE NULL + END; + + IF positive_rel_type IS NULL OR resolved_login IS NULL THEN + RETURN NEW; + END IF; + + DELETE FROM connections_state + WHERE login = NEW.login + AND rel_type = positive_rel_type + AND to_login = resolved_login; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + previous_sub_type INTEGER; +BEGIN + IF NEW.msg_type <> 2 OR NEW.msg_sub_type NOT IN (1, 2) THEN + RETURN NEW; + END IF; + + IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN + RETURN NEW; + END IF; + + INSERT INTO message_stats ( + to_login, to_bch_name, to_block_number, to_block_hash, + likes_count, replies_count, edits_count + ) VALUES ( + NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, + 0, 0, 0 + ) + ON CONFLICT (to_login, to_bch_name, to_block_number, to_block_hash) DO NOTHING; + + SELECT last_sub_type + INTO previous_sub_type + FROM reactions_state + WHERE from_login = NEW.login + AND from_bch_name = NEW.bch_name + AND reaction_type = 1 + AND to_login = NEW.to_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = NEW.to_block_number + AND to_block_hash = NEW.to_block_hash + LIMIT 1; + + IF NEW.msg_sub_type = 1 AND previous_sub_type IS DISTINCT FROM 1 THEN + UPDATE message_stats + SET likes_count = likes_count + 1 + WHERE to_login = NEW.to_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = NEW.to_block_number + AND to_block_hash = NEW.to_block_hash; + ELSIF NEW.msg_sub_type = 2 AND previous_sub_type = 1 THEN + UPDATE message_stats + SET likes_count = GREATEST(0, likes_count - 1) + WHERE to_login = NEW.to_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = NEW.to_block_number + AND to_block_hash = NEW.to_block_hash; + END IF; + + INSERT INTO reactions_state ( + from_login, from_bch_name, reaction_type, + to_login, to_bch_name, to_block_number, to_block_hash, + last_sub_type + ) VALUES ( + NEW.login, NEW.bch_name, 1, + NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, + NEW.msg_sub_type + ) + ON CONFLICT ( + from_login, from_bch_name, reaction_type, + to_login, to_bch_name, to_block_number, to_block_hash + ) DO UPDATE SET + last_sub_type = EXCLUDED.last_sub_type; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_blocks_message_stats_reply_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.msg_type <> 1 OR NEW.msg_sub_type <> 20 THEN + RETURN NEW; + END IF; + + IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN + RETURN NEW; + END IF; + + INSERT INTO message_stats ( + to_login, to_bch_name, to_block_number, to_block_hash, + likes_count, replies_count, edits_count + ) VALUES ( + NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, + 0, 0, 0 + ) + ON CONFLICT (to_login, to_bch_name, to_block_number, to_block_hash) DO NOTHING; + + UPDATE message_stats + SET replies_count = replies_count + 1 + WHERE to_login = NEW.to_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = NEW.to_block_number + AND to_block_hash = NEW.to_block_hash; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_blocks_edit_apply_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.msg_type <> 1 OR NEW.msg_sub_type NOT IN (11, 21) THEN + RETURN NEW; + END IF; + + UPDATE blocks + SET edited_by_block_number = NEW.block_number + WHERE login = NEW.login + AND bch_name = NEW.bch_name + AND block_number = NEW.to_block_number + AND NEW.to_block_number IS NOT NULL; + + IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN + RETURN NEW; + END IF; + + INSERT INTO message_stats ( + to_login, to_bch_name, to_block_number, to_block_hash, + likes_count, replies_count, edits_count + ) VALUES ( + NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, + 0, 0, 0 + ) + ON CONFLICT (to_login, to_bch_name, to_block_number, to_block_hash) DO NOTHING; + + UPDATE message_stats + SET edits_count = edits_count + 1 + WHERE to_login = NEW.to_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = NEW.to_block_number + AND to_block_hash = NEW.to_block_hash; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi ON blocks; +CREATE TRIGGER trg_blocks_line_integrity_bi +BEFORE INSERT ON blocks +FOR EACH ROW +EXECUTE FUNCTION shine_blocks_line_integrity_bi(); + +DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai ON blocks; +CREATE TRIGGER trg_blocks_connection_state_ai +AFTER INSERT ON blocks +FOR EACH ROW +EXECUTE FUNCTION shine_blocks_connection_state_ai(); + +DROP TRIGGER IF EXISTS trg_blocks_message_stats_like_ai ON blocks; +CREATE TRIGGER trg_blocks_message_stats_like_ai +AFTER INSERT ON blocks +FOR EACH ROW +EXECUTE FUNCTION shine_blocks_message_stats_like_ai(); + +DROP TRIGGER IF EXISTS trg_blocks_message_stats_reply_ai ON blocks; +CREATE TRIGGER trg_blocks_message_stats_reply_ai +AFTER INSERT ON blocks +FOR EACH ROW +EXECUTE FUNCTION shine_blocks_message_stats_reply_ai(); + +DROP TRIGGER IF EXISTS trg_blocks_edit_apply_ai ON blocks; +CREATE TRIGGER trg_blocks_edit_apply_ai +AFTER INSERT ON blocks +FOR EACH ROW +EXECUTE FUNCTION shine_blocks_edit_apply_ai(); + +COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java index 742c851e..4877f45f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java @@ -1,7 +1,7 @@ package server.logic.ws_protocol.JSON; import org.eclipse.jetty.websocket.api.Session; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import shine.db.entities.ActiveSessionEntry; /** @@ -25,8 +25,8 @@ public class ConnectionContext { public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge) public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь - // Полный пользователь из БД (solana_users) - private SolanaUserEntry solanaUserEntry; + // Полный пользователь из runtime БД (current users / solana_user_pda_current) + private CurrentUserEntry currentUserEntry; // Активная сессия из БД (active_sessions) private ActiveSessionEntry activeSessionEntry; @@ -89,12 +89,12 @@ public class ConnectionContext { // --- SolanaUser / ActiveSession --- - public SolanaUserEntry getSolanaUser() { - return solanaUserEntry; + public CurrentUserEntry getCurrentUser() { + return currentUserEntry; } - public void setSolanaUser(SolanaUserEntry solanaUserEntry) { - this.solanaUserEntry = solanaUserEntry; + public void setCurrentUser(CurrentUserEntry currentUserEntry) { + this.currentUserEntry = currentUserEntry; } public ActiveSessionEntry getActiveSession() { @@ -108,7 +108,7 @@ public class ConnectionContext { // --- Удобный геттер для логина --- public String getLogin() { - return solanaUserEntry != null ? solanaUserEntry.getLogin() : null; + return currentUserEntry != null ? currentUserEntry.getLogin() : null; } // --- sessionId --- @@ -176,7 +176,7 @@ public class ConnectionContext { } public void reset() { - solanaUserEntry = null; + currentUserEntry = null; activeSessionEntry = null; sessionId = null; @@ -198,4 +198,4 @@ public class ConnectionContext { ", authenticationStatus=" + authenticationStatus + '}'; } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 9d2121aa..787efd20 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -43,9 +43,6 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request; - import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestGetFreeAvatarQuota_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler; @@ -91,6 +88,7 @@ import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler; import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler; import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler; +import server.logic.ws_protocol.JSON.messages.Net_DmSyncBatch_Handler; import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler; import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler; @@ -103,6 +101,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request; @@ -142,7 +141,6 @@ public final class JsonHandlerRegistry { } private static final Map HANDLERS = Map.ofEntries( - Map.entry("AddUser", new Net_AddUser_Handler()), Map.entry("GetUser", new Net_GetUser_Handler()), Map.entry("SearchUsers", new Net_SearchUsers_Handler()), Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()), @@ -202,6 +200,7 @@ public final class JsonHandlerRegistry { Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()), Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()), Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()), + Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()), Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()), Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()), Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()), @@ -223,7 +222,6 @@ public final class JsonHandlerRegistry { ); private static final Map> REQUEST_TYPES = Map.ofEntries( - Map.entry("AddUser", Net_AddUser_Request.class), Map.entry("GetUser", Net_GetUser_Request.class), Map.entry("SearchUsers", Net_SearchUsers_Request.class), Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class), @@ -283,6 +281,7 @@ public final class JsonHandlerRegistry { Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class), Map.entry("DeleteMessage", Net_DeleteMessage_Request.class), Map.entry("DeleteConversation", Net_DeleteConversation_Request.class), + Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class), Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class), Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class), Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java index 3b956b8f..1a3aed35 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java @@ -9,8 +9,8 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Re import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; +import shine.db.dao.CurrentUsersDAO; +import shine.db.entities.CurrentUserEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,7 +26,7 @@ import java.security.SecureRandom; * * Что делает: * 1) Проверяет login. - * 2) Находит пользователя (solana_users). + * 2) Находит пользователя в текущем PDA snapshot. * 3) Пишет solanaUser в ctx, ставит AUTH_STATUS_AUTH_IN_PROGRESS. * 4) Генерирует authNonce (base64url(32)) и сохраняет в ctx.authNonce. */ @@ -60,33 +60,17 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler { ); } - SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login); - if (solanaUserEntry == null) { - try { - solanaUserEntry = SolanaUserPdaImportService.findOrImportByLogin(login); - if (solanaUserEntry != null) { - log.info("AuthChallenge: пользователь {} импортирован из Solana PDA", solanaUserEntry.getLogin()); - } - } catch (Exception e) { - log.error("AuthChallenge: ошибка lazy-import пользователя {} из Solana", login, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "SOLANA_IMPORT_FAILED", - "Ошибка проверки пользователя в Solana" - ); - } - } + CurrentUserEntry solanaUserEntry = CurrentUsersDAO.getInstance().getByLogin(login); if (solanaUserEntry == null) { return NetExceptionResponseFactory.error( - req, + req, WireCodes.Status.UNVERIFIED, "UNKNOWN_USER", "Пользователь с таким логином не найден" ); } - ctx.setSolanaUser(solanaUserEntry); + ctx.setCurrentUser(solanaUserEntry); ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS); byte[] buf = new byte[32]; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java index e5ceb22c..cd29954f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java @@ -18,7 +18,7 @@ import server.logic.ws_protocol.WireCodes; import server.ws.WsConnectionUtils; import shine.db.dao.ActiveSessionsDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import java.sql.SQLException; @@ -42,7 +42,7 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler { public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { Net_CloseActiveSession_Request req = (Net_CloseActiveSession_Request) baseReq; - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { + if (ctx == null || ctx.getCurrentUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { return NetExceptionResponseFactory.error( req, WireCodes.Status.UNVERIFIED, @@ -51,7 +51,7 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler { ); } - SolanaUserEntry user = ctx.getSolanaUser(); + CurrentUserEntry user = ctx.getCurrentUser(); String currentLogin = user.getLogin(); String targetSessionId = req.getSessionId(); @@ -152,4 +152,4 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler { ); } } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java index cebbfbde..4ca98505 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java @@ -15,9 +15,9 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import server.ws.WsConnectionUtils; import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import shine.geo.ClientInfoService; import shine.geo.GeoLookupService; import utils.crypto.Ed25519Util; @@ -58,7 +58,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { Net_CreateAuthSession_Request req = (Net_CreateAuthSession_Request) baseReq; if (ctx == null - || ctx.getSolanaUser() == null + || ctx.getCurrentUser() == null || ctx.getAuthNonce() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS) { @@ -72,7 +72,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { return err; } - SolanaUserEntry userFromContext = ctx.getSolanaUser(); + CurrentUserEntry userFromContext = ctx.getCurrentUser(); String loginFromContext = userFromContext.getLogin(); String loginFromReq = req.getLogin(); if (loginFromReq == null || loginFromReq.isBlank()) { @@ -97,9 +97,9 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { return err; } - SolanaUserEntry user; + CurrentUserEntry user; try { - user = SolanaUsersDAO.getInstance().getByLogin(loginFromContext); + user = CurrentUsersDAO.getInstance().getByLogin(loginFromContext); } catch (SQLException e) { Net_Response err = NetExceptionResponseFactory.error( req, diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java index e53a24db..8f62a4d7 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java @@ -14,7 +14,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.ActiveSessionsDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import shine.geo.GeoLookupService; import java.sql.SQLException; @@ -36,7 +36,7 @@ public class Net_ListSessions_Handler implements JsonMessageHandler { public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { Net_ListSessions_Request req = (Net_ListSessions_Request) baseReq; - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { + if (ctx == null || ctx.getCurrentUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { return NetExceptionResponseFactory.error( req, WireCodes.Status.UNVERIFIED, @@ -45,7 +45,7 @@ public class Net_ListSessions_Handler implements JsonMessageHandler { ); } - SolanaUserEntry user = ctx.getSolanaUser(); + CurrentUserEntry user = ctx.getCurrentUser(); String currentLogin = user.getLogin(); List sessions; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java index 6a4f51a1..c2dea576 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java @@ -14,9 +14,9 @@ import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import shine.geo.ClientInfoService; import shine.geo.GeoLookupService; import utils.crypto.Ed25519Util; @@ -188,9 +188,9 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { ctx.setSessionLoginNonceExpiresAtMs(0); // подтягиваем пользователя - SolanaUserEntry user; + CurrentUserEntry user; try { - user = SolanaUsersDAO.getInstance().getByLogin(session.getLogin()); + user = CurrentUsersDAO.getInstance().getByLogin(session.getLogin()); } catch (SQLException e) { return NetExceptionResponseFactory.error( req, @@ -294,7 +294,7 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { // ctx ctx.setActiveSession(session); - ctx.setSolanaUser(user); + ctx.setCurrentUser(user); ctx.setSessionId(sessionId); ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java index bf08799a..ed6254e8 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java @@ -15,10 +15,10 @@ import server.logic.ws_protocol.JSON.utils.NetIdGenerator; import server.logic.ws_protocol.WireCodes; import shine.db.dao.EspPairingRequestsDAO; import shine.db.dao.EspPairingSettingsDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.EspPairingRequestEntry; import shine.db.entities.EspPairingSettingsEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import java.util.List; @@ -60,7 +60,7 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PASSWORD_HASH_FORMAT", "passwordHash должен быть пустым или иметь формат sha256$<64 hex>"); } - SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login); + CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(login); if (user == null) { return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен"); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java index 60065c90..4f58614a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java @@ -4,9 +4,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import shine.db.dao.SolanaUsersDAO; -import shine.db.dao.UserCreateDAO; -import shine.db.entities.SolanaUserEntry; import utils.config.SolanaProgramsConfig; import java.net.URI; @@ -22,8 +19,7 @@ import java.util.Locale; import java.util.Map; /** - * Lazy-import пользователя из Solana PDA в локальную БД сервера. - * Используется при входе, если в solana_users нет записи по login. + * Чтение пользовательских и серверных PDA из Solana. */ public final class SolanaUserPdaImportService { @@ -34,34 +30,6 @@ public final class SolanaUserPdaImportService { private SolanaUserPdaImportService() {} - public static SolanaUserEntry findOrImportByLogin(String loginRaw) throws Exception { - String login = normalizeLogin(loginRaw); - if (login == null) return null; - - SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance(); - SolanaUserEntry existing = usersDao.getByLogin(login); - if (existing != null) return existing; - - ParsedSolanaUser parsed = fetchFromSolana(login); - if (parsed == null) return null; - - long now = System.currentTimeMillis(); - long sizeLimit = parsed.paidLimitBytes > 0 ? parsed.paidLimitBytes : 100_000L; - boolean inserted = UserCreateDAO.getInstance().insertUserWithBlockchain( - parsed.login, - parsed.blockchainName, - parsed.clientKeyB64, // в текущей модели solanaKey = clientKey - parsed.blockchainKeyB64, - parsed.clientKeyB64, - sizeLimit, - now - ); - if (!inserted) { - return usersDao.getByLogin(login); - } - return usersDao.getByLogin(login); - } - public static SessionTypeCheckResult checkSessionTypeAgainstPda(String loginRaw, String sessionKeyApi, int requestedSessionType) throws Exception { String login = normalizeLogin(loginRaw); if (login == null) return SessionTypeCheckResult.noRecord(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/AUTH_SESSION_NEW.md b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/AUTH_SESSION_NEW.md index 9e6ab857..de8c9143 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/AUTH_SESSION_NEW.md +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/AUTH_SESSION_NEW.md @@ -33,46 +33,10 @@ } } -1. Добавление пользователя (AddUser) +1. Пользователь уже существует в актуальном PDA snapshot -Назначение: создать локальную запись пользователя с двумя ключами — solanaKey и clientKey. - -📤 Запрос клиента -{ -"op": "AddUser", -"requestId": "req-1", -"payload": { -"login": "anya4", -"loginId": 100212, -"bchId": 4222, -"solanaKey": "BASE64_LOGIN_KEY", -"clientKey": "BASE64_DEVICE_KEY", -"bchLimit": 1000000 -} -} - -🖥 Действия сервера - -Проверяет корректность данных. - -Вставляет запись в таблицу: - -CREATE TABLE solana_users ( -login TEXT NOT NULL, -loginId INTEGER PRIMARY KEY, -bchId INTEGER NOT NULL, -solanaKey TEXT, -clientKey TEXT, -bchLimit INTEGER -); - -📥 Ответ -{ -"op": "AddUser", -"requestId": "req-1", -"status": 200, -"payload": { "ok": true } -} +Назначение: перед началом auth-flow сервер уже должен видеть пользователя +в актуальном `solana_user_pda_current`. 2. Шаг 1 — запрос временного пароля сессии AuthChallenge @@ -173,4 +137,4 @@ pushAuthKey TEXT } 📘 Итоговая схема создания сессии -AddUser → AuthChallenge → CreateAuthSession → Session Created +PDA snapshot ready → AuthChallenge → CreateAuthSession → Session Created diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 298dc245..2ffc971d 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -279,18 +279,19 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { ChannelNameStateEntry channelNameStateEntry = null; Chat200CreateSeed chat200CreateSeed = null; if (block.body instanceof CreateChannelBody createChannelBody) { + int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode); + int channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion); final String normalizedName; final String slug; try { - normalizedName = ChannelNameRules.requireValidDisplayNameForCreate(createChannelBody.channelName); + normalizedName = channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PERSONAL & 0xFFFF) + ? ChannelNameRules.requireValidDisplayNameForCreate(createChannelBody.channelName) + : ChannelNameRules.requireValidPublicDisplayNameForCreate(createChannelBody.channelName); slug = ChannelNameRules.toCanonicalSlug(normalizedName); } catch (IllegalArgumentException badName) { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_name", serverLastNum, serverLastHashHex); } - int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode); - int channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion); - try { if (channelNameStateDAO.existsByOwnerTypeAndSlug(blockchainName, channelTypeCode, slug)) { return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex); @@ -544,7 +545,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } private void upsertChat200StateFromCreate(Chat200CreateSeed seed) throws Exception { - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection(); + try (Connection c = shine.db.DbController.getInstance().getConnection(); PreparedStatement ps = c.prepareStatement(""" INSERT INTO chat200_state ( owner_login, owner_bch_name, channel_root_block_number, channel_root_block_hash, @@ -586,7 +587,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { long updatedAtMs = block.timestamp * 1000L; if ("desc".equals(cmd.command)) { - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection(); + try (Connection c = shine.db.DbController.getInstance().getConnection(); PreparedStatement ps = c.prepareStatement(""" UPDATE chat200_state SET chat_title = ?, updated_at_ms = ? @@ -606,7 +607,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { String memberChannel = cmd.arg2 == null ? "" : cmd.arg2.trim(); if (memberLogin.isBlank() || memberChannel.isBlank()) return; - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection(); + try (Connection c = shine.db.DbController.getInstance().getConnection(); PreparedStatement ps = c.prepareStatement(""" INSERT INTO chat200_members_state ( owner_bch_name, channel_root_block_number, member_login, member_channel_name, @@ -630,7 +631,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } private boolean isChat200Channel(String ownerBch, int rootBlockNumber) throws Exception { - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection(); + try (Connection c = shine.db.DbController.getInstance().getConnection(); PreparedStatement ps = c.prepareStatement(""" SELECT channel_type_code FROM channel_names_state diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java index 6a5ab99b..a5f22c1f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java @@ -68,7 +68,7 @@ public final class BlockchainWriter { prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes); boolean committed = false; - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { + try (Connection c = shine.db.DbController.getInstance().getConnection()) { c.setAutoCommit(false); try { // 1) insert block diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java index 4b61c9d6..4ff7d627 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java @@ -4,7 +4,7 @@ import blockchain.BchBlockEntry; import blockchain.body.CreateChannelBody; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.channels.ChannelNameRules; import shine.db.dao.ChannelNameStateDAO; import shine.db.entities.ChannelNameStateEntry; @@ -48,7 +48,7 @@ public final class ChannelNamesStateBootstrapper { ORDER BY bch_name, block_number """; - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { c.setAutoCommit(false); try { try (PreparedStatement ps = c.prepareStatement(sql)) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index b1e3fc80..1b7ca2df 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -8,6 +8,7 @@ import blockchain.body.TextLineBody; import blockchain.body.TextReplyBody; import shine.db.channels.ChannelNameRules; import shine.db.MsgSubType; +import shine.db.sql.CurrentUsersSql; import java.sql.Connection; import java.sql.PreparedStatement; @@ -30,7 +31,8 @@ final class ChannelsReadSupport { private ChannelsReadSupport() {} static String canonicalLogin(Connection c, String anyCaseLogin) throws SQLException { - String sql = "SELECT login FROM solana_users WHERE login = ? COLLATE NOCASE LIMIT 1"; + String sql = ("SELECT login FROM " + CurrentUsersSql.usersSubquery("su") + + " WHERE LOWER(login) = LOWER(?) LIMIT 1"); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, anyCaseLogin); try (ResultSet rs = ps.executeQuery()) { @@ -411,7 +413,7 @@ final class ChannelsReadSupport { String partnerBchSql = """ SELECT blockchain_name FROM blockchain_state - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) ORDER BY blockchain_name LIMIT 1 """; @@ -462,7 +464,7 @@ final class ChannelsReadSupport { String sql = """ SELECT msg_sub_type FROM blocks - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) AND msg_type = ? AND to_bch_name = ? AND to_block_number = ? diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index 2dacd60e..a4301417 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -10,7 +10,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMe import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.MsgSubType; import utils.blockchain.BlockchainNameUtil; import blockchain.body.CreateChannelBody; @@ -39,7 +39,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort()); - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { String viewerLogin = ctx != null ? ctx.getLogin() : null; if (viewerLogin == null || viewerLogin.isBlank()) { viewerLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelsCounters_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelsCounters_Handler.java index 9f775da6..59c25b57 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelsCounters_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelsCounters_Handler.java @@ -10,7 +10,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsC import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.MsgSubType; import java.sql.Connection; @@ -26,7 +26,7 @@ public class Net_GetChannelsCounters_Handler implements JsonMessageHandler { if (req.getLogin() == null || req.getLogin().isBlank()) { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login"); } - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { String canonicalLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin().trim()); if (canonicalLogin == null) { return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден"); @@ -52,7 +52,7 @@ public class Net_GetChannelsCounters_Handler implements JsonMessageHandler { String sql = """ SELECT COUNT(*) FROM connections_state - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) AND rel_type = ? """; try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -68,7 +68,7 @@ public class Net_GetChannelsCounters_Handler implements JsonMessageHandler { String sql = """ SELECT COUNT(*) FROM channel_names_state - WHERE owner_login = ? COLLATE NOCASE + WHERE LOWER(owner_login) = LOWER(?) AND channel_type_code = ? """; try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -81,7 +81,7 @@ public class Net_GetChannelsCounters_Handler implements JsonMessageHandler { } private int countMyChannels(Connection c, String login) throws Exception { - String bchCountSql = "SELECT COUNT(*) FROM blockchain_state WHERE login = ? COLLATE NOCASE"; + String bchCountSql = "SELECT COUNT(*) FROM blockchain_state WHERE LOWER(login) = LOWER(?)"; int stories = 0; try (PreparedStatement ps = c.prepareStatement(bchCountSql)) { ps.setString(1, login); @@ -92,7 +92,7 @@ public class Net_GetChannelsCounters_Handler implements JsonMessageHandler { String namedSql = """ SELECT COUNT(*) FROM channel_names_state - WHERE owner_login = ? COLLATE NOCASE + WHERE LOWER(owner_login) = LOWER(?) AND channel_type_code IN (1,100,200) """; int named = 0; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetGroupDialog_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetGroupDialog_Handler.java index c4e23f2a..c9855c61 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetGroupDialog_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetGroupDialog_Handler.java @@ -10,7 +10,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDial import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.channels.ChannelNameRules; import java.sql.Connection; @@ -33,7 +33,7 @@ public class Net_GetGroupDialog_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля group"); } - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { Net_GetGroupDialog_Response resp = new Net_GetGroupDialog_Response(); resp.setOp(req.getOp()); resp.setRequestId(req.getRequestId()); @@ -115,7 +115,7 @@ public class Net_GetGroupDialog_Handler implements JsonMessageHandler { String canonicalLogin = ChannelsReadSupport.canonicalLogin(c, ref.memberLogin); if (canonicalLogin == null || canonicalLogin.isBlank()) return null; - String bchSql = "SELECT blockchain_name FROM blockchain_state WHERE login = ? COLLATE NOCASE ORDER BY blockchain_name LIMIT 1"; + String bchSql = "SELECT blockchain_name FROM blockchain_state WHERE LOWER(login) = LOWER(?) ORDER BY blockchain_name LIMIT 1"; String memberBch = null; try (PreparedStatement ps = c.prepareStatement(bchSql)) { ps.setString(1, canonicalLogin); @@ -214,4 +214,3 @@ public class Net_GetGroupDialog_Handler implements JsonMessageHandler { String text; } } - diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java index 7ee1ee5e..243ea507 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java @@ -12,7 +12,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageTh import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.Connection; import java.sql.PreparedStatement; @@ -28,21 +28,21 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { Net_GetMessageThread_Request req = (Net_GetMessageThread_Request) baseRequest; if (req.getMessage() == null || req.getMessage().getBlockchainName() == null || req.getMessage().getBlockNumber() == null) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message"); + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message"); } int depthUp = req.getDepthUp() == null ? 20 : Math.max(0, req.getDepthUp()); int depthDown = req.getDepthDown() == null ? 2 : Math.max(0, req.getDepthDown()); int childLimit = req.getLimitChildrenPerNode() == null ? 50 : Math.max(1, req.getLimitChildrenPerNode()); - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { String viewerLogin = ctx != null ? ctx.getLogin() : null; if (viewerLogin == null || viewerLogin.isBlank()) { viewerLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin()); } - PostRow focusRow = findByNumber(c, req.getMessage().getBlockchainName(), req.getMessage().getBlockNumber()); + PostRow focusRow = findFocusRow(c, req.getMessage()); if (focusRow == null) { - return NetExceptionResponseFactory.error(req, 404, "message_not_found", "Сообщение РЅРµ найдено"); + return NetExceptionResponseFactory.error(req, 404, "message_not_found", "Сообщение не найдено"); } Net_GetMessageThread_Response resp = new Net_GetMessageThread_Response(); @@ -67,10 +67,26 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { return resp; } catch (Exception e) { log.error("GetMessageThread failed", e); - return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера"); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера"); } } + private PostRow findFocusRow(Connection c, Net_GetMessageThread_Request.MessageSelector selector) throws Exception { + String blockchainName = String.valueOf(selector.getBlockchainName() == null ? "" : selector.getBlockchainName()).trim(); + int blockNumber = selector.getBlockNumber(); + byte[] blockHash = parseOptionalHash(selector.getBlockHash()); + + if (blockHash != null) { + PostRow exact = findByNumberAndHash(c, blockchainName, blockNumber, blockHash); + if (exact != null) return exact; + } + + PostRow byNumber = findByNumber(c, blockchainName, blockNumber); + if (byNumber != null) return byNumber; + + return null; + } + private List loadChildren(Connection c, PostRow parent, int depthDown, int childLimit, String viewerLogin) throws Exception { if (depthDown <= 0) return List.of(); List replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit); @@ -123,6 +139,33 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { } } + private PostRow findByNumberAndHash(Connection c, String bchName, int blockNumber, byte[] blockHash) throws Exception { + String sql = """ + SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number + FROM blocks + WHERE bch_name=? AND block_number=? AND block_hash=? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, bchName); + ps.setInt(2, blockNumber); + ps.setBytes(3, blockHash); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? mapRow(rs) : null; + } + } + } + + private byte[] parseOptionalHash(String hex) { + String value = String.valueOf(hex == null ? "" : hex).trim(); + if (value.isEmpty()) return null; + try { + return ChannelsReadSupport.hexToBytes(value); + } catch (Exception ignored) { + return null; + } + } + private PostRow mapRow(ResultSet rs) throws Exception { PostRow row = new PostRow(); row.login = rs.getString("login"); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListGroupChats200_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListGroupChats200_Handler.java index 6b5af1c3..5de34d74 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListGroupChats200_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListGroupChats200_Handler.java @@ -10,7 +10,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupCha import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.Connection; import java.sql.PreparedStatement; @@ -27,7 +27,7 @@ public class Net_ListGroupChats200_Handler implements JsonMessageHandler { if (req.getLogin() == null || req.getLogin().isBlank()) { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login"); } - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { String canonicalLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin().trim()); if (canonicalLogin == null) { return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден"); @@ -59,7 +59,7 @@ public class Net_ListGroupChats200_Handler implements JsonMessageHandler { ) m ON m.owner_bch_name = s.owner_bch_name AND m.channel_root_block_number = s.channel_root_block_number - WHERE s.owner_login = ? COLLATE NOCASE + WHERE LOWER(s.owner_login) = LOWER(?) ORDER BY s.updated_at_ms DESC, s.channel_root_block_number DESC """; List out = new ArrayList<>(); @@ -83,4 +83,3 @@ public class Net_ListGroupChats200_Handler implements JsonMessageHandler { return out; } } - diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java index aaeb677c..23ac1763 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java @@ -11,7 +11,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscrip import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.Connection; import java.sql.PreparedStatement; @@ -29,7 +29,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login"); } - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { String canonicalLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin().trim()); if (canonicalLogin == null) { return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден"); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java index 3d96ee8a..a41b9811 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java @@ -11,7 +11,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_MarkChannelM import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.Connection; import java.sql.PreparedStatement; @@ -37,7 +37,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler { return ok; } - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { String viewerLogin = ctx != null ? ctx.getLogin() : null; if (viewerLogin == null || viewerLogin.isBlank()) { viewerLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin()); @@ -64,9 +64,10 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler { """.formatted(strictChannelMatch ? "AND line_code = ?" : ""); String insertSql = """ - INSERT OR IGNORE INTO message_views_state ( + INSERT INTO message_views_state ( viewer_login, to_bch_name, to_block_number, to_block_hash, first_seen_at_ms ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING """; int seenAccepted = 0; @@ -132,4 +133,3 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler { } } } - diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java index 9a7350ae..bbb03fc4 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java @@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseF import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; +import shine.db.sql.CurrentUsersSql; import java.sql.Connection; import java.sql.PreparedStatement; @@ -31,7 +32,7 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Нельзя добавить себя"); } - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { + try (Connection c = shine.db.DbController.getInstance().getConnection()) { String canonicalTo = findCanonicalLogin(c, toLogin); if (canonicalTo == null) { return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); @@ -42,8 +43,7 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler { } // Idempotent insert for close-friend relation. - // Using INSERT OR IGNORE avoids ON CONFLICT(column list) mismatches - // across DB instances with different UNIQUE schemas. + // Rely on PostgreSQL ON CONFLICT DO NOTHING. insertCloseFriendIgnoreDuplicate(c, from, canonicalTo, targetBch); Net_AddCloseFriend_Response resp = new Net_AddCloseFriend_Response(); @@ -58,7 +58,8 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler { } private String findCanonicalLogin(Connection c, String login) throws Exception { - String sql = "SELECT login FROM solana_users WHERE login = ? COLLATE NOCASE LIMIT 1"; + String sql = "SELECT login FROM " + CurrentUsersSql.usersSubquery("su") + + " WHERE LOWER(login) = LOWER(?) LIMIT 1"; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, login); try (ResultSet rs = ps.executeQuery()) { @@ -68,7 +69,7 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler { } private String findPrimaryBlockchain(Connection c, String login) throws Exception { - String sql = "SELECT blockchain_name FROM blockchain_state WHERE login = ? COLLATE NOCASE ORDER BY blockchain_name LIMIT 1"; + String sql = "SELECT blockchain_name FROM blockchain_state WHERE LOWER(login) = LOWER(?) ORDER BY blockchain_name LIMIT 1"; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, login); try (ResultSet rs = ps.executeQuery()) { @@ -82,10 +83,11 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler { String toLogin, String toBchName) throws Exception { String sql = """ - INSERT OR IGNORE INTO connections_state ( + INSERT INTO connections_state ( login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) DO NOTHING """; try (PreparedStatement ps = c.prepareStatement(sql)) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java index 1c6a4765..0306fac6 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java @@ -11,8 +11,9 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.dao.ConnectionsStateDAO; +import shine.db.sql.CurrentUsersSql; import java.sql.Connection; import java.sql.PreparedStatement; @@ -26,10 +27,7 @@ import java.util.List; * * ВАЖНО: * - login в запросе может быть любым регистром - * - в ответе возвращаем канонический регистр (как в solana_users.login) - * - * ПРИМЕЧАНИЕ: - * Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй SQL. + * - в ответе возвращаем канонический регистр логина. */ public class Net_GetFriendsLists_Handler implements JsonMessageHandler { @@ -51,12 +49,12 @@ public class Net_GetFriendsLists_Handler implements JsonMessageHandler { final String loginAnyCase = req.getLogin().trim(); try { - SqliteDbController db = SqliteDbController.getInstance(); + DbController db = DbController.getInstance(); ConnectionsStateDAO dao = ConnectionsStateDAO.getInstance(); try (Connection c = db.getConnection()) { - // 1) Канонизируем login через solana_users (NOCASE) + // 1) Канонизируем login через current users слой (NOCASE) String canonicalLogin = findCanonicalLogin(c, loginAnyCase); if (canonicalLogin == null) { return NetExceptionResponseFactory.error( @@ -99,10 +97,10 @@ public class Net_GetFriendsLists_Handler implements JsonMessageHandler { private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception { String sql = """ SELECT login - FROM solana_users - WHERE login = ? COLLATE NOCASE + FROM %s + WHERE LOWER(login) = LOWER(?) LIMIT 1 - """; + """.formatted(CurrentUsersSql.usersSubquery("su")); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, loginAnyCase); try (ResultSet rs = ps.executeQuery()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java index 36bca444..ea83a791 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java @@ -10,6 +10,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; import shine.db.dao.ConnectionsStateDAO; +import shine.db.sql.CurrentUsersSql; import java.sql.Connection; import java.sql.PreparedStatement; @@ -39,7 +40,7 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { } } - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { + try (Connection c = shine.db.DbController.getInstance().getConnection()) { String canonicalLogin = findCanonicalLogin(c, requestedLogin); if (canonicalLogin == null) { return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); @@ -113,7 +114,8 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { } private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception { - String sql = "SELECT login FROM solana_users WHERE login = ? COLLATE NOCASE LIMIT 1"; + String sql = "SELECT login FROM " + CurrentUsersSql.usersSubquery("su") + + " WHERE LOWER(login) = LOWER(?) LIMIT 1"; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, loginAnyCase); try (ResultSet rs = ps.executeQuery()) { @@ -168,19 +170,19 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { MAX(CASE WHEN up.param = 'official' THEN up.value END) AS official_value, MAX(CASE WHEN up.param = 'shine' THEN up.value END) AS shine_value, MAX(CASE WHEN up.param = 'ava' THEN up.value END) AS avatar_value - FROM solana_users su + FROM %s LEFT JOIN users_params up - ON up.login = su.login COLLATE NOCASE + ON LOWER(up.login) = LOWER(su.login) AND up.param IN ('gender', 'official', 'shine', 'ava') - WHERE su.login COLLATE NOCASE IN (%s) + WHERE LOWER(su.login) IN (%s) GROUP BY su.login ORDER BY su.login - """.formatted(String.join(", ", placeholders)); + """.formatted(CurrentUsersSql.usersSubquery("su"), String.join(", ", placeholders)); try (PreparedStatement ps = c.prepareStatement(sql)) { int i = 1; for (String login : logins) { - ps.setString(i, login); + ps.setString(i, normKey(login)); i += 1; } try (ResultSet rs = ps.executeQuery()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java index 7275ed79..92dcd2df 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java @@ -22,7 +22,7 @@ public class Net_ListContacts_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); } - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { + try (Connection c = shine.db.DbController.getInstance().getConnection()) { List contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, ctx.getLogin(), MsgSubType.CONNECTION_CONTACT); Net_ListContacts_Response resp = new Net_ListContacts_Response(); resp.setOp(req.getOp()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetSyncUserProfile_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetSyncUserProfile_Handler.java index 9bf9c582..e7137593 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetSyncUserProfile_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetSyncUserProfile_Handler.java @@ -11,19 +11,19 @@ import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserPro import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; /** * GetSyncUserProfile — server-to-server профиль пользователя для межсерверной синхронизации. - * Нужен, чтобы принимающий сервер мог создать локальные solana_users + blockchain_state - * без прямого запроса в Solana RPC. + * Нужен, чтобы принимающий сервер мог создать локальную runtime-проекцию пользователя + * и blockchain_state без прямого запроса в Solana RPC. */ public final class Net_GetSyncUserProfile_Handler implements JsonMessageHandler { private static final Logger log = LoggerFactory.getLogger(Net_GetSyncUserProfile_Handler.class); - private final SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); + private final CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance(); private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); @Override @@ -41,7 +41,7 @@ public final class Net_GetSyncUserProfile_Handler implements JsonMessageHandler } try { - SolanaUserEntry user = usersDAO.getByLogin(login); + CurrentUserEntry user = usersDAO.getByLogin(login); Net_GetSyncUserProfile_Response resp = new Net_GetSyncUserProfile_Response(); resp.setOp(req.getOp()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_AddUser_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_AddUser_Handler.java deleted file mode 100644 index d2756b0b..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_AddUser_Handler.java +++ /dev/null @@ -1,26 +0,0 @@ -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -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.tempToTest.entyties.Net_AddUser_Request; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; - -/** - * AddUser отключен: регистрация работает через Solana. - */ -public class Net_AddUser_Handler implements JsonMessageHandler { - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_AddUser_Request req = (Net_AddUser_Request) baseRequest; - return NetExceptionResponseFactory.error( - req, - 410, - "ADD_USER_DISABLED", - "Серверная регистрация AddUser отключена. Используйте регистрацию через Solana." - ); - } -} - diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java index 728b8d3f..961faa65 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java @@ -5,16 +5,15 @@ 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.auth.SolanaUserPdaImportService; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import java.sql.SQLException; import java.util.Arrays; @@ -38,14 +37,11 @@ public class Net_GetUser_Handler implements JsonMessageHandler { ); } - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); + CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance(); BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); try { - SolanaUserEntry u = usersDAO.getByLogin(req.getLogin()); - if (u == null) { - u = SolanaUserPdaImportService.findOrImportByLogin(req.getLogin()); - } + CurrentUserEntry u = usersDAO.getByLogin(req.getLogin()); Net_GetUser_Response resp = new Net_GetUser_Response(); resp.setOp(req.getOp()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_SearchUsers_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_SearchUsers_Handler.java index 6647fea2..afe7356c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_SearchUsers_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_SearchUsers_Handler.java @@ -10,8 +10,8 @@ import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUser import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUsers_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; +import shine.db.dao.CurrentUsersDAO; +import shine.db.entities.CurrentUserEntry; import java.sql.SQLException; import java.util.ArrayList; @@ -35,13 +35,14 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler { } String prefix = req.getPrefix().trim(); + Boolean isServer = req.getIsServer(); try { - SolanaUsersDAO dao = SolanaUsersDAO.getInstance(); - List users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5 + CurrentUsersDAO dao = CurrentUsersDAO.getInstance(); + List users = dao.searchByLoginPrefix(prefix, isServer); // case-insensitive + LIMIT 5 List logins = new ArrayList<>(); - for (SolanaUserEntry u : users) { + for (CurrentUserEntry u : users) { if (u != null && u.getLogin() != null) { logins.add(u.getLogin()); // регистр как в БД } @@ -53,7 +54,7 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler { resp.setStatus(WireCodes.Status.OK); resp.setLogins(logins); - log.info("✅ SearchUsers ok: prefix='{}' -> {}", prefix, logins.size()); + log.info("✅ SearchUsers ok: prefix='{}', isServer={} -> {}", prefix, isServer, logins.size()); return resp; } catch (SQLException e) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Request.java deleted file mode 100644 index 28b6a6b7..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Request.java +++ /dev/null @@ -1,58 +0,0 @@ -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос AddUser — временная/тестовая регистрация локального пользователя. - * - * Клиент отправляет: - * - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "payload": { - * "login": "anya", - * "blockchainName": "anya-001", - * "solanaKey": "base64-ed25519-public-key-login", - * "blockchainKey": "base64-ed25519-public-key-blockchain", - * "clientKey": "base64-ed25519-public-key-device", - * "bchLimit": 1000000 - * } - * } - * - * Все поля лежат внутри payload. - */ -public class Net_AddUser_Request extends Net_Request { - - private String login; - private String blockchainName; - - /** Ключ пользователя Solana (публичный ключ логина) */ - private String solanaKey; - - /** Ключ блокчейна (публичный ключ блокчейна) */ - private String blockchainKey; - - /** Ключ устройства (публичный ключ устройства) */ - private String clientKey; - - private Integer bchLimit; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public String getSolanaKey() { return solanaKey; } - public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; } - - public String getBlockchainKey() { return blockchainKey; } - public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - - public String getClientKey() { return clientKey; } - public void setClientKey(String clientKey) { this.clientKey = clientKey; } - - public Integer getBchLimit() { return bchLimit; } - public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; } -} \ No newline at end of file diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Response.java deleted file mode 100644 index 465cdcb6..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Response.java +++ /dev/null @@ -1,21 +0,0 @@ -// file: server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Response.java -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Успешный ответ на AddUser. - * - * Сейчас дополнительных полей нет — достаточно status=200. - * - * Пример: - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "status": 200, - * "payload": { } - * } - */ -public class Net_AddUser_Response extends Net_Response { - // При необходимости сюда можно добавить, например, флаг created/updated и т.п. -} \ No newline at end of file diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_SearchUsers_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_SearchUsers_Request.java index 1e974d52..3f2e9990 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_SearchUsers_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_SearchUsers_Request.java @@ -18,7 +18,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; public class Net_SearchUsers_Request extends Net_Request { private String prefix; + private Boolean isServer; public String getPrefix() { return prefix; } public void setPrefix(String prefix) { this.prefix = prefix; } -} \ No newline at end of file + public Boolean getIsServer() { return isServer; } + public void setIsServer(Boolean isServer) { this.isServer = isServer; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java index 93c12989..69b69bf0 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java @@ -10,7 +10,7 @@ import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserPar import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.dao.UserParamsDAO; import shine.db.entities.UserParamEntry; @@ -48,7 +48,7 @@ public class Net_GetUserParam_Handler implements JsonMessageHandler { String param = req.getParam().trim(); try { - SqliteDbController db = SqliteDbController.getInstance(); + DbController db = DbController.getInstance(); UserParamsDAO dao = UserParamsDAO.getInstance(); try (Connection c = db.getConnection()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java index 866a9c58..d6460161 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java @@ -10,10 +10,10 @@ import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserPa import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.SolanaUsersDAO; +import shine.db.DbController; +import shine.db.dao.CurrentUsersDAO; import shine.db.dao.UserParamsDAO; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import shine.db.entities.UserParamEntry; import java.sql.Connection; @@ -50,7 +50,7 @@ public class Net_ListUserParams_Handler implements JsonMessageHandler { String login = req.getLogin().trim(); try { - SqliteDbController db = SqliteDbController.getInstance(); + DbController db = DbController.getInstance(); UserParamsDAO dao = UserParamsDAO.getInstance(); List entries; @@ -63,7 +63,7 @@ public class Net_ListUserParams_Handler implements JsonMessageHandler { resp.setRequestId(req.getRequestId()); resp.setStatus(WireCodes.Status.OK); - SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login); + CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(login); resp.setLogin(user != null && user.getLogin() != null ? user.getLogin() : login); List items = new ArrayList<>(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java index 1d1f369a..5fa03b14 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java @@ -11,10 +11,10 @@ import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUser import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.SolanaUsersDAO; +import shine.db.DbController; +import shine.db.dao.CurrentUsersDAO; import shine.db.dao.UserParamsDAO; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import shine.db.entities.UserParamEntry; import utils.config.ShineSignatureConstants; import utils.crypto.Ed25519Util; @@ -104,13 +104,13 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler { } // ---------------- DB checks + upsert ---------------- - SqliteDbController db = SqliteDbController.getInstance(); - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); + DbController db = DbController.getInstance(); + CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance(); UserParamsDAO paramsDAO = UserParamsDAO.getInstance(); try (Connection c = db.getConnection()) { // 1) user exists - SolanaUserEntry user = usersDAO.getByLogin(c, login); + CurrentUserEntry user = usersDAO.getByLogin(c, login); if (user == null) { return NetExceptionResponseFactory.error( req, diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java new file mode 100644 index 00000000..37a1f0e0 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java @@ -0,0 +1,50 @@ +package server.logic.ws_protocol.JSON.messages; + +import shine.db.dao.SignedMessagesDAO; +import shine.db.entities.SignedMessageEntry; + +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 + ) {} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java index 4ada25e8..98493a04 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java @@ -8,7 +8,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Re import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SignedMessagesV2DAO; +import shine.db.dao.SignedMessagesDAO; public class Net_AckSessionDelivery_Handler implements JsonMessageHandler { @Override @@ -22,7 +22,7 @@ public class Net_AckSessionDelivery_Handler implements JsonMessageHandler { } String messageKey = req.getMessageKey().trim(); - SignedMessagesV2DAO.getInstance().markDelivered(messageKey, ctx.getSessionId(), System.currentTimeMillis()); + SignedMessagesDAO.getInstance().markDelivered(messageKey, ctx.getSessionId(), System.currentTimeMillis()); Net_AckSessionDelivery_Response resp = new Net_AckSessionDelivery_Response(); resp.setOp(req.getOp()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java index a1023a2b..359529e0 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java @@ -15,9 +15,9 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.JSON.utils.NetIdGenerator; import server.logic.ws_protocol.WireCodes; import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import java.util.HashSet; import java.util.List; @@ -42,7 +42,7 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100 обязательны"); } - SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest); + CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest); if (targetUser == null) { return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java index 60ec4df3..b81bd0c5 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java @@ -17,9 +17,9 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.JSON.utils.NetIdGenerator; import server.logic.ws_protocol.WireCodes; import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import java.util.HashSet; import java.util.List; @@ -50,7 +50,7 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/targetSessionId/callId/type обязательны"); } - SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest); + CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest); if (targetUser == null) { return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java index a8a7f503..66c0b25e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java @@ -9,8 +9,8 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Re import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import server.sync.DmFederationService; -import shine.db.dao.SignedMessagesV2DAO; -import shine.db.entities.SignedMessageV2Entry; +import shine.db.dao.SignedMessagesDAO; +import shine.db.entities.SignedMessageEntry; public class Net_DeleteConversation_Handler implements JsonMessageHandler { @Override @@ -38,8 +38,8 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); } - SignedMessageV2Entry entry = SignedMessagesCore.toEntry(block, "DeleteConversation", null); - SignedMessagesV2DAO.ApplyStatus status = SignedMessagesV2DAO.getInstance().applyDeleteConversation(entry); + SignedMessageEntry entry = SignedMessagesCore.toEntry(block, "DeleteConversation", null); + SignedMessagesDAO.ApplyStatus status = SignedMessagesDAO.getInstance().applyDeleteConversation(entry); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java index aac091c7..fe2e2ae2 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java @@ -9,8 +9,8 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Respons import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import server.sync.DmFederationService; -import shine.db.dao.SignedMessagesV2DAO; -import shine.db.entities.SignedMessageV2Entry; +import shine.db.dao.SignedMessagesDAO; +import shine.db.entities.SignedMessageEntry; public class Net_DeleteMessage_Handler implements JsonMessageHandler { @Override @@ -38,8 +38,8 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); } - SignedMessageV2Entry entry = SignedMessagesCore.toEntry(block, "DeleteMessage", null); - SignedMessagesV2DAO.ApplyStatus status = SignedMessagesV2DAO.getInstance().applyDeleteMessage(entry); + SignedMessageEntry entry = SignedMessagesCore.toEntry(block, "DeleteMessage", null); + SignedMessagesDAO.ApplyStatus status = SignedMessagesDAO.getInstance().applyDeleteMessage(entry); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DmSyncBatch_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DmSyncBatch_Handler.java new file mode 100644 index 00000000..81192f17 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DmSyncBatch_Handler.java @@ -0,0 +1,114 @@ +package server.logic.ws_protocol.JSON.messages; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.SignedMessagesDAO; +import shine.db.dao.UserAccessServersCurrentDAO; +import shine.db.entities.SignedMessageEntry; +import shine.db.entities.UserAccessServerRouteEntry; +import utils.config.AppConfig; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +public class Net_DmSyncBatch_Handler implements JsonMessageHandler { + private static final int DEFAULT_LIMIT = 500; + private static final int MAX_LIMIT = 500; + 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_DmSyncBatch_Request req = (Net_DmSyncBatch_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 afterStoredAtMs = Math.max(0L, req.getAfterStoredAtMs() == null ? 0L : req.getAfterStoredAtMs()); + String afterMessageKey = req.getAfterMessageKey() == null ? "" : req.getAfterMessageKey().trim(); + + SignedMessagesDAO.SyncBatch batch = SignedMessagesDAO.getInstance().listSyncBatch( + ownerLogin, + afterStoredAtMs, + afterMessageKey, + limit, + maxBytes + ); + + Net_DmSyncBatch_Response resp = new Net_DmSyncBatch_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setOwnerLogin(ownerLogin); + resp.setLimit(limit); + resp.setRawBytes(batch.rawBytes()); + resp.setHasMore(batch.hasMore()); + resp.setNextStoredAtMs(afterStoredAtMs); + resp.setNextMessageKey(afterMessageKey); + + List items = new ArrayList<>(); + Base64.Encoder encoder = Base64.getEncoder(); + for (SignedMessageEntry entry : batch.items()) { + Net_DmSyncBatch_Response.Item item = new Net_DmSyncBatch_Response.Item(); + item.setMessageKey(entry.getMessageKey()); + item.setBaseKey(entry.getBaseKey()); + item.setTargetLogin(entry.getTargetLogin()); + item.setFromLogin(entry.getFromLogin()); + item.setToLogin(entry.getToLogin()); + item.setMessageType(entry.getMessageType()); + item.setTimeMs(entry.getTimeMs()); + item.setStoredAtMs(entry.getCreatedAtMs()); + item.setBlobB64(encoder.encodeToString(entry.getRawBlock())); + items.add(item); + resp.setNextStoredAtMs(entry.getCreatedAtMs()); + resp.setNextMessageKey(entry.getMessageKey()); + } + resp.setItems(items); + return resp; + } + + private boolean isLocalAccessServer(String ownerLogin, String ownServerLogin) throws Exception { + for (UserAccessServerRouteEntry route : UserAccessServersCurrentDAO.getInstance().listByUserLogin(ownerLogin)) { + if (route == null || route.getServerLogin() == null) continue; + if (ownServerLogin.equals(normalize(route.getServerLogin()))) { + return true; + } + } + 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; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java index 19d7f5f9..eca54d60 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java @@ -10,8 +10,8 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Req import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SignedMessagesV2DAO; -import shine.db.entities.SignedMessageV2Entry; +import shine.db.dao.SignedMessagesDAO; +import shine.db.entities.SignedMessageEntry; import java.util.ArrayList; import java.util.Base64; @@ -43,7 +43,7 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler { String beforeMessageKey = req.getBeforeMessageKey() == null ? "" : req.getBeforeMessageKey().trim(); try { - List page = SignedMessagesV2DAO.getInstance().listConversationPage( + List page = SignedMessagesDAO.getInstance().listConversationPage( login, peerLogin, beforeTimeMs, @@ -66,7 +66,7 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler { resp.setHasMore(hasMore); List items = new ArrayList<>(); - for (SignedMessageV2Entry entry : page) { + for (SignedMessageEntry entry : page) { Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem(); item.setMessageKey(entry.getMessageKey()); item.setBaseKey(entry.getBaseKey()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java index 1f87ffd3..811d42ce 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java @@ -8,8 +8,8 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessag import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SignedMessagesV2DAO; -import shine.db.entities.SignedMessageV2Entry; +import shine.db.dao.SignedMessagesDAO; +import shine.db.entities.SignedMessageEntry; import java.util.Base64; @@ -40,21 +40,26 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); } - final SignedMessageV2Entry entry; + final SignedMessageEntry entry; try { entry = SignedMessagesCore.toEntry(incoming, "ReceiveIncomingMessage", null); } catch (IllegalArgumentException ex) { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - SignedMessagesV2DAO.ApplyStatus status = incoming.isContentType() - ? SignedMessagesV2DAO.getInstance().upsertIncomingCopy(entry) - : SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); + SignedMessagesDAO.ApplyStatus status = incoming.isContentType() + ? SignedMessagesDAO.getInstance().upsertIncomingCopy(entry) + : SignedMessagesDAO.getInstance().insertIfAbsent(entry); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming); + server.sync.DmFederationService.fanOutIncomingToRecipientAccessServers( + incoming.toLogin, + req.getIncomingBlobB64().trim(), + req.getSourceServerLogin() + ); } - if (status == SignedMessagesV2DAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { + if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin); } @@ -74,7 +79,7 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { } private void bounceConversationDeleteIfKnown(String fromLogin, String toLogin) throws Exception { - SignedMessageV2Entry tombstone = SignedMessagesV2DAO.getInstance().getLatestConversationDelete(fromLogin, toLogin); + SignedMessageEntry tombstone = SignedMessagesDAO.getInstance().getLatestConversationDelete(fromLogin, toLogin); if (tombstone == null || tombstone.getRawBlock() == null || tombstone.getRawBlock().length == 0) return; server.sync.DmFederationService.fanOutDeleteConversation( tombstone.getFromLogin(), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java index 353f335d..32b80d64 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java @@ -18,11 +18,11 @@ import shine.db.dao.ActiveSessionsDAO; import shine.db.dao.DirectMessagesDAO; import shine.db.dao.SignedDirectMessagesHistoryDAO; import shine.db.dao.SignedDmReplayDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; import shine.db.entities.DirectMessageEntry; import shine.db.entities.SignedDirectMessageHistoryEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import utils.crypto.Ed25519Util; import java.nio.charset.StandardCharsets; @@ -54,8 +54,8 @@ public class Net_SendDirectMessage_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат пакета"); } - SolanaUserEntry fromUser = SolanaUsersDAO.getInstance().getByLogin(packet.fromLogin); - SolanaUserEntry toUser = SolanaUsersDAO.getInstance().getByLogin(packet.toLogin); + CurrentUserEntry fromUser = CurrentUsersDAO.getInstance().getByLogin(packet.fromLogin); + CurrentUserEntry toUser = CurrentUsersDAO.getInstance().getByLogin(packet.toLogin); if (fromUser == null || toUser == null) { return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "from/to пользователь не найден"); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java index d1baee68..332a5555 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java @@ -9,8 +9,8 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Respo import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import server.sync.DmFederationService; -import shine.db.dao.SignedMessagesV2DAO; -import shine.db.entities.SignedMessageV2Entry; +import shine.db.dao.SignedMessagesDAO; +import shine.db.entities.SignedMessageEntry; import java.util.Base64; @@ -41,8 +41,8 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); } - SignedMessageV2Entry incomingEntry; - SignedMessageV2Entry outgoingEntry; + SignedMessageEntry incomingEntry; + SignedMessageEntry outgoingEntry; try { String sourceApi = "SendMessagePair"; String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null; @@ -52,15 +52,15 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - SignedMessagesV2DAO.ApplyStatus pairStatus; + SignedMessagesDAO.ApplyStatus pairStatus; if (incoming.isContentType()) { - pairStatus = SignedMessagesV2DAO.getInstance().upsertContentPair( + pairStatus = SignedMessagesDAO.getInstance().upsertContentPair( incomingEntry, outgoingEntry ); } else { - pairStatus = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry) - ? SignedMessagesV2DAO.ApplyStatus.APPLIED - : SignedMessagesV2DAO.ApplyStatus.DUPLICATE_OR_OLDER; + pairStatus = SignedMessagesDAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry) + ? SignedMessagesDAO.ApplyStatus.APPLIED + : SignedMessagesDAO.ApplyStatus.DUPLICATE_OR_OLDER; } SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters(); @@ -77,7 +77,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId); } - if (pairStatus == SignedMessagesV2DAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { + if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin); } @@ -107,7 +107,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { } private void bounceConversationDeleteIfKnown(String fromLogin, String toLogin) throws Exception { - SignedMessageV2Entry tombstone = SignedMessagesV2DAO.getInstance().getLatestConversationDelete(fromLogin, toLogin); + SignedMessageEntry tombstone = SignedMessagesDAO.getInstance().getLatestConversationDelete(fromLogin, toLogin); if (tombstone == null || tombstone.getRawBlock() == null || tombstone.getRawBlock().length == 0) return; DmFederationService.fanOutDeleteConversation( tombstone.getFromLogin(), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java index 6a751cd8..7c24e117 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java @@ -16,9 +16,9 @@ import server.logic.ws_protocol.JSON.push.WsEventSender; import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import utils.crypto.Ed25519Util; import java.security.MessageDigest; @@ -74,12 +74,12 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "TIME_SKEW", "Время клиента отличается от сервера более чем на 30 секунд"); } - SolanaUserEntry senderUser = ctx.getSolanaUser(); + CurrentUserEntry senderUser = ctx.getCurrentUser(); if (senderUser == null || senderUser.getClientKey() == null || senderUser.getClientKey().isBlank()) { return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "NO_CLIENT_KEY", "Для пользователя не найден client key"); } - SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest); + CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest); if (targetUser == null) { return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java index f51c2eb7..ad16a106 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java @@ -1,9 +1,8 @@ package server.logic.ws_protocol.JSON.messages; -import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SignedMessageV2Entry; -import shine.db.entities.SolanaUserEntry; +import shine.db.dao.CurrentUsersDAO; +import shine.db.entities.SignedMessageEntry; +import shine.db.entities.CurrentUserEntry; import utils.crypto.Ed25519Util; import java.util.Base64; @@ -47,25 +46,22 @@ final class SignedMessagesCore { } static void verifyUsersAndSignature(SignedMessageBlock block) throws Exception { - SolanaUserEntry from = resolveUser(block.fromLogin); - SolanaUserEntry to = resolveUser(block.toLogin); + CurrentUserEntry from = resolveUser(block.fromLogin); + CurrentUserEntry to = resolveUser(block.toLogin); if (from == null || to == null) { throw new IllegalArgumentException("USER_NOT_FOUND"); } String signerLogin = block.isSignedByRecipient() ? block.toLogin : block.fromLogin; - SolanaUserEntry signer = signerLogin.equalsIgnoreCase(block.fromLogin) ? from : to; + CurrentUserEntry signer = signerLogin.equalsIgnoreCase(block.fromLogin) ? from : to; byte[] pubKey32 = Ed25519Util.keyFromBase64(signer.getClientKey()); if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) { throw new IllegalArgumentException("BAD_SIGNATURE"); } } - private static SolanaUserEntry resolveUser(String login) throws Exception { - SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance(); - SolanaUserEntry user = usersDao.getByLogin(login); - if (user != null) return user; - return SolanaUserPdaImportService.findOrImportByLogin(login); + private static CurrentUserEntry resolveUser(String login) throws Exception { + return CurrentUsersDAO.getInstance().getByLogin(login); } static void validatePair(SignedMessageBlock incoming, SignedMessageBlock outgoing) { @@ -100,11 +96,11 @@ final class SignedMessagesCore { } } - static SignedMessageV2Entry toEntry(SignedMessageBlock block, String sourceApi, String originSessionId) { + static SignedMessageEntry toEntry(SignedMessageBlock block, String sourceApi, String originSessionId) { String baseKey = SignedMessageKeys.baseKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce); String messageKey = SignedMessageKeys.messageKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce, block.messageType); - SignedMessageV2Entry entry = new SignedMessageV2Entry(); + SignedMessageEntry entry = new SignedMessageEntry(); entry.setMessageKey(messageKey); entry.setBaseKey(baseKey); entry.setTargetLogin(primaryTargetLogin(block)); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java index 2d681dc1..76bd9306 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java @@ -9,9 +9,9 @@ import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.push.WebPushSender; import server.logic.ws_protocol.JSON.push.WsEventSender; import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SignedMessagesV2DAO; +import shine.db.dao.SignedMessagesDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SignedMessageV2Entry; +import shine.db.entities.SignedMessageEntry; import java.util.ArrayList; import java.util.Base64; @@ -37,12 +37,12 @@ public final class SignedMessagesRealtime { private SignedMessagesRealtime() {} - static DeliveryCounters deliverToRelevantSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception { + static DeliveryCounters deliverToRelevantSessions(SignedMessageEntry message, SignedMessageBlock block) throws Exception { return deliverToRelevantSessions(message, block, null); } static DeliveryCounters deliverToRelevantSessions( - SignedMessageV2Entry message, + SignedMessageEntry message, SignedMessageBlock block, String excludeSessionId ) throws Exception { @@ -58,7 +58,7 @@ public final class SignedMessagesRealtime { } sessionIdsToTrack.add(sessionId); } - SignedMessagesV2DAO.getInstance().ensureDeliveryRows(message.getMessageKey(), sessionIdsToTrack, now); + SignedMessagesDAO.getInstance().ensureDeliveryRows(message.getMessageKey(), sessionIdsToTrack, now); for (ActiveSessionEntry s : sessions) { String sessionId = s.getSessionId(); if (excludeSessionId != null && excludeSessionId.equals(sessionId)) { @@ -97,9 +97,9 @@ public final class SignedMessagesRealtime { private static void dispatchPendingForSession(String login, String sessionId) { try { - List pending = SignedMessagesV2DAO.getInstance() + List pending = SignedMessagesDAO.getInstance() .listPendingForSession(login, sessionId); - for (SignedMessageV2Entry e : pending) { + for (SignedMessageEntry e : pending) { sendEventToSessionIfOnline(sessionId, login, e, true); } } catch (Exception e) { @@ -110,7 +110,7 @@ public final class SignedMessagesRealtime { private static boolean sendEventToSessionIfOnline( String sessionId, String actualTargetLogin, - SignedMessageV2Entry message, + SignedMessageEntry message, boolean backlog ) { ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId); @@ -134,14 +134,14 @@ public final class SignedMessagesRealtime { return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload); } - private static boolean shouldPushNewIncomingMessage(String targetLogin, SignedMessageV2Entry message, SignedMessageBlock block) { + private static boolean shouldPushNewIncomingMessage(String targetLogin, SignedMessageEntry message, SignedMessageBlock block) { if (block == null) return false; if (message.getMessageType() != SignedMessageBlock.TYPE_INCOMING_TEXT) return false; if (!targetLogin.equalsIgnoreCase(message.getToLogin())) return false; return block.revisionTimeMs == 0; } - private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) { + private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageEntry message) { try { if (session == null) return false; if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { @@ -160,7 +160,7 @@ public final class SignedMessagesRealtime { } } - private static List targetLoginsForMessage(SignedMessageV2Entry message) { + private static List targetLoginsForMessage(SignedMessageEntry message) { Set out = new LinkedHashSet<>(); int type = message.getMessageType(); if (type == SignedMessageBlock.TYPE_INCOMING_TEXT || type == SignedMessageBlock.TYPE_READ_INCOMING) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Request.java new file mode 100644 index 00000000..fdaea682 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Request.java @@ -0,0 +1,22 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_DmSyncBatch_Request extends Net_Request { + private String ownerLogin; + private Long afterStoredAtMs; + private String afterMessageKey; + private Integer limit; + private Integer maxBytes; + + public String getOwnerLogin() { return ownerLogin; } + public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; } + public Long getAfterStoredAtMs() { return afterStoredAtMs; } + public void setAfterStoredAtMs(Long afterStoredAtMs) { this.afterStoredAtMs = afterStoredAtMs; } + public String getAfterMessageKey() { return afterMessageKey; } + public void setAfterMessageKey(String afterMessageKey) { this.afterMessageKey = afterMessageKey; } + 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; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Response.java new file mode 100644 index 00000000..edab6afd --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Response.java @@ -0,0 +1,62 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_DmSyncBatch_Response extends Net_Response { + private String ownerLogin; + private int limit; + private int rawBytes; + private boolean hasMore; + private long nextStoredAtMs; + private String nextMessageKey; + private List items = new ArrayList<>(); + + public String getOwnerLogin() { return ownerLogin; } + public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; } + public int getLimit() { return limit; } + public void setLimit(int limit) { this.limit = limit; } + public int getRawBytes() { return rawBytes; } + public void setRawBytes(int rawBytes) { this.rawBytes = rawBytes; } + public boolean isHasMore() { return hasMore; } + public void setHasMore(boolean hasMore) { this.hasMore = hasMore; } + public long getNextStoredAtMs() { return nextStoredAtMs; } + public void setNextStoredAtMs(long nextStoredAtMs) { this.nextStoredAtMs = nextStoredAtMs; } + public String getNextMessageKey() { return nextMessageKey; } + public void setNextMessageKey(String nextMessageKey) { this.nextMessageKey = nextMessageKey; } + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } + + public static class Item { + private String messageKey; + private String baseKey; + private String targetLogin; + private String fromLogin; + private String toLogin; + private int messageType; + private long timeMs; + private long storedAtMs; + private String blobB64; + + public String getMessageKey() { return messageKey; } + public void setMessageKey(String messageKey) { this.messageKey = messageKey; } + public String getBaseKey() { return baseKey; } + public void setBaseKey(String baseKey) { this.baseKey = baseKey; } + public String getTargetLogin() { return targetLogin; } + public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; } + public String getFromLogin() { return fromLogin; } + public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; } + public String getToLogin() { return toLogin; } + public void setToLogin(String toLogin) { this.toLogin = toLogin; } + public int getMessageType() { return messageType; } + public void setMessageType(int messageType) { this.messageType = messageType; } + public long getTimeMs() { return timeMs; } + public void setTimeMs(long timeMs) { this.timeMs = timeMs; } + public long getStoredAtMs() { return storedAtMs; } + public void setStoredAtMs(long storedAtMs) { this.storedAtMs = storedAtMs; } + public String getBlobB64() { return blobB64; } + public void setBlobB64(String blobB64) { this.blobB64 = blobB64; } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java index 5b67429d..45dc2681 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java @@ -4,7 +4,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; public class Net_ReceiveIncomingMessage_Request extends Net_Request { private String incomingBlobB64; + private String sourceServerLogin; public String getIncomingBlobB64() { return incomingBlobB64; } public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; } + public String getSourceServerLogin() { return sourceServerLogin; } + public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java index d2917221..7dc07361 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java @@ -5,9 +5,12 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; public class Net_SendMessagePair_Request extends Net_Request { private String incomingBlobB64; private String outgoingBlobB64; + private String sourceServerLogin; public String getIncomingBlobB64() { return incomingBlobB64; } public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; } public String getOutgoingBlobB64() { return outgoingBlobB64; } public void setOutgoingBlobB64(String outgoingBlobB64) { this.outgoingBlobB64 = outgoingBlobB64; } + public String getSourceServerLogin() { return sourceServerLogin; } + public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/AuthSignatures.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/AuthSignatures.java index 539ab1e2..93702ef9 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/AuthSignatures.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/AuthSignatures.java @@ -1,6 +1,6 @@ ////package server.logic.ws_protocol.JSON.utils; // -//import shine.db.entities.SolanaUserEntry; +//import shine.db.entities.CurrentUserEntry; //import utils.crypto.Ed25519Util; // //import java.nio.charset.StandardCharsets; @@ -35,7 +35,7 @@ // * Подпись проверяется над preimageCreateAuthSession(...). // */ // public static boolean verifyCreateAuthSessionSignature( -// SolanaUserEntry user, +// CurrentUserEntry user, // String login, // String authNonce, // long timeMs, diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java index b2fd337f..2e4741ab 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java @@ -114,6 +114,12 @@ public final class AddBlockSyncService { return; } + if (firstTry.serverAlreadyHasBlock(blockNumber)) { + log.info("AddBlock sync skipped: partner already has block. partner={} blockchainName={} blockNumber={} remoteLast={}", + partner.getLogin(), blockchainName, blockNumber, firstTry.serverLastGlobalNumber()); + return; + } + if (!firstTry.needsBackfill()) { log.warn("AddBlock sync failed without backfill: partner={} blockchainName={} blockNumber={} code={}", partner.getLogin(), blockchainName, blockNumber, firstTry.code()); @@ -123,7 +129,7 @@ public final class AddBlockSyncService { int remoteLast = firstTry.serverLastGlobalNumber(); int fromBlockNumber = remoteLast + 1; if (fromBlockNumber > blockNumber) { - log.warn("AddBlock sync inconsistent backfill window: partner={} blockchainName={} remoteLast={} target={}", + log.info("AddBlock sync skipped: partner already caught up during backfill window. partner={} blockchainName={} remoteLast={} target={}", partner.getLogin(), blockchainName, remoteLast, blockNumber); return; } @@ -295,6 +301,12 @@ public final class AddBlockSyncService { boolean needsBackfill() { return !ok && ("bad_prev_hash".equalsIgnoreCase(code) || "bad_block_number".equalsIgnoreCase(code)); } + + boolean serverAlreadyHasBlock(int targetBlockNumber) { + return !ok + && serverLastGlobalNumber != Integer.MIN_VALUE + && serverLastGlobalNumber >= targetBlockNumber; + } } private static final class SyncWsListener implements WebSocket.Listener { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java index 50589755..47994619 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java @@ -2,7 +2,8 @@ package server.sync; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; +import shine.db.dao.UserAccessServersCurrentDAO; +import shine.db.entities.UserAccessServerRouteEntry; import utils.config.AppConfig; import java.util.LinkedHashMap; @@ -18,26 +19,48 @@ public final class DmFederationService { public static void fanOutPair(String fromLogin, String toLogin, String incomingBlobB64, String outgoingBlobB64) { try { - Map senderRoutes = - routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin)); - Map recipientRoutes = - routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin)); + Map senderRoutes = + routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin)); + Map recipientRoutes = + routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin)); String ownServerLogin = ownServerLogin(); - for (SolanaUserPdaImportService.ParsedServerRoute route : senderRoutes.values()) { + for (UserAccessServerRouteEntry route : senderRoutes.values()) { if (isOwnServer(route, ownServerLogin)) continue; - REMOTE.sendMessagePair(route.serverAddress(), incomingBlobB64, outgoingBlobB64); + REMOTE.sendMessagePair(route.getServerUrl(), incomingBlobB64, outgoingBlobB64, ownServerLogin); } - for (SolanaUserPdaImportService.ParsedServerRoute route : recipientRoutes.values()) { + for (UserAccessServerRouteEntry route : recipientRoutes.values()) { if (isOwnServer(route, ownServerLogin)) continue; - if (senderRoutes.containsKey(route.login())) continue; - REMOTE.receiveIncomingMessage(route.serverAddress(), incomingBlobB64); + if (senderRoutes.containsKey(normalize(route.getServerLogin()))) continue; + REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin); } } catch (Exception e) { log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e); } } + public static void fanOutIncomingToRecipientAccessServers( + String toLogin, + String incomingBlobB64, + String sourceServerLogin + ) { + try { + Map recipientRoutes = + routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin)); + String ownServerLogin = ownServerLogin(); + String normalizedSource = normalize(sourceServerLogin); + for (UserAccessServerRouteEntry route : recipientRoutes.values()) { + String routeLogin = normalize(route.getServerLogin()); + if (routeLogin == null) continue; + if (ownServerLogin != null && ownServerLogin.equalsIgnoreCase(routeLogin)) continue; + if (normalizedSource != null && normalizedSource.equalsIgnoreCase(routeLogin)) continue; + REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin); + } + } catch (Exception e) { + log.warn("DM federation incoming relay failed: to={}", toLogin, e); + } + } + public static void fanOutDeleteMessage(String fromLogin, String toLogin, int messageType, String blobB64) { fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true); } @@ -48,17 +71,17 @@ public final class DmFederationService { private static void fanOutSingleDelete(String fromLogin, String toLogin, int messageType, String blobB64, boolean oneMessageDelete) { try { - Map routes = routesByLogin( - SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin), - SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin) + Map routes = routesByLogin( + UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin), + UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin) ); String ownServerLogin = ownServerLogin(); - for (SolanaUserPdaImportService.ParsedServerRoute route : routes.values()) { + for (UserAccessServerRouteEntry route : routes.values()) { if (isOwnServer(route, ownServerLogin)) continue; if (oneMessageDelete) { - REMOTE.deleteMessage(route.serverAddress(), blobB64); + REMOTE.deleteMessage(route.getServerUrl(), blobB64); } else { - REMOTE.deleteConversation(route.serverAddress(), blobB64); + REMOTE.deleteConversation(route.getServerUrl(), blobB64); } } } catch (Exception e) { @@ -68,15 +91,15 @@ public final class DmFederationService { } @SafeVarargs - private static Map routesByLogin( - List... routeLists + private static Map routesByLogin( + List... routeLists ) { - Map out = new LinkedHashMap<>(); - for (List routeList : routeLists) { - for (SolanaUserPdaImportService.ParsedServerRoute route : routeList) { + Map out = new LinkedHashMap<>(); + for (List routeList : routeLists) { + for (UserAccessServerRouteEntry route : routeList) { if (route == null) continue; - String login = normalize(route.login()); - String address = route.serverAddress() == null ? "" : route.serverAddress().trim(); + String login = normalize(route.getServerLogin()); + String address = route.getServerUrl() == null ? "" : route.getServerUrl().trim(); if (login == null || address.isBlank()) continue; out.putIfAbsent(login, route); } @@ -84,8 +107,11 @@ public final class DmFederationService { return out; } - private static boolean isOwnServer(SolanaUserPdaImportService.ParsedServerRoute route, String ownServerLogin) { - return ownServerLogin != null && ownServerLogin.equalsIgnoreCase(route.login()); + private static boolean isOwnServer(UserAccessServerRouteEntry route, String ownServerLogin) { + return ownServerLogin != null + && route != null + && route.getServerLogin() != null + && ownServerLogin.equalsIgnoreCase(route.getServerLogin()); } private static String ownServerLogin() { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java index c71d7185..cdd7c1df 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java @@ -8,6 +8,8 @@ import java.net.http.HttpClient; import java.net.http.WebSocket; import java.nio.ByteBuffer; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -21,36 +23,83 @@ public final class RemoteDmSyncClient { .connectTimeout(Duration.ofSeconds(6)) .build(); - public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64) throws Exception { + public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64, String sourceServerLogin) throws Exception { String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64); + String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin); JsonNode response = send(serverAddressRaw, """ { "op":"ReceiveOutcomingMessage", "requestId":%s, "payload":{ "incomingBlobB64":%s, - "outgoingBlobB64":%s + "outgoingBlobB64":%s%s } } - """.formatted("%s", incomingJson, outgoingJson)); + """.formatted("%s", incomingJson, outgoingJson, sourceServerLoginJson)); ensureOk("ReceiveOutcomingMessage", response); } - public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64) throws Exception { + public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64, String sourceServerLogin) throws Exception { String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); + String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin); JsonNode response = send(serverAddressRaw, """ { "op":"ReceiveIncomingMessage", "requestId":%s, "payload":{ - "incomingBlobB64":%s + "incomingBlobB64":%s%s } } - """.formatted("%s", incomingJson)); + """.formatted("%s", incomingJson, sourceServerLoginJson)); ensureOk("ReceiveIncomingMessage", response); } + public RemoteDmBatch dmSyncBatch( + String serverAddressRaw, + String ownerLogin, + long afterStoredAtMs, + String afterMessageKey, + int limit, + int maxBytes + ) throws Exception { + String ownerLoginJson = MAPPER.writeValueAsString(ownerLogin); + String afterMessageKeyJson = MAPPER.writeValueAsString(afterMessageKey == null ? "" : afterMessageKey); + JsonNode response = send(serverAddressRaw, """ + { + "op":"DmSyncBatch", + "requestId":%s, + "payload":{ + "ownerLogin":%s, + "afterStoredAtMs":%d, + "afterMessageKey":%s, + "limit":%d, + "maxBytes":%d + } + } + """.formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson, limit, maxBytes)); + ensureOk("DmSyncBatch", response); + + JsonNode payload = response.path("payload"); + List items = new ArrayList<>(); + JsonNode arr = payload.path("items"); + if (arr.isArray()) { + for (JsonNode item : arr) { + items.add(new RemoteDmItem( + item.path("messageKey").asText(""), + item.path("storedAtMs").asLong(0L), + item.path("blobB64").asText("") + )); + } + } + return new RemoteDmBatch( + payload.path("nextStoredAtMs").asLong(afterStoredAtMs), + payload.path("nextMessageKey").asText(afterMessageKey == null ? "" : afterMessageKey), + payload.path("hasMore").asBoolean(false), + items + ); + } + public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception { String blobJson = MAPPER.writeValueAsString(blobB64); JsonNode response = send(serverAddressRaw, """ @@ -107,6 +156,13 @@ public final class RemoteDmSyncClient { return MAPPER.readTree(responseJson); } + 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; @@ -126,6 +182,19 @@ public final class RemoteDmSyncClient { } } + public record RemoteDmBatch( + long nextStoredAtMs, + String nextMessageKey, + boolean hasMore, + List items + ) {} + + public record RemoteDmItem( + String messageKey, + long storedAtMs, + String blobB64 + ) {} + private static final class SyncWsListener implements WebSocket.Listener { private final CompletableFuture responseFuture; private final CountDownLatch openLatch; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/ws/WsConnectionUtils.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/ws/WsConnectionUtils.java index 0a8ec176..5a7ba084 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/ws/WsConnectionUtils.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/ws/WsConnectionUtils.java @@ -5,7 +5,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; import server.logic.ws_protocol.JSON.ConnectionContext; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import java.net.SocketAddress; import java.util.concurrent.atomic.AtomicLong; @@ -57,7 +57,7 @@ public final class WsConnectionUtils { final String sessionId = safeString(ctx.getSessionId()); final int authStatus = safeAuthStatus(ctx); - final SolanaUserEntry user = ctx.getSolanaUser(); + final CurrentUserEntry user = ctx.getCurrentUser(); final String login = (user != null ? safeString(user.getLogin()) : ""); final String activeSessionId = @@ -152,4 +152,4 @@ public final class WsConnectionUtils { return "remote=" + remote + ", local=" + local; } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-solana-users-sync/build.gradle b/SHiNE-server/shine-server-solana-users-sync/build.gradle new file mode 100644 index 00000000..74314d62 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/build.gradle @@ -0,0 +1,32 @@ +plugins { + id 'java' +} + +group = 'shine' +version = '1.0.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':shine-server-config') + + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' + implementation 'org.postgresql:postgresql:42.7.7' + implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' + implementation 'org.slf4j:slf4j-api:2.0.16' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java new file mode 100644 index 00000000..38597f90 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java @@ -0,0 +1,1444 @@ +package sync.codec; + +import com.fasterxml.jackson.databind.JsonNode; +import sync.util.Base58Util; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; + +public final class ShineUsersCodec { + + public static final String MAGIC = + "SHiNE"; + + public static final int IX_INIT_USERS_ECONOMY_CONFIG = + 1; + + public static final int IX_UPDATE_USERS_ECONOMY_CONFIG = + 2; + + public static final int IX_CREATE_USER_PDA = + 3; + + public static final int IX_UPDATE_USER_PDA = + 4; + + public static final long START_REGISTRATION_FEE_LAMPORTS = + 10_000_000L; + + public static final long START_LAMPORTS_PER_LIMIT_STEP = + 100_000L; + + public static final long START_BONUS_LIMIT = + 100_000L; + + private static final int BLOCK_TYPE_RECOVERY_KEY = + 0; + + private static final int BLOCK_TYPE_ROOT_KEY = + 1; + + private static final int BLOCK_TYPE_CLIENT_KEY = + 2; + + private static final int BLOCK_TYPE_BLOCKCHAIN_REGISTRY = + 3; + + private static final int BLOCK_TYPE_SERVER_PROFILE = + 30; + + private static final int BLOCK_TYPE_ACCESS_SERVERS = + 40; + + private static final int BLOCK_TYPE_SESSIONS = + 50; + + private static final int BLOCK_TYPE_TRUSTED_STATE = + 70; + + private static final int BLOCK_VERSION_0 = + 0; + + private static final int BLOCKCHAIN_TYPE_MAIN_USER = + 1; + + private static final byte[] ZERO_HASH = + new byte[32]; + + private ShineUsersCodec() { + } + + public static ParsedInstruction parseShineUsersInstruction( + JsonNode instructionNode, + String expectedProgramId + ) { + + if (instructionNode == null) { + return null; + } + + String programId = + instructionNode.path("programId") + .asText(""); + + if (!expectedProgramId.equals(programId)) { + return null; + } + + String dataBase58 = + instructionNode.path("data") + .asText(""); + + if (dataBase58.isBlank()) { + return new ParsedInstruction( + TxKind.OTHER, + false, + null, + null, + null, + null + ); + } + + byte[] data = + Base58Util.decode( + dataBase58 + ); + + Reader reader = + new Reader(data); + + int tag = + reader.readU8(); + + List accounts = + readInstructionAccounts( + instructionNode.path("accounts") + ); + + return switch (tag) { + case IX_INIT_USERS_ECONOMY_CONFIG -> + new ParsedInstruction( + TxKind.INIT_USERS_ECONOMY_CONFIG, + false, + null, + null, + EconomyConfigState.initial(), + null + ); + + case IX_UPDATE_USERS_ECONOMY_CONFIG -> + new ParsedInstruction( + TxKind.UPDATE_USERS_ECONOMY_CONFIG, + false, + null, + null, + new EconomyConfigState( + 1, + reader.readU64(), + reader.readU64(), + reader.readU64() + ), + null + ); + + case IX_CREATE_USER_PDA -> + parseCreateInstruction( + reader, + accounts + ); + + case IX_UPDATE_USER_PDA -> + parseUpdateInstruction( + reader, + accounts + ); + + default -> + new ParsedInstruction( + TxKind.OTHER, + false, + null, + null, + null, + null + ); + }; + } + + public static UserPdaSnapshot parseUserPdaAccount( + String pdaAddress, + long slot, + String rawDataBase64, + String lastTxSignature + ) { + + byte[] raw = + Base64.getDecoder() + .decode(rawDataBase64); + + if (raw.length < 9) { + throw new IllegalArgumentException( + "PDA record too short" + ); + } + + String magic = + new String( + raw, + 0, + 5, + StandardCharsets.UTF_8 + ); + + if (!MAGIC.equals(magic)) { + throw new IllegalArgumentException( + "Unsupported PDA magic" + ); + } + + int recordLen = + u16le( + raw, + 7 + ); + + if (recordLen > raw.length) { + throw new IllegalArgumentException( + "Invalid PDA record length" + ); + } + + byte[] useful = + new byte[recordLen]; + + System.arraycopy( + raw, + 0, + useful, + 0, + recordLen + ); + + Reader reader = + new Reader(useful); + + reader.skip(9); + + long createdAtMs = + reader.readU64(); + + long updatedAtMs = + reader.readU64(); + + int recordNumber = + (int) reader.readU32(); + + byte[] prevHash = + reader.readFixed(32); + + String login = + reader.readStringU8(); + + int blocksCount = + reader.readU8(); + + String recoveryKey = + null; + + String rootKey = + null; + + String clientKey = + null; + + String blockchainName = + null; + + String blockchainKey = + null; + + long paidLimitBytes = + 0L; + + long usedBytes = + 0L; + + int lastBlockNumber = + 0; + + String lastBlockHash = + ""; + + String lastBlockSignature = + ""; + + String arweaveTxId = + ""; + + boolean isServer = + false; + + int addressFormatType = + 0; + + int addressFormatVersion = + 0; + + String serverAddress = + ""; + + List syncServers = + new ArrayList<>(); + + List accessServers = + new ArrayList<>(); + + int sessionsMode = + 1; + + List sessions = + new ArrayList<>(); + + int trustedCount = + 0; + + for (int i = 0; i < blocksCount; i++) { + + int blockType = + reader.readU8(); + + int blockVersion = + reader.readU8(); + + if (blockVersion != BLOCK_VERSION_0) { + throw new IllegalArgumentException( + "Unsupported block version" + ); + } + + switch (blockType) { + + case BLOCK_TYPE_RECOVERY_KEY -> + recoveryKey = + Base58Util.encode( + reader.readFixed(32) + ); + + case BLOCK_TYPE_ROOT_KEY -> + rootKey = + Base58Util.encode( + reader.readFixed(32) + ); + + case BLOCK_TYPE_CLIENT_KEY -> + clientKey = + Base58Util.encode( + reader.readFixed(32) + ); + + case BLOCK_TYPE_BLOCKCHAIN_REGISTRY -> { + int count = + reader.readU8(); + + if (count != 1) { + throw new IllegalArgumentException( + "Unexpected blockchain count" + ); + } + + int blockchainType = + reader.readU8(); + + if (blockchainType != BLOCKCHAIN_TYPE_MAIN_USER) { + throw new IllegalArgumentException( + "Unsupported blockchain type" + ); + } + + blockchainName = + reader.readStringU8(); + + blockchainKey = + Base58Util.encode( + reader.readFixed(32) + ); + + paidLimitBytes = + reader.readU64(); + + usedBytes = + reader.readU64(); + + lastBlockNumber = + (int) reader.readU32(); + + lastBlockHash = + toHex( + reader.readFixed(32) + ); + + lastBlockSignature = + Base58Util.encode( + reader.readFixed(64) + ); + + int arweavePresent = + reader.readU8(); + + if (arweavePresent == 1) { + arweaveTxId = + reader.readStringU8(); + } else if (arweavePresent != 0) { + throw new IllegalArgumentException( + "Invalid arweave marker" + ); + } + } + + case BLOCK_TYPE_SERVER_PROFILE -> { + isServer = + reader.readU8() == 1; + + if (isServer) { + addressFormatType = + reader.readU8(); + addressFormatVersion = + reader.readU8(); + serverAddress = + reader.readStringU8(); + int syncCount = + reader.readU8(); + for (int j = 0; j < syncCount; j++) { + syncServers.add( + reader.readStringU8() + ); + } + } + } + + case BLOCK_TYPE_ACCESS_SERVERS -> { + int accessCount = + reader.readU8(); + for (int j = 0; j < accessCount; j++) { + accessServers.add( + reader.readStringU8() + ); + } + } + + case BLOCK_TYPE_SESSIONS -> { + sessionsMode = + reader.readU8(); + + int sessionCount = + reader.readU8(); + + for (int j = 0; j < sessionCount; j++) { + sessions.add( + new UserSessionSnapshot( + reader.readU8(), + reader.readU8(), + reader.readStringU8(), + Base58Util.encode( + reader.readFixed(32) + ) + ) + ); + } + } + + case BLOCK_TYPE_TRUSTED_STATE -> + trustedCount = + reader.readU8(); + + default -> + throw new IllegalArgumentException( + "Unsupported block type: " + blockType + ); + } + } + + String signature = + Base58Util.encode( + reader.readFixed(64) + ); + + return new UserPdaSnapshot( + pdaAddress, + login, + recordNumber, + slot, + lastTxSignature, + recoveryKey, + rootKey, + clientKey, + blockchainName, + blockchainKey, + paidLimitBytes, + usedBytes, + lastBlockNumber, + lastBlockHash, + lastBlockSignature, + arweaveTxId, + isServer, + addressFormatType, + addressFormatVersion, + serverAddress, + List.copyOf(syncServers), + List.copyOf(accessServers), + sessionsMode, + List.copyOf(sessions), + trustedCount, + createdAtMs, + updatedAtMs, + toHex(prevHash), + signature, + rawDataBase64 + ); + } + + public static UserPdaSnapshot buildCreateSnapshot( + UserPdaMutation mutation, + EconomyConfigState economyConfigState, + String txSignature, + long slot + ) { + + long paidLimitBytes = + economyConfigState.startBonusLimit() + + mutation.additionalLimit(); + + return buildSnapshot( + mutation, + 0, + slot, + txSignature, + mutation.createdAtMs(), + mutation.createdAtMs(), + toHex(ZERO_HASH), + paidLimitBytes + ); + } + + public static UserPdaSnapshot buildUpdateSnapshot( + UserPdaMutation mutation, + UserPdaSnapshot previous, + String txSignature, + long slot + ) { + + long paidLimitBytes = + previous.paidLimitBytes() + + mutation.additionalLimit(); + + return buildSnapshot( + mutation, + mutation.version(), + slot, + txSignature, + mutation.createdAtMs(), + mutation.updatedAtMs(), + toHex(mutation.prevHash()), + paidLimitBytes + ); + } + + public static String serializeSnapshotToBase64( + UserPdaSnapshot snapshot + ) { + + byte[] recoveryKey = + Base58Util.decode( + snapshot.recoveryKey() + ); + + byte[] rootKey = + Base58Util.decode( + snapshot.rootKey() + ); + + byte[] clientKey = + Base58Util.decode( + snapshot.clientKey() + ); + + byte[] blockchainKey = + Base58Util.decode( + snapshot.blockchainKey() + ); + + byte[] lastBlockHash = + fromHex( + snapshot.lastBlockHash() + ); + + byte[] lastBlockSignature = + Base58Util.decode( + snapshot.lastBlockSignature() + ); + + byte[] prevHash = + fromHex( + snapshot.prevRecordHash() + ); + + byte[] recordSignature = + Base58Util.decode( + snapshot.recordSignature() + ); + + List syncServers = + snapshot.syncServers(); + + List accessServers = + snapshot.accessServers(); + + List sessions = + snapshot.sessions(); + + byte[] loginBytes = + snapshot.login() + .getBytes(StandardCharsets.UTF_8); + + List out = + new ArrayList<>(); + + pushFixed(out, MAGIC.getBytes(StandardCharsets.UTF_8)); + out.add((byte) 1); + out.add((byte) 0); + pushU16(out, 0); + pushU64(out, snapshot.createdAtMs()); + pushU64(out, snapshot.updatedAtMs()); + pushU32(out, snapshot.recordNumber()); + pushFixed(out, prevHash); + pushStringU8(out, loginBytes); + + int blocksCount = + snapshot.isServer() + ? 8 + : 7; + + out.add((byte) blocksCount); + + out.add((byte) BLOCK_TYPE_RECOVERY_KEY); + out.add((byte) BLOCK_VERSION_0); + pushFixed(out, recoveryKey); + + out.add((byte) BLOCK_TYPE_ROOT_KEY); + out.add((byte) BLOCK_VERSION_0); + pushFixed(out, rootKey); + + out.add((byte) BLOCK_TYPE_CLIENT_KEY); + out.add((byte) BLOCK_VERSION_0); + pushFixed(out, clientKey); + + out.add((byte) BLOCK_TYPE_BLOCKCHAIN_REGISTRY); + out.add((byte) BLOCK_VERSION_0); + out.add((byte) 1); + out.add((byte) BLOCKCHAIN_TYPE_MAIN_USER); + pushString(out, snapshot.blockchainName()); + pushFixed(out, blockchainKey); + pushU64(out, snapshot.paidLimitBytes()); + pushU64(out, snapshot.usedBytes()); + pushU32(out, snapshot.lastBlockNumber()); + pushFixed(out, lastBlockHash); + pushFixed(out, lastBlockSignature); + + if (snapshot.arweaveTxId().isBlank()) { + out.add((byte) 0); + } else { + out.add((byte) 1); + pushString(out, snapshot.arweaveTxId()); + } + + if (snapshot.isServer()) { + out.add((byte) BLOCK_TYPE_SERVER_PROFILE); + out.add((byte) BLOCK_VERSION_0); + out.add((byte) 1); + out.add((byte) snapshot.addressFormatType()); + out.add((byte) snapshot.addressFormatVersion()); + pushString(out, snapshot.serverAddress()); + out.add((byte) syncServers.size()); + for (String syncServer : syncServers) { + pushString(out, syncServer); + } + } + + out.add((byte) BLOCK_TYPE_ACCESS_SERVERS); + out.add((byte) BLOCK_VERSION_0); + out.add((byte) accessServers.size()); + for (String accessServer : accessServers) { + pushString(out, accessServer); + } + + out.add((byte) BLOCK_TYPE_SESSIONS); + out.add((byte) BLOCK_VERSION_0); + out.add((byte) snapshot.sessionsMode()); + out.add((byte) sessions.size()); + for (UserSessionSnapshot session : sessions) { + out.add((byte) session.sessionType()); + out.add((byte) session.sessionVersion()); + pushString(out, session.sessionName()); + pushFixed( + out, + Base58Util.decode( + session.sessionPubKey() + ) + ); + } + + out.add((byte) BLOCK_TYPE_TRUSTED_STATE); + out.add((byte) BLOCK_VERSION_0); + out.add((byte) snapshot.trustedCount()); + + int recordLength = + out.size() + 64; + + byte[] recordLengthBytes = + new byte[]{ + (byte) (recordLength & 0xFF), + (byte) ((recordLength >> 8) & 0xFF) + }; + + out.set(7, recordLengthBytes[0]); + out.set(8, recordLengthBytes[1]); + + pushFixed(out, recordSignature); + + byte[] bytes = + new byte[out.size()]; + + for (int i = 0; i < out.size(); i++) { + bytes[i] = out.get(i); + } + + return Base64.getEncoder() + .encodeToString(bytes); + } + + private static ParsedInstruction parseCreateInstruction( + Reader reader, + List accounts + ) { + + CreateOrUpdateArgs args = + parseCreateArgs(reader); + + return new ParsedInstruction( + TxKind.CREATE_USER_PDA, + true, + accountAt( + accounts, + 1 + ), + args.login(), + null, + new UserPdaMutation( + true, + accountAt( + accounts, + 1 + ), + args.login(), + args.recoveryKey(), + args.rootKey(), + args.createdAtMs(), + args.createdAtMs(), + 0, + ZERO_HASH.clone(), + args.additionalLimit(), + args.fields(), + args.recordSignature() + ) + ); + } + + private static ParsedInstruction parseUpdateInstruction( + Reader reader, + List accounts + ) { + + UpdateArgs args = + parseUpdateArgs(reader); + + return new ParsedInstruction( + TxKind.UPDATE_USER_PDA, + true, + accountAt( + accounts, + 1 + ), + args.login(), + null, + new UserPdaMutation( + false, + accountAt( + accounts, + 1 + ), + args.login(), + args.recoveryKey(), + args.rootKey(), + args.createdAtMs(), + args.updatedAtMs(), + args.version(), + args.prevHash(), + args.additionalLimit(), + args.fields(), + args.recordSignature() + ) + ); + } + + private static CreateOrUpdateArgs parseCreateArgs( + Reader reader + ) { + + String login = + reader.readStringU8(); + + String recoveryKey = + Base58Util.encode( + reader.readFixed(32) + ); + + String rootKey = + Base58Util.encode( + reader.readFixed(32) + ); + + long createdAtMs = + reader.readU64(); + + long additionalLimit = + reader.readU64(); + + UserFields fields = + parseFields(reader); + + String recordSignature = + Base58Util.encode( + reader.readFixed(64) + ); + + return new CreateOrUpdateArgs( + login, + recoveryKey, + rootKey, + createdAtMs, + additionalLimit, + fields, + recordSignature + ); + } + + private static UpdateArgs parseUpdateArgs( + Reader reader + ) { + + String login = + reader.readStringU8(); + + String recoveryKey = + Base58Util.encode( + reader.readFixed(32) + ); + + String rootKey = + Base58Util.encode( + reader.readFixed(32) + ); + + long createdAtMs = + reader.readU64(); + + long updatedAtMs = + reader.readU64(); + + int version = + (int) reader.readU32(); + + byte[] prevHash = + reader.readFixed(32); + + long additionalLimit = + reader.readU64(); + + UserFields fields = + parseFields(reader); + + String recordSignature = + Base58Util.encode( + reader.readFixed(64) + ); + + return new UpdateArgs( + login, + recoveryKey, + rootKey, + createdAtMs, + updatedAtMs, + version, + prevHash, + additionalLimit, + fields, + recordSignature + ); + } + + private static UserFields parseFields( + Reader reader + ) { + + String clientKey = + Base58Util.encode( + reader.readFixed(32) + ); + + String blockchainKey = + Base58Util.encode( + reader.readFixed(32) + ); + + String blockchainName = + reader.readStringU8(); + + long usedBytes = + reader.readU64(); + + int lastBlockNumber = + (int) reader.readU32(); + + String lastBlockHash = + toHex( + reader.readFixed(32) + ); + + String lastBlockSignature = + Base58Util.encode( + reader.readFixed(64) + ); + + String arweaveTxId = + reader.readStringU8(); + + boolean isServer = + reader.readU8() == 1; + + int addressFormatType = + 0; + int addressFormatVersion = + 0; + String serverAddress = + ""; + List syncServers = + new ArrayList<>(); + + if (isServer) { + addressFormatType = + reader.readU8(); + addressFormatVersion = + reader.readU8(); + serverAddress = + reader.readStringU8(); + int syncCount = + reader.readU8(); + for (int i = 0; i < syncCount; i++) { + syncServers.add( + reader.readStringU8() + ); + } + } + + int accessCount = + reader.readU8(); + + List accessServers = + new ArrayList<>(); + + for (int i = 0; i < accessCount; i++) { + accessServers.add( + reader.readStringU8() + ); + } + + int sessionsMode = + reader.readU8(); + + int sessionsCount = + reader.readU8(); + + List sessions = + new ArrayList<>(); + + for (int i = 0; i < sessionsCount; i++) { + sessions.add( + new UserSessionSnapshot( + reader.readU8(), + reader.readU8(), + reader.readStringU8(), + Base58Util.encode( + reader.readFixed(32) + ) + ) + ); + } + + int trustedCount = + reader.readU8(); + + return new UserFields( + clientKey, + blockchainKey, + blockchainName, + usedBytes, + lastBlockNumber, + lastBlockHash, + lastBlockSignature, + arweaveTxId, + isServer, + addressFormatType, + addressFormatVersion, + serverAddress, + List.copyOf(syncServers), + List.copyOf(accessServers), + sessionsMode, + List.copyOf(sessions), + trustedCount + ); + } + + private static UserPdaSnapshot buildSnapshot( + UserPdaMutation mutation, + int recordNumber, + long slot, + String txSignature, + long createdAtMs, + long updatedAtMs, + String prevRecordHash, + long paidLimitBytes + ) { + + UserFields fields = + mutation.fields(); + + UserPdaSnapshot snapshot = + new UserPdaSnapshot( + mutation.pdaAddress(), + mutation.login(), + recordNumber, + slot, + txSignature, + mutation.recoveryKey(), + mutation.rootKey(), + fields.clientKey(), + fields.blockchainName(), + fields.blockchainKey(), + paidLimitBytes, + fields.usedBytes(), + fields.lastBlockNumber(), + fields.lastBlockHash(), + fields.lastBlockSignature(), + fields.arweaveTxId(), + fields.isServer(), + fields.addressFormatType(), + fields.addressFormatVersion(), + fields.serverAddress(), + fields.syncServers(), + fields.accessServers(), + fields.sessionsMode(), + fields.sessions(), + fields.trustedCount(), + createdAtMs, + updatedAtMs, + prevRecordHash, + mutation.recordSignature(), + "" + ); + + return snapshot.withRawDataBase64( + serializeSnapshotToBase64( + snapshot + ) + ); + } + + private static List readInstructionAccounts( + JsonNode accountsNode + ) { + + List accounts = + new ArrayList<>(); + + if (!accountsNode.isArray()) { + return accounts; + } + + for (JsonNode node : accountsNode) { + accounts.add( + node.asText("") + ); + } + + return accounts; + } + + private static String accountAt( + List accounts, + int index + ) { + + if (index < 0 + || index >= accounts.size()) { + return null; + } + + String value = + accounts.get(index); + + return value == null + || value.isBlank() + ? null + : value; + } + + private static String toHex( + byte[] bytes + ) { + return HexFormat.of() + .formatHex(bytes); + } + + private static byte[] fromHex( + String hex + ) { + if (hex == null + || hex.isBlank()) { + return new byte[0]; + } + + return HexFormat.of() + .parseHex(hex); + } + + private static int u16le( + byte[] data, + int offset + ) { + return (data[offset] & 0xFF) + | ((data[offset + 1] & 0xFF) << 8); + } + + private static void pushString( + List out, + String value + ) { + pushStringU8( + out, + value.getBytes(StandardCharsets.UTF_8) + ); + } + + private static void pushStringU8( + List out, + byte[] bytes + ) { + out.add((byte) bytes.length); + pushFixed( + out, + bytes + ); + } + + private static void pushFixed( + List out, + byte[] bytes + ) { + for (byte value : bytes) { + out.add(value); + } + } + + private static void pushU16( + List out, + int value + ) { + out.add((byte) (value & 0xFF)); + out.add((byte) ((value >> 8) & 0xFF)); + } + + private static void pushU32( + List out, + int value + ) { + out.add((byte) (value & 0xFF)); + out.add((byte) ((value >> 8) & 0xFF)); + out.add((byte) ((value >> 16) & 0xFF)); + out.add((byte) ((value >> 24) & 0xFF)); + } + + private static void pushU64( + List out, + long value + ) { + out.add((byte) (value & 0xFF)); + out.add((byte) ((value >> 8) & 0xFF)); + out.add((byte) ((value >> 16) & 0xFF)); + out.add((byte) ((value >> 24) & 0xFF)); + out.add((byte) ((value >> 32) & 0xFF)); + out.add((byte) ((value >> 40) & 0xFF)); + out.add((byte) ((value >> 48) & 0xFF)); + out.add((byte) ((value >> 56) & 0xFF)); + } + + private static final class Reader { + + private final byte[] data; + private int cursor; + + private Reader( + byte[] data + ) { + this.data = data; + this.cursor = 0; + } + + private int readU8() { + return data[cursor++] & 0xFF; + } + + private long readU32() { + long value = + (data[cursor] & 0xFFL) + | ((data[cursor + 1] & 0xFFL) << 8) + | ((data[cursor + 2] & 0xFFL) << 16) + | ((data[cursor + 3] & 0xFFL) << 24); + + cursor += 4; + return value; + } + + private long readU64() { + long value = 0L; + for (int i = 0; i < 8; i++) { + value |= (long) (data[cursor + i] & 0xFF) << (8 * i); + } + cursor += 8; + return value; + } + + private byte[] readFixed( + int length + ) { + byte[] out = + new byte[length]; + System.arraycopy( + data, + cursor, + out, + 0, + length + ); + cursor += length; + return out; + } + + private String readStringU8() { + int length = + readU8(); + String value = + new String( + data, + cursor, + length, + StandardCharsets.UTF_8 + ); + cursor += length; + return value; + } + + private void skip( + int bytes + ) { + cursor += bytes; + } + } + + public enum TxKind { + INIT_USERS_ECONOMY_CONFIG, + UPDATE_USERS_ECONOMY_CONFIG, + CREATE_USER_PDA, + UPDATE_USER_PDA, + OTHER + } + + public record ParsedInstruction( + TxKind kind, + boolean relevant, + String affectedPdaAddress, + String affectedLogin, + EconomyConfigState economyConfigState, + UserPdaMutation userPdaMutation + ) { + } + + public record EconomyConfigState( + int version, + long registrationFeeLamports, + long lamportsPerLimitStep, + long startBonusLimit + ) { + public static EconomyConfigState initial() { + return new EconomyConfigState( + 1, + START_REGISTRATION_FEE_LAMPORTS, + START_LAMPORTS_PER_LIMIT_STEP, + START_BONUS_LIMIT + ); + } + } + + public record UserSessionSnapshot( + int sessionType, + int sessionVersion, + String sessionName, + String sessionPubKey + ) { + } + + public record UserFields( + String clientKey, + String blockchainKey, + String blockchainName, + long usedBytes, + int lastBlockNumber, + String lastBlockHash, + String lastBlockSignature, + String arweaveTxId, + boolean isServer, + int addressFormatType, + int addressFormatVersion, + String serverAddress, + List syncServers, + List accessServers, + int sessionsMode, + List sessions, + int trustedCount + ) { + } + + private record CreateOrUpdateArgs( + String login, + String recoveryKey, + String rootKey, + long createdAtMs, + long additionalLimit, + UserFields fields, + String recordSignature + ) { + } + + private record UpdateArgs( + String login, + String recoveryKey, + String rootKey, + long createdAtMs, + long updatedAtMs, + int version, + byte[] prevHash, + long additionalLimit, + UserFields fields, + String recordSignature + ) { + } + + public record UserPdaMutation( + boolean create, + String pdaAddress, + String login, + String recoveryKey, + String rootKey, + long createdAtMs, + long updatedAtMs, + int version, + byte[] prevHash, + long additionalLimit, + UserFields fields, + String recordSignature + ) { + } + + public record UserPdaSnapshot( + String pdaAddress, + String login, + int recordNumber, + long slot, + String lastTxSignature, + String recoveryKey, + String rootKey, + String clientKey, + String blockchainName, + String blockchainKey, + long paidLimitBytes, + long usedBytes, + int lastBlockNumber, + String lastBlockHash, + String lastBlockSignature, + String arweaveTxId, + boolean isServer, + int addressFormatType, + int addressFormatVersion, + String serverAddress, + List syncServers, + List accessServers, + int sessionsMode, + List sessions, + int trustedCount, + long createdAtMs, + long updatedAtMs, + String prevRecordHash, + String recordSignature, + String rawDataBase64 + ) { + public UserPdaSnapshot withRawDataBase64( + String value + ) { + return new UserPdaSnapshot( + pdaAddress, + login, + recordNumber, + slot, + lastTxSignature, + recoveryKey, + rootKey, + clientKey, + blockchainName, + blockchainKey, + paidLimitBytes, + usedBytes, + lastBlockNumber, + lastBlockHash, + lastBlockSignature, + arweaveTxId, + isServer, + addressFormatType, + addressFormatVersion, + serverAddress, + syncServers, + accessServers, + sessionsMode, + sessions, + trustedCount, + createdAtMs, + updatedAtMs, + prevRecordHash, + recordSignature, + value + ); + } + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/config/AppConfig.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/config/AppConfig.java new file mode 100644 index 00000000..9932f9c5 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/config/AppConfig.java @@ -0,0 +1,234 @@ +package sync.config; + +import java.time.Duration; + +public record AppConfig( + String rpcUrl, + String websocketUrl, + String programId, + String databaseUrl, + String databaseUser, + String databasePassword, + Duration pollInterval, + String commitment +) { + + public static final String DEFAULT_PROGRAM_ID = + "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6"; + + public static final String FIXED_COMMITMENT = + "confirmed"; + + public static final String ENABLED_KEY = + "solana.users.sync.enabled"; + public static final String RPC_URL_KEY = + "solana.users.sync.rpcUrl"; + public static final String WEBSOCKET_URL_KEY = + "solana.users.sync.wsUrl"; + public static final String PROGRAM_ID_KEY = + "solana.users.sync.programId"; + public static final String DATABASE_URL_KEY = + "solana.users.sync.databaseUrl"; + public static final String DATABASE_USER_KEY = + "solana.users.sync.dbUser"; + public static final String DATABASE_PASSWORD_KEY = + "solana.users.sync.dbPassword"; + public static final String POLL_INTERVAL_KEY = + "solana.users.sync.pollIntervalSeconds"; + public static final String LEGACY_SOLANA_RPC_URL_KEY = + "solana.rpcUrl"; + + public static boolean isEnabled( + utils.config.AppConfig serverConfig + ) { + String value = + trimToNull( + serverConfig.getParam(ENABLED_KEY) + ); + + if (value == null) { + return false; + } + + return Boolean.parseBoolean(value); + } + + public static AppConfig fromServerConfig( + utils.config.AppConfig serverConfig + ) { + + String rpcUrl = + firstRequired( + serverConfig, + "Solana users sync RPC URL", + RPC_URL_KEY, + LEGACY_SOLANA_RPC_URL_KEY + ); + + String websocketUrl = + requireParam( + serverConfig, + WEBSOCKET_URL_KEY + ); + + String programId = + optionalParam( + serverConfig, + PROGRAM_ID_KEY + ); + + if (programId == null) { + programId = + DEFAULT_PROGRAM_ID; + } + + String databaseUrl = + requireParam( + serverConfig, + DATABASE_URL_KEY + ); + + String databaseUser = + requireParam( + serverConfig, + DATABASE_USER_KEY + ); + + String databasePassword = + requireParam( + serverConfig, + DATABASE_PASSWORD_KEY + ); + + long pollIntervalSeconds = + parsePositiveLong( + optionalParam( + serverConfig, + POLL_INTERVAL_KEY + ), + 300L, + POLL_INTERVAL_KEY + ); + + return new AppConfig( + rpcUrl, + websocketUrl, + programId, + databaseUrl, + databaseUser, + databasePassword, + Duration.ofSeconds( + pollIntervalSeconds + ), + FIXED_COMMITMENT + ); + } + + private static long parsePositiveLong( + String rawValue, + long defaultValue, + String envName + ) { + + if (rawValue == null) { + return defaultValue; + } + + try { + + long value = + Long.parseLong( + rawValue + ); + + if (value <= 0L) { + throw new IllegalArgumentException( + envName + " must be > 0" + ); + } + + return value; + + } catch (NumberFormatException exception) { + + throw new IllegalArgumentException( + envName + " must be a positive integer", + exception + ); + } + } + + private static String firstRequired( + utils.config.AppConfig serverConfig, + String humanName, + String... names + ) { + + for (String name : names) { + String value = + optionalParam( + serverConfig, + name + ); + + if (value != null) { + return value; + } + } + + throw new IllegalStateException( + "Missing required server config: " + + humanName + ); + } + + private static String requireParam( + utils.config.AppConfig serverConfig, + String name + ) { + + String value = + optionalParam( + serverConfig, + name + ); + + if (value == null) { + throw new IllegalStateException( + "Missing required server config: " + + name + ); + } + + return value; + } + + private static String optionalParam( + utils.config.AppConfig serverConfig, + String name + ) { + + String value = + serverConfig.getParam(name); + + return trimToNull(value); + } + + private static String trimToNull( + String value + ) { + + if (value == null) { + return null; + } + + String trimmed = + value.trim(); + + if (trimmed.isEmpty()) { + return null; + } + + return trimmed; + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/model/ProgramAccountUpdate.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/model/ProgramAccountUpdate.java new file mode 100644 index 00000000..c86a6da3 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/model/ProgramAccountUpdate.java @@ -0,0 +1,12 @@ +package sync.model; + +public record ProgramAccountUpdate( + String address, + String owner, + long lamports, + long slot, + String dataBase64, + boolean executable, + Long rentEpoch +) { +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/model/SnapshotResult.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/model/SnapshotResult.java new file mode 100644 index 00000000..62f12d22 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/model/SnapshotResult.java @@ -0,0 +1,9 @@ +package sync.model; + +import java.util.List; + +public record SnapshotResult( + long snapshotSlot, + List accounts +) { +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java new file mode 100644 index 00000000..5ef0b1c0 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java @@ -0,0 +1,822 @@ +package sync.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sync.codec.ShineUsersCodec; +import sync.config.AppConfig; +import sync.model.ProgramAccountUpdate; +import sync.model.SnapshotResult; +import sync.source.AccountUpdateListener; +import sync.source.ConnectionListener; +import sync.source.rpc.SolanaRpcClient; +import sync.source.rpc.SolanaWebSocketClient; +import sync.storage.postgres.PostgresStorageRepository; +import sync.util.SolanaPdaUtil; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class SolanaUsersSyncService + implements AutoCloseable { + + private static final int FULL_SNAPSHOT_PROGRESS_STEP = + 100; + + private static final Logger log = + LoggerFactory.getLogger( + SolanaUsersSyncService.class + ); + + private final AppConfig config; + private final ObjectMapper mapper; + private final PostgresStorageRepository storage; + private final SolanaRpcClient rpcClient; + private final SolanaWebSocketClient webSocketClient; + private final ExecutorService syncExecutor; + private final ScheduledExecutorService pollScheduler; + private final CompletableFuture readyFuture = + new CompletableFuture<>(); + private final AtomicBoolean closed = + new AtomicBoolean(false); + private final AtomicBoolean syncRequested = + new AtomicBoolean(false); + private final AtomicBoolean syncWorkerScheduled = + new AtomicBoolean(false); + private final String economyConfigPda; + + private volatile boolean initialSyncCompleted = + false; + + public SolanaUsersSyncService( + AppConfig config + ) throws Exception { + + this.config = + config; + + this.mapper = + new ObjectMapper(); + + this.storage = + new PostgresStorageRepository( + config.databaseUrl(), + config.databaseUser(), + config.databasePassword(), + mapper + ); + + this.rpcClient = + new SolanaRpcClient( + config.rpcUrl(), + config.programId(), + config.commitment() + ); + + this.webSocketClient = + new SolanaWebSocketClient( + config.websocketUrl(), + config.programId(), + config.commitment() + ); + + this.syncExecutor = + Executors.newSingleThreadExecutor( + runnable -> { + Thread thread = + new Thread( + runnable, + "solana-users-sync-worker" + ); + thread.setDaemon(true); + return thread; + } + ); + + this.pollScheduler = + Executors.newSingleThreadScheduledExecutor( + runnable -> { + Thread thread = + new Thread( + runnable, + "solana-users-sync-periodic" + ); + thread.setDaemon(true); + return thread; + } + ); + + this.economyConfigPda = + SolanaPdaUtil.findProgramAddress( + List.of( + "shine_users_economy_config" + .getBytes(StandardCharsets.UTF_8) + ), + config.programId() + ); + } + + public void start() + throws Exception { + + log.info( + "Starting sync service. programId={} economyConfigPda={} pollInterval={}", + config.programId(), + economyConfigPda, + config.pollInterval() + ); + + storage.updateLifecycleState( + "STARTING", + false, + null, + null, + null + ); + + webSocketClient.start( + new AccountUpdateListener() { + @Override + public void onAccountUpdate( + ProgramAccountUpdate update + ) { + log.debug( + "Realtime notification received. address={} slot={}", + update.address(), + update.slot() + ); + requestSync( + "realtime" + ); + } + }, + new ConnectionListener() { + @Override + public void onConnected( + boolean firstConnection + ) { + log.info( + "Solana websocket connected. firstConnection={}", + firstConnection + ); + requestSync( + firstConnection + ? "initial-connect" + : "reconnect" + ); + } + + @Override + public void onDisconnected( + Throwable cause + ) { + log.warn( + "Solana websocket disconnected: {}", + cause == null + ? "unknown" + : cause.getMessage() + ); + } + } + ); + + long periodSeconds = + config.pollInterval() + .getSeconds(); + + pollScheduler.scheduleWithFixedDelay( + () -> requestSync( + "periodic" + ), + periodSeconds, + periodSeconds, + TimeUnit.SECONDS + ); + } + + public void awaitReady() + throws Exception { + readyFuture.get(); + } + + public boolean isReady() { + return readyFuture.isDone() + && !readyFuture.isCompletedExceptionally(); + } + + private void requestSync( + String reason + ) { + + if (closed.get()) { + return; + } + + syncRequested.set(true); + + if (!syncWorkerScheduled.compareAndSet( + false, + true + )) { + return; + } + + syncExecutor.submit( + () -> runSyncLoop(reason) + ); + } + + private void runSyncLoop( + String firstReason + ) { + + String reason = + firstReason; + + try { + + while (!closed.get()) { + + boolean shouldRun = + syncRequested.getAndSet(false); + + if (!shouldRun) { + return; + } + + performSync(reason); + reason = "coalesced"; + } + + } catch (Exception exception) { + + log.error( + "Sync loop failed", + exception + ); + + try { + storage.updateLifecycleState( + "FAILED", + false, + exception.getMessage(), + System.currentTimeMillis(), + null + ); + } catch (Exception storageException) { + log.error( + "Failed to persist sync failure state", + storageException + ); + } + + readyFuture.completeExceptionally( + exception + ); + + } finally { + + syncWorkerScheduled.set(false); + + if (syncRequested.get() + && !closed.get() + && syncWorkerScheduled.compareAndSet( + false, + true + )) { + syncExecutor.submit( + () -> runSyncLoop("rescheduled") + ); + } + } + } + + private void performSync( + String reason + ) throws Exception { + + long nowMs = + System.currentTimeMillis(); + + PostgresStorageRepository.SyncStateSnapshot state = + storage.loadState(); + + log.info( + "Starting history sync. reason={} lastSeenSignature={}", + reason, + state.lastSeenSignature() + ); + + storage.updateLifecycleState( + initialSyncCompleted + ? "SYNCING" + : "BOOTSTRAPPING", + false, + null, + nowMs, + null + ); + + SolanaRpcClient.SignatureFetchResult fetchResult = + rpcClient.getSignaturesForAddressSince( + economyConfigPda, + state.lastSeenSignature() + ); + + if (state.lastSeenSignature() != null + && !fetchResult.anchorFound()) { + + log.error( + "History anchor signature not found anymore: {}. Running current-state full snapshot fallback.", + state.lastSeenSignature() + ); + + runFullSnapshotFallback( + state, + fetchResult, + nowMs + ); + + markReadyAfterSync( + state, + nowMs + ); + + return; + } + + if (fetchResult.signatures().isEmpty()) { + + PostgresStorageRepository.SyncStateSnapshot newState = + new PostgresStorageRepository.SyncStateSnapshot( + "READY", + true, + nowMs, + nowMs, + state.lastSeenSignature(), + state.lastSeenSlot(), + state.lastRelevantSignature(), + state.lastRelevantSlot(), + null, + state.economyConfigState(), + nowMs + ); + + storage.applyHistoryBatch( + List.of(), + List.of(), + newState + ); + + log.info( + "History sync completed with no new transactions." + ); + + markReadyAfterSync( + newState, + nowMs + ); + + return; + } + + List chronologicalSignatures = + new ArrayList<>( + fetchResult.signatures() + ); + + Collections.reverse( + chronologicalSignatures + ); + + List envelopes = + new ArrayList<>(); + + Set updatePdaAddresses = + new LinkedHashSet<>(); + + for (SolanaRpcClient.SignatureRecord signatureRecord : chronologicalSignatures) { + + JsonNode transaction = + rpcClient.getTransactionJsonParsed( + signatureRecord.signature() + ); + + ParsedTxEnvelope envelope = + parseTransactionEnvelope( + signatureRecord, + transaction + ); + + envelopes.add( + envelope + ); + + if (envelope.parsedInstruction() != null + && envelope.parsedInstruction().kind() == ShineUsersCodec.TxKind.UPDATE_USER_PDA + && envelope.parsedInstruction().affectedPdaAddress() != null) { + updatePdaAddresses.add( + envelope.parsedInstruction() + .affectedPdaAddress() + ); + } + } + + Map currentSnapshots = + storage.getCurrentSnapshots( + updatePdaAddresses + ); + + List txEntries = + new ArrayList<>(); + + List snapshotsToPersist = + new ArrayList<>(); + + ShineUsersCodec.EconomyConfigState economyState = + state.economyConfigState(); + + String lastRelevantSignature = + state.lastRelevantSignature(); + + Long lastRelevantSlot = + state.lastRelevantSlot(); + + for (ParsedTxEnvelope envelope : envelopes) { + + ShineUsersCodec.ParsedInstruction parsedInstruction = + envelope.parsedInstruction(); + + String txKind = + parsedInstruction == null + ? "failed_or_unavailable" + : parsedInstruction.kind().name(); + + boolean relevant = + false; + + String affectedPdaAddress = + null; + + String affectedLogin = + null; + + if (parsedInstruction != null) { + + if (parsedInstruction.kind() == ShineUsersCodec.TxKind.INIT_USERS_ECONOMY_CONFIG + || parsedInstruction.kind() == ShineUsersCodec.TxKind.UPDATE_USERS_ECONOMY_CONFIG) { + economyState = + parsedInstruction.economyConfigState(); + } + + if (parsedInstruction.relevant() + && parsedInstruction.userPdaMutation() != null) { + + relevant = true; + affectedPdaAddress = parsedInstruction.affectedPdaAddress(); + affectedLogin = parsedInstruction.affectedLogin(); + + ShineUsersCodec.UserPdaSnapshot snapshot; + + if (parsedInstruction.kind() == ShineUsersCodec.TxKind.CREATE_USER_PDA) { + + if (economyState == null) { + economyState = + ShineUsersCodec.EconomyConfigState.initial(); + log.warn( + "Economy config state was absent while processing create tx {}. Falling back to initial constants.", + envelope.signatureRecord().signature() + ); + } + + snapshot = + ShineUsersCodec.buildCreateSnapshot( + parsedInstruction.userPdaMutation(), + economyState, + envelope.signatureRecord().signature(), + envelope.signatureRecord().slot() + ); + + } else { + + ShineUsersCodec.UserPdaSnapshot previous = + currentSnapshots.get( + affectedPdaAddress + ); + + if (previous == null) { + throw new IllegalStateException( + "Missing previous snapshot for update PDA " + + affectedPdaAddress + ); + } + + snapshot = + ShineUsersCodec.buildUpdateSnapshot( + parsedInstruction.userPdaMutation(), + previous, + envelope.signatureRecord().signature(), + envelope.signatureRecord().slot() + ); + } + + currentSnapshots.put( + snapshot.pdaAddress(), + snapshot + ); + + snapshotsToPersist.add( + snapshot + ); + + lastRelevantSignature = + envelope.signatureRecord().signature(); + + lastRelevantSlot = + envelope.signatureRecord().slot(); + } + } + + txEntries.add( + new PostgresStorageRepository.TxHistoryEntry( + envelope.signatureRecord().signature(), + envelope.signatureRecord().slot(), + envelope.signatureRecord().blockTime(), + txKind, + relevant, + affectedPdaAddress, + affectedLogin, + envelope.rawTransactionJson(), + nowMs + ) + ); + } + + SolanaRpcClient.SignatureRecord newestSeen = + fetchResult.signatures() + .get(0); + + PostgresStorageRepository.SyncStateSnapshot newState = + new PostgresStorageRepository.SyncStateSnapshot( + "READY", + true, + nowMs, + nowMs, + newestSeen.signature(), + newestSeen.slot(), + lastRelevantSignature, + lastRelevantSlot, + null, + economyState, + nowMs + ); + + storage.applyHistoryBatch( + txEntries, + snapshotsToPersist, + newState + ); + + log.info( + "History sync completed. txCount={} relevantCount={} latestSignature={}", + txEntries.size(), + snapshotsToPersist.size(), + newestSeen.signature() + ); + + markReadyAfterSync( + newState, + nowMs + ); + } + + private void runFullSnapshotFallback( + PostgresStorageRepository.SyncStateSnapshot state, + SolanaRpcClient.SignatureFetchResult fetchResult, + long nowMs + ) throws Exception { + + log.warn( + "Starting full snapshot fallback because incremental history anchor is unavailable." + ); + + SnapshotResult snapshotResult = + rpcClient.loadFullSnapshot(); + + log.info( + "Full snapshot downloaded. snapshotSlot={} rawAccounts={}", + snapshotResult.snapshotSlot(), + snapshotResult.accounts().size() + ); + + List currentSnapshots = + new ArrayList<>(); + + int processedAccounts = + 0; + + for (ProgramAccountUpdate account : snapshotResult.accounts()) { + + processedAccounts++; + + try { + currentSnapshots.add( + ShineUsersCodec.parseUserPdaAccount( + account.address(), + account.slot(), + account.dataBase64(), + state.lastSeenSignature() + ) + ); + } catch (Exception ignored) { + } + + if (processedAccounts == 1 + || processedAccounts % FULL_SNAPSHOT_PROGRESS_STEP == 0 + || processedAccounts == snapshotResult.accounts().size()) { + log.info( + "Full snapshot parse progress: {}/{} accounts, {} user PDA snapshots accepted.", + processedAccounts, + snapshotResult.accounts().size(), + currentSnapshots.size() + ); + } + } + + String newestSignature = + fetchResult.signatures().isEmpty() + ? state.lastSeenSignature() + : fetchResult.signatures().get(0).signature(); + + Long newestSlot = + fetchResult.signatures().isEmpty() + ? state.lastSeenSlot() + : fetchResult.signatures().get(0).slot(); + + PostgresStorageRepository.SyncStateSnapshot newState = + new PostgresStorageRepository.SyncStateSnapshot( + "READY", + true, + nowMs, + nowMs, + newestSignature, + newestSlot, + state.lastRelevantSignature(), + state.lastRelevantSlot(), + "history_anchor_missing_full_snapshot_fallback", + state.economyConfigState(), + nowMs + ); + + storage.replaceCurrentFromFullSnapshot( + currentSnapshots, + newState + ); + + log.warn( + "Full snapshot fallback completed. currentSnapshots={} newestSignature={} newestSlot={}", + currentSnapshots.size(), + newestSignature, + newestSlot + ); + } + + private void markReadyAfterSync( + PostgresStorageRepository.SyncStateSnapshot state, + long nowMs + ) throws Exception { + + initialSyncCompleted = true; + + if (!readyFuture.isDone()) { + readyFuture.complete(null); + log.info( + "Sync service entered READY state." + ); + } + + storage.updateLifecycleState( + "READY", + true, + state.lastError(), + nowMs, + nowMs + ); + } + + private ParsedTxEnvelope parseTransactionEnvelope( + SolanaRpcClient.SignatureRecord signatureRecord, + JsonNode transaction + ) { + + if (signatureRecord.failed()) { + return new ParsedTxEnvelope( + signatureRecord, + new ShineUsersCodec.ParsedInstruction( + ShineUsersCodec.TxKind.OTHER, + false, + null, + null, + null, + null + ), + transaction == null + ? "{\"failed\":true,\"error\":" + + String.valueOf(signatureRecord.errorJson()) + "}" + : transaction.toString() + ); + } + + if (transaction == null + || transaction.isNull()) { + return new ParsedTxEnvelope( + signatureRecord, + null, + "{\"transaction\":null}" + ); + } + + JsonNode instructions = + transaction.path("transaction") + .path("message") + .path("instructions"); + + if (instructions.isArray()) { + for (JsonNode instruction : instructions) { + ShineUsersCodec.ParsedInstruction parsedInstruction = + ShineUsersCodec.parseShineUsersInstruction( + instruction, + config.programId() + ); + + if (parsedInstruction != null) { + return new ParsedTxEnvelope( + signatureRecord, + parsedInstruction, + transaction.toString() + ); + } + } + } + + return new ParsedTxEnvelope( + signatureRecord, + new ShineUsersCodec.ParsedInstruction( + ShineUsersCodec.TxKind.OTHER, + false, + null, + null, + null, + null + ), + transaction.toString() + ); + } + + @Override + public void close() { + + if (!closed.compareAndSet( + false, + true + )) { + return; + } + + try { + pollScheduler.shutdownNow(); + } catch (Exception ignored) { + } + + try { + webSocketClient.close(); + } catch (Exception ignored) { + } + + try { + syncExecutor.shutdownNow(); + } catch (Exception ignored) { + } + + try { + rpcClient.close(); + } catch (Exception ignored) { + } + + try { + storage.close(); + } catch (Exception ignored) { + } + } + + private record ParsedTxEnvelope( + SolanaRpcClient.SignatureRecord signatureRecord, + ShineUsersCodec.ParsedInstruction parsedInstruction, + String rawTransactionJson + ) { + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/AccountUpdateListener.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/AccountUpdateListener.java new file mode 100644 index 00000000..619ccb55 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/AccountUpdateListener.java @@ -0,0 +1,11 @@ +package sync.source; + +import sync.model.ProgramAccountUpdate; + +@FunctionalInterface +public interface AccountUpdateListener { + + void onAccountUpdate( + ProgramAccountUpdate update + ); +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/ConnectionListener.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/ConnectionListener.java new file mode 100644 index 00000000..4c8badce --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/ConnectionListener.java @@ -0,0 +1,12 @@ +package sync.source; + +public interface ConnectionListener { + + void onConnected( + boolean firstConnection + ); + + void onDisconnected( + Throwable cause + ); +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/rpc/SolanaRpcClient.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/rpc/SolanaRpcClient.java new file mode 100644 index 00000000..bde598d8 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/rpc/SolanaRpcClient.java @@ -0,0 +1,737 @@ +package sync.source.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sync.model.ProgramAccountUpdate; +import sync.model.SnapshotResult; + +import java.io.IOException; +import java.time.Duration; +import java.util.*; + +public final class SolanaRpcClient + implements AutoCloseable { + + private static final Logger log = + LoggerFactory.getLogger( + SolanaRpcClient.class + ); + + private static final MediaType JSON = + MediaType.get("application/json"); + + private static final int SIGNATURE_PAGE_SIZE = + 1000; + + private static final int ACCOUNT_BATCH_SIZE = + 100; + + private final String rpcUrl; + private final String programId; + private final String commitment; + private final ObjectMapper mapper = + new ObjectMapper(); + private final OkHttpClient httpClient = + new OkHttpClient.Builder() + .callTimeout(Duration.ofMinutes(2)) + .build(); + + public SolanaRpcClient( + String rpcUrl, + String programId, + String commitment + ) { + this.rpcUrl = rpcUrl; + this.programId = programId; + this.commitment = commitment; + } + + public SnapshotResult loadFullSnapshot() + throws IOException { + + log.info( + "Requesting full snapshot via getProgramAccounts. programId={}", + programId + ); + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 100, + "method", "getProgramAccounts", + "params", List.of( + programId, + Map.of( + "encoding", "base64", + "commitment", commitment, + "withContext", true + ) + ) + ); + + JsonNode root = + executeRpc(payload); + + log.info( + "Full snapshot RPC response received. Parsing account list..." + ); + + JsonNode result = + root.path("result"); + + long snapshotSlot = + result.path("context") + .path("slot") + .asLong(-1); + + if (snapshotSlot < 0) { + throw new IOException( + "Missing context.slot in getProgramAccounts" + ); + } + + JsonNode values = + result.path("value"); + + if (!values.isArray()) { + throw new IOException( + "Unexpected getProgramAccounts response" + ); + } + + List accounts = + new ArrayList<>(); + + for (JsonNode item : values) { + + JsonNode account = + item.path("account"); + + JsonNode data = + account.path("data"); + + if (!data.isArray() + || data.isEmpty()) { + continue; + } + + accounts.add( + parseAccount( + item.path("pubkey") + .asText(), + account, + snapshotSlot + ) + ); + } + + log.info( + "Full snapshot RPC parsed. accounts={} snapshotSlot={}", + accounts.size(), + snapshotSlot + ); + + return new SnapshotResult( + snapshotSlot, + accounts + ); + } + + public SignatureFetchResult getSignaturesForAddressSince( + String address, + String knownSignature + ) throws IOException { + + List signatures = + new ArrayList<>(); + + String before = + null; + + boolean anchorFound = + knownSignature == null; + + while (true) { + + Map options = + new LinkedHashMap<>(); + + options.put( + "limit", + SIGNATURE_PAGE_SIZE + ); + + options.put( + "commitment", + commitment + ); + + if (before != null) { + options.put( + "before", + before + ); + } + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 103, + "method", "getSignaturesForAddress", + "params", List.of( + address, + options + ) + ); + + JsonNode root = + executeRpc(payload); + + JsonNode values = + root.path("result"); + + if (!values.isArray()) { + throw new IOException( + "Unexpected getSignaturesForAddress response" + ); + } + + if (values.isEmpty()) { + break; + } + + for (JsonNode item : values) { + + String signature = + item.path("signature") + .asText(""); + + if (signature.isBlank()) { + continue; + } + + if (knownSignature != null + && knownSignature.equals(signature)) { + anchorFound = true; + return new SignatureFetchResult( + signatures, + true + ); + } + + Long blockTime = + item.hasNonNull("blockTime") + ? item.get("blockTime").asLong() + : null; + + signatures.add( + new SignatureRecord( + signature, + item.path("slot") + .asLong(-1), + blockTime, + item.hasNonNull("err"), + item.path("err").toString() + ) + ); + } + + JsonNode last = + values.get(values.size() - 1); + + before = + last.path("signature") + .asText(""); + + if (before.isBlank() + || values.size() < SIGNATURE_PAGE_SIZE) { + break; + } + } + + return new SignatureFetchResult( + signatures, + anchorFound + ); + } + + public JsonNode getTransactionJsonParsed( + String signature + ) throws IOException { + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 104, + "method", "getTransaction", + "params", List.of( + signature, + Map.of( + "encoding", + "jsonParsed", + "commitment", + commitment, + "maxSupportedTransactionVersion", + 0 + ) + ) + ); + + JsonNode root = + executeRpc(payload); + + JsonNode result = + root.get("result"); + + if (result == null + || result.isNull()) { + return null; + } + + return result; + } + + public long getCurrentSlot() + throws IOException { + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 101, + "method", "getSlot", + "params", List.of( + Map.of( + "commitment", + commitment + ) + ) + ); + + JsonNode root = + executeRpc(payload); + + long slot = + root.path("result") + .asLong(-1); + + if (slot < 0) { + throw new IOException( + "Invalid getSlot response" + ); + } + + return slot; + } + + public AccountBatchResult getCurrentAccounts( + Collection addresses, + long recoverySlot + ) throws IOException { + + List updates = + new ArrayList<>(); + + List missingAddresses = + new ArrayList<>(); + + List addressList = + new ArrayList<>(addresses); + + for (int offset = 0; + offset < addressList.size(); + offset += ACCOUNT_BATCH_SIZE) { + + int end = + Math.min( + offset + ACCOUNT_BATCH_SIZE, + addressList.size() + ); + + List batch = + addressList.subList( + offset, + end + ); + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 105, + "method", "getMultipleAccounts", + "params", List.of( + batch, + Map.of( + "encoding", + "base64", + "commitment", + commitment + ) + ) + ); + + JsonNode root = + executeRpc(payload); + + JsonNode values = + root.path("result") + .path("value"); + + if (!values.isArray()) { + throw new IOException( + "Unexpected getMultipleAccounts response" + ); + } + + if (values.size() != batch.size()) { + throw new IOException( + "Unexpected getMultipleAccounts account count" + ); + } + + for (int i = 0; i < batch.size(); i++) { + + String address = + batch.get(i); + + JsonNode account = + values.get(i); + + if (account == null + || account.isNull()) { + missingAddresses.add(address); + continue; + } + + String owner = + account.path("owner") + .asText(""); + + if (!programId.equals(owner)) { + continue; + } + + JsonNode data = + account.path("data"); + + if (!data.isArray() + || data.isEmpty()) { + throw new IOException( + "Unexpected account.data format for " + + address + ); + } + + updates.add( + parseAccount( + address, + account, + recoverySlot + ) + ); + } + } + + return new AccountBatchResult( + updates, + missingAddresses + ); + } + + public long getFirstAvailableBlock() + throws IOException { + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 102, + "method", "getFirstAvailableBlock" + ); + + JsonNode root = + executeRpc(payload); + + return root.path("result") + .asLong(-1); + } + + public List getSignaturesAfterSlot( + long fromSlot, + long targetSlot + ) throws IOException { + + List signatures = + new ArrayList<>(); + + String before = + null; + + boolean lowerBoundaryReached = + false; + + while (!lowerBoundaryReached) { + + Map options = + new LinkedHashMap<>(); + + options.put("limit", SIGNATURE_PAGE_SIZE); + options.put("commitment", commitment); + + if (before != null) { + options.put("before", before); + } + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 106, + "method", "getSignaturesForAddress", + "params", List.of( + programId, + options + ) + ); + + JsonNode root = + executeRpc(payload); + + JsonNode values = + root.path("result"); + + if (!values.isArray()) { + throw new IOException( + "Unexpected getSignaturesForAddress response" + ); + } + + if (values.isEmpty()) { + break; + } + + for (JsonNode item : values) { + + long slot = + item.path("slot") + .asLong(-1); + + if (slot < 0) { + continue; + } + + if (slot <= fromSlot) { + lowerBoundaryReached = true; + break; + } + + if (slot > targetSlot) { + continue; + } + + String signature = + item.path("signature") + .asText(""); + + if (signature.isBlank()) { + continue; + } + + JsonNode error = + item.get("err"); + + if (error != null + && !error.isNull()) { + continue; + } + + signatures.add( + new SignatureInfo( + signature, + slot + ) + ); + } + + JsonNode last = + values.get(values.size() - 1); + + before = + last.path("signature") + .asText(""); + + if (before.isBlank() + || values.size() < SIGNATURE_PAGE_SIZE) { + break; + } + } + + return signatures; + } + + public Set getTouchedAddresses( + List signatures + ) throws IOException { + + Set addresses = + new LinkedHashSet<>(); + + for (SignatureInfo signatureInfo : signatures) { + + JsonNode transaction = + getTransactionJsonParsed( + signatureInfo.signature() + ); + + if (transaction == null + || transaction.isNull()) { + throw new IOException( + "Transaction unavailable during recovery: " + + signatureInfo.signature() + ); + } + + JsonNode accountKeys = + transaction.path("transaction") + .path("message") + .path("accountKeys"); + + if (!accountKeys.isArray()) { + throw new IOException( + "Missing accountKeys for transaction: " + + signatureInfo.signature() + ); + } + + for (JsonNode keyNode : accountKeys) { + + String pubkey; + + if (keyNode.isTextual()) { + pubkey = keyNode.asText(); + } else { + pubkey = keyNode.path("pubkey") + .asText(""); + } + + if (!pubkey.isBlank()) { + addresses.add(pubkey); + } + } + } + + addresses.remove(programId); + return addresses; + } + + private ProgramAccountUpdate parseAccount( + String address, + JsonNode account, + long slot + ) { + + JsonNode data = + account.path("data"); + + return new ProgramAccountUpdate( + address, + account.path("owner") + .asText(), + account.path("lamports") + .asLong(), + slot, + data.get(0) + .asText(), + account.path("executable") + .asBoolean(false), + account.hasNonNull("rentEpoch") + ? account.get("rentEpoch").asLong() + : null + ); + } + + private JsonNode executeRpc( + Map payload + ) throws IOException { + + Request request = + new Request.Builder() + .url(rpcUrl) + .post( + RequestBody.create( + mapper.writeValueAsBytes(payload), + JSON + ) + ) + .build(); + + try (Response response = + httpClient.newCall(request).execute()) { + + if (!response.isSuccessful()) { + throw new IOException( + "Solana RPC HTTP error: " + response.code() + ); + } + + ResponseBody body = + response.body(); + + if (body == null) { + throw new IOException( + "Solana RPC returned empty body" + ); + } + + JsonNode root = + mapper.readTree( + body.string() + ); + + if (root.has("error")) { + throw new IOException( + "Solana RPC error: " + root.get("error") + ); + } + + return root; + } + } + + @Override + public void close() { + + httpClient.dispatcher() + .executorService() + .shutdown(); + httpClient.connectionPool() + .evictAll(); + } + + public record SignatureRecord( + String signature, + long slot, + Long blockTime, + boolean failed, + String errorJson + ) { + } + + public record SignatureFetchResult( + List signatures, + boolean anchorFound + ) { + } + + public record AccountBatchResult( + List updates, + List missingAddresses + ) { + } + + public record SignatureInfo( + String signature, + long slot + ) { + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/rpc/SolanaWebSocketClient.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/rpc/SolanaWebSocketClient.java new file mode 100644 index 00000000..9320007e --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/rpc/SolanaWebSocketClient.java @@ -0,0 +1,542 @@ +package sync.source.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sync.model.ProgramAccountUpdate; +import sync.source.AccountUpdateListener; +import sync.source.ConnectionListener; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +public final class SolanaWebSocketClient + extends WebSocketListener + implements AutoCloseable { + + private static final Logger log = + LoggerFactory.getLogger( + SolanaWebSocketClient.class + ); + + private final String websocketUrl; + private final String programId; + private final String commitment; + + private final ObjectMapper mapper = + new ObjectMapper(); + + private final OkHttpClient httpClient; + + private final ScheduledExecutorService scheduler; + + private final AtomicBoolean closed = + new AtomicBoolean(false); + + private final AtomicInteger reconnectAttempt = + new AtomicInteger(0); + + private final AtomicBoolean everSubscribed = + new AtomicBoolean(false); + + private volatile WebSocket webSocket; + + private volatile AccountUpdateListener + accountUpdateListener; + + private volatile ConnectionListener + connectionListener; + + public SolanaWebSocketClient( + String websocketUrl, + String programId, + String commitment + ) { + this.websocketUrl = websocketUrl; + this.programId = programId; + this.commitment = commitment; + + this.httpClient = + new OkHttpClient.Builder() + .readTimeout( + Duration.ZERO + ) + .pingInterval( + Duration.ofSeconds(20) + ) + .build(); + + this.scheduler = + Executors + .newSingleThreadScheduledExecutor( + runnable -> { + + Thread thread = + new Thread( + runnable, + "solana-rpc-reconnect" + ); + + thread.setDaemon( + true + ); + + return thread; + } + ); + } + + public void start( + AccountUpdateListener accountUpdateListener, + ConnectionListener connectionListener + ) { + + this.accountUpdateListener = + accountUpdateListener; + + this.connectionListener = + connectionListener; + + connect(); + } + + private void connect() { + + if (closed.get()) { + return; + } + + log.info( + "Connecting to Solana WebSocket: {}", + websocketUrl + ); + + Request request = + new Request.Builder() + .url( + websocketUrl + ) + .build(); + + this.webSocket = + httpClient.newWebSocket( + request, + this + ); + } + + @Override + public void onOpen( + WebSocket webSocket, + Response response + ) { + + reconnectAttempt.set( + 0 + ); + + log.info( + "WebSocket connected" + ); + + sendProgramSubscribe( + webSocket + ); + } + + private void sendProgramSubscribe( + WebSocket webSocket + ) { + + try { + + Map request = + Map.of( + "jsonrpc", + "2.0", + "id", + 1, + "method", + "programSubscribe", + "params", + List.of( + programId, + Map.of( + "encoding", + "base64", + "commitment", + commitment + ) + ) + ); + + String payload = + mapper.writeValueAsString( + request + ); + + if (!webSocket.send( + payload + )) { + throw new IllegalStateException( + "WebSocket rejected subscription request" + ); + } + + log.info( + "Subscription request sent for program: {}", + programId + ); + + } catch (Exception exception) { + + log.error( + "Failed to send subscription request", + exception + ); + + webSocket.cancel(); + } + } + + @Override + public void onMessage( + WebSocket webSocket, + String text + ) { + + try { + + JsonNode root = + mapper.readTree( + text + ); + + if ( + root.has("id") + && root.has("result") + && root.get("id").asInt() == 1 + ) { + + log.info( + "Subscribed successfully. subscriptionId={}", + root.get("result").asText() + ); + + boolean firstConnection = + everSubscribed + .compareAndSet( + false, + true + ); + + ConnectionListener listener = + connectionListener; + + if (listener != null) { + listener.onConnected( + firstConnection + ); + } + + return; + } + + if (root.has("error")) { + + log.error( + "Solana websocket RPC error: {}", + root.get("error") + ); + + return; + } + + if (!"programNotification" + .equals( + root + .path("method") + .asText() + )) { + return; + } + + ProgramAccountUpdate update = + parseProgramNotification( + root + ); + + log.debug( + "Account update. address={} slot={} base64Chars={}", + update.address(), + update.slot(), + update.dataBase64().length() + ); + + AccountUpdateListener listener = + accountUpdateListener; + + if (listener != null) { + listener.onAccountUpdate( + update + ); + } + + } catch (Exception exception) { + + log.error( + "Failed to process WebSocket message. raw={}", + text, + exception + ); + } + } + + private ProgramAccountUpdate parseProgramNotification( + JsonNode root + ) { + + JsonNode result = + root + .path("params") + .path("result"); + + JsonNode context = + result.path("context"); + + JsonNode value = + result.path("value"); + + JsonNode account = + value.path("account"); + + JsonNode data = + account.path("data"); + + if (!data.isArray() + || data.isEmpty()) { + throw new IllegalArgumentException( + "Unexpected account.data format" + ); + } + + return new ProgramAccountUpdate( + requiredText( + value, + "pubkey" + ), + requiredText( + account, + "owner" + ), + requiredLong( + account, + "lamports" + ), + requiredLong( + context, + "slot" + ), + data.get(0).asText(), + account.path( + "executable" + ).asBoolean(false), + account.hasNonNull( + "rentEpoch" + ) + ? account + .get("rentEpoch") + .asLong() + : null + ); + } + + private String requiredText( + JsonNode node, + String field + ) { + + JsonNode value = + node.get(field); + + if (value == null + || value.isNull() + || value.asText().isBlank()) { + throw new IllegalArgumentException( + "Missing field: " + + field + ); + } + + return value.asText(); + } + + private long requiredLong( + JsonNode node, + String field + ) { + + JsonNode value = + node.get(field); + + if (value == null + || !value.isNumber()) { + throw new IllegalArgumentException( + "Missing numeric field: " + + field + ); + } + + return value.asLong(); + } + + @Override + public void onClosed( + WebSocket webSocket, + int code, + String reason + ) { + + log.warn( + "WebSocket closed. code={} reason={}", + code, + reason + ); + + notifyDisconnected( + new IllegalStateException( + "WebSocket closed: " + + reason + ) + ); + + scheduleReconnect(); + } + + @Override + public void onFailure( + WebSocket webSocket, + Throwable throwable, + Response response + ) { + + log.error( + "WebSocket failure", + throwable + ); + + if (response != null) { + log.error( + "WebSocket HTTP status: {}", + response.code() + ); + } + + notifyDisconnected( + throwable + ); + + scheduleReconnect(); + } + + private void notifyDisconnected( + Throwable cause + ) { + + ConnectionListener listener = + connectionListener; + + if (listener != null) { + + try { + listener.onDisconnected( + cause + ); + } catch (Exception exception) { + log.error( + "Connection listener failed", + exception + ); + } + } + } + + private void scheduleReconnect() { + + if (closed.get()) { + return; + } + + int attempt = + reconnectAttempt + .incrementAndGet(); + + long delaySeconds = + switch ( + Math.min( + attempt, + 5 + ) + ) { + case 1 -> 1; + case 2 -> 2; + case 3 -> 5; + case 4 -> 10; + default -> 30; + }; + + log.warn( + "Reconnect scheduled in {} seconds (attempt {})", + delaySeconds, + attempt + ); + + scheduler.schedule( + this::connect, + delaySeconds, + TimeUnit.SECONDS + ); + } + + public void stop() { + close(); + } + + @Override + public void close() { + + if (!closed.compareAndSet( + false, + true + )) { + return; + } + + WebSocket socket = + this.webSocket; + + if (socket != null) { + socket.close( + 1000, + "Application shutdown" + ); + } + + scheduler.shutdownNow(); + + httpClient + .dispatcher() + .executorService() + .shutdown(); + + httpClient + .connectionPool() + .evictAll(); + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java new file mode 100644 index 00000000..cdbe7d27 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java @@ -0,0 +1,1278 @@ +package sync.storage.postgres; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sync.codec.ShineUsersCodec; + +import java.sql.*; +import java.util.*; + +public final class PostgresStorageRepository + implements AutoCloseable { + + private static final Logger log = + LoggerFactory.getLogger( + PostgresStorageRepository.class + ); + + private final String databaseUrl; + private final String databaseUser; + private final String databasePassword; + private final ObjectMapper mapper; + + public PostgresStorageRepository( + String databaseUrl, + String databaseUser, + String databasePassword, + ObjectMapper mapper + ) throws Exception { + + this.databaseUrl = + databaseUrl; + this.databaseUser = + databaseUser; + this.databasePassword = + databasePassword; + this.mapper = + mapper; + + migrate(); + + log.info( + "PostgreSQL schema ready: {}", + databaseUrl + ); + } + + public synchronized SyncStateSnapshot loadState() + throws Exception { + + try (Connection connection = + openConnection(); + PreparedStatement statement = + connection.prepareStatement( + "SELECT " + + "status, ready, last_poll_at_ms, " + + "last_successful_poll_at_ms, " + + "last_seen_signature, last_seen_slot, " + + "last_relevant_signature, last_relevant_slot, " + + "last_error, economy_config_version, " + + "registration_fee_lamports, " + + "lamports_per_limit_step, " + + "start_bonus_limit, updated_at_ms " + + "FROM solana_sync_state WHERE id = 1" + ); + ResultSet resultSet = + statement.executeQuery()) { + + if (!resultSet.next()) { + return new SyncStateSnapshot( + "EMPTY", + false, + null, + null, + null, + null, + null, + null, + null, + null, + null + ); + } + + Integer economyVersion = + getNullableInt( + resultSet, + "economy_config_version" + ); + + ShineUsersCodec.EconomyConfigState economyConfigState = + null; + + if (economyVersion != null) { + economyConfigState = + new ShineUsersCodec.EconomyConfigState( + economyVersion, + resultSet.getLong( + "registration_fee_lamports" + ), + resultSet.getLong( + "lamports_per_limit_step" + ), + resultSet.getLong( + "start_bonus_limit" + ) + ); + } + + return new SyncStateSnapshot( + resultSet.getString("status"), + resultSet.getBoolean("ready"), + getNullableLong( + resultSet, + "last_poll_at_ms" + ), + getNullableLong( + resultSet, + "last_successful_poll_at_ms" + ), + resultSet.getString("last_seen_signature"), + getNullableLong( + resultSet, + "last_seen_slot" + ), + resultSet.getString("last_relevant_signature"), + getNullableLong( + resultSet, + "last_relevant_slot" + ), + resultSet.getString("last_error"), + economyConfigState, + getNullableLong( + resultSet, + "updated_at_ms" + ) + ); + } + } + + public synchronized boolean hasCurrentData() + throws Exception { + + try (Connection connection = + openConnection(); + PreparedStatement statement = + connection.prepareStatement( + "SELECT 1 FROM solana_user_pda_current LIMIT 1" + ); + ResultSet resultSet = + statement.executeQuery()) { + + return resultSet.next(); + } + } + + public synchronized Map getCurrentSnapshots( + Collection pdaAddresses + ) throws Exception { + + Map result = + new LinkedHashMap<>(); + + if (pdaAddresses == null + || pdaAddresses.isEmpty()) { + return result; + } + + String placeholders = + String.join( + ",", + Collections.nCopies( + pdaAddresses.size(), + "?" + ) + ); + + String sql = + "SELECT * FROM solana_user_pda_current " + + "WHERE pda_address IN (" + placeholders + ")"; + + try (Connection connection = + openConnection(); + PreparedStatement statement = + connection.prepareStatement(sql)) { + + int index = 1; + + for (String address : pdaAddresses) { + statement.setString( + index++, + address + ); + } + + try (ResultSet resultSet = + statement.executeQuery()) { + + while (resultSet.next()) { + ShineUsersCodec.UserPdaSnapshot snapshot = + mapSnapshot(resultSet); + result.put( + snapshot.pdaAddress(), + snapshot + ); + } + } + } + + return result; + } + + public synchronized void updateLifecycleState( + String status, + boolean ready, + String lastError, + Long lastPollAtMs, + Long lastSuccessfulPollAtMs + ) throws Exception { + + SyncStateSnapshot current = + loadState(); + + upsertSyncState( + new SyncStateSnapshot( + status, + ready, + lastPollAtMs != null + ? lastPollAtMs + : current.lastPollAtMs(), + lastSuccessfulPollAtMs != null + ? lastSuccessfulPollAtMs + : current.lastSuccessfulPollAtMs(), + current.lastSeenSignature(), + current.lastSeenSlot(), + current.lastRelevantSignature(), + current.lastRelevantSlot(), + lastError, + current.economyConfigState(), + System.currentTimeMillis() + ) + ); + } + + public synchronized void applyHistoryBatch( + List txEntries, + List snapshots, + SyncStateSnapshot newState + ) throws Exception { + + try (Connection connection = + openConnection()) { + + connection.setAutoCommit(false); + + try { + + upsertTxHistory( + connection, + txEntries + ); + + insertHistorySnapshots( + connection, + snapshots + ); + + upsertCurrentSnapshots( + connection, + snapshots + ); + + upsertSyncState( + connection, + newState + ); + + connection.commit(); + + } catch (Exception exception) { + + connection.rollback(); + throw exception; + } finally { + connection.setAutoCommit(true); + } + } + } + + public synchronized void replaceCurrentFromFullSnapshot( + List snapshots, + SyncStateSnapshot newState + ) throws Exception { + + try (Connection connection = + openConnection()) { + + connection.setAutoCommit(false); + + try (Statement statement = + connection.createStatement()) { + + statement.executeUpdate( + "TRUNCATE TABLE solana_user_pda_current" + ); + + upsertCurrentSnapshots( + connection, + snapshots + ); + + upsertSyncState( + connection, + newState + ); + + connection.commit(); + + } catch (Exception exception) { + + connection.rollback(); + throw exception; + } finally { + connection.setAutoCommit(true); + } + } + } + + private void upsertTxHistory( + Connection connection, + List txEntries + ) throws Exception { + + if (txEntries == null + || txEntries.isEmpty()) { + return; + } + + String sql = + "INSERT INTO solana_sync_tx_history (" + + "signature, slot, block_time, tx_kind, " + + "is_relevant, affected_pda_address, affected_login, " + + "raw_summary_json, processed_at_ms" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (signature) DO UPDATE SET " + + "slot = EXCLUDED.slot, " + + "block_time = EXCLUDED.block_time, " + + "tx_kind = EXCLUDED.tx_kind, " + + "is_relevant = EXCLUDED.is_relevant, " + + "affected_pda_address = EXCLUDED.affected_pda_address, " + + "affected_login = EXCLUDED.affected_login, " + + "raw_summary_json = EXCLUDED.raw_summary_json, " + + "processed_at_ms = EXCLUDED.processed_at_ms"; + + try (PreparedStatement statement = + connection.prepareStatement(sql)) { + + for (TxHistoryEntry entry : txEntries) { + + statement.setString( + 1, + entry.signature() + ); + statement.setLong( + 2, + entry.slot() + ); + if (entry.blockTime() == null) { + statement.setNull( + 3, + Types.BIGINT + ); + } else { + statement.setLong( + 3, + entry.blockTime() + ); + } + statement.setString( + 4, + entry.txKind() + ); + statement.setBoolean( + 5, + entry.relevant() + ); + statement.setString( + 6, + entry.affectedPdaAddress() + ); + statement.setString( + 7, + entry.affectedLogin() + ); + statement.setString( + 8, + entry.rawSummaryJson() + ); + statement.setLong( + 9, + entry.processedAtMs() + ); + statement.addBatch(); + } + + statement.executeBatch(); + } + } + + private void insertHistorySnapshots( + Connection connection, + List snapshots + ) throws Exception { + + if (snapshots == null + || snapshots.isEmpty()) { + return; + } + + String sql = + "INSERT INTO solana_user_pda_history (" + + "tx_signature, slot, block_time, pda_address, login, " + + "record_number, recovery_key, root_key, client_key, " + + "blockchain_name, blockchain_key, paid_limit_bytes, " + + "used_bytes, last_block_number, last_block_hash, " + + "last_block_signature, arweave_tx_id, is_server, " + + "address_format_type, address_format_version, " + + "server_address, sync_servers_json, access_servers_json, " + + "sessions_mode, sessions_json, trusted_count, " + + "created_at_ms, updated_at_ms, prev_record_hash, " + + "record_signature, raw_data_base64, saved_at_ms" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (pda_address, record_number) DO NOTHING"; + + try (PreparedStatement statement = + connection.prepareStatement(sql)) { + + long nowMs = + System.currentTimeMillis(); + + for (ShineUsersCodec.UserPdaSnapshot snapshot : snapshots) { + + bindSnapshot( + statement, + snapshot, + nowMs + ); + statement.addBatch(); + } + + statement.executeBatch(); + } + } + + private void upsertCurrentSnapshots( + Connection connection, + List snapshots + ) throws Exception { + + if (snapshots == null + || snapshots.isEmpty()) { + return; + } + + String sql = + "INSERT INTO solana_user_pda_current (" + + "pda_address, login, record_number, slot, last_tx_signature, " + + "recovery_key, root_key, client_key, blockchain_name, " + + "blockchain_key, paid_limit_bytes, used_bytes, " + + "last_block_number, last_block_hash, last_block_signature, " + + "arweave_tx_id, is_server, address_format_type, " + + "address_format_version, server_address, sync_servers_json, " + + "access_servers_json, sessions_mode, sessions_json, " + + "trusted_count, created_at_ms, updated_at_ms, " + + "prev_record_hash, record_signature, raw_data_base64, " + + "first_seen_at_ms, last_synced_at_ms" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (pda_address) DO UPDATE SET " + + "login = EXCLUDED.login, " + + "record_number = EXCLUDED.record_number, " + + "slot = EXCLUDED.slot, " + + "last_tx_signature = EXCLUDED.last_tx_signature, " + + "recovery_key = EXCLUDED.recovery_key, " + + "root_key = EXCLUDED.root_key, " + + "client_key = EXCLUDED.client_key, " + + "blockchain_name = EXCLUDED.blockchain_name, " + + "blockchain_key = EXCLUDED.blockchain_key, " + + "paid_limit_bytes = EXCLUDED.paid_limit_bytes, " + + "used_bytes = EXCLUDED.used_bytes, " + + "last_block_number = EXCLUDED.last_block_number, " + + "last_block_hash = EXCLUDED.last_block_hash, " + + "last_block_signature = EXCLUDED.last_block_signature, " + + "arweave_tx_id = EXCLUDED.arweave_tx_id, " + + "is_server = EXCLUDED.is_server, " + + "address_format_type = EXCLUDED.address_format_type, " + + "address_format_version = EXCLUDED.address_format_version, " + + "server_address = EXCLUDED.server_address, " + + "sync_servers_json = EXCLUDED.sync_servers_json, " + + "access_servers_json = EXCLUDED.access_servers_json, " + + "sessions_mode = EXCLUDED.sessions_mode, " + + "sessions_json = EXCLUDED.sessions_json, " + + "trusted_count = EXCLUDED.trusted_count, " + + "created_at_ms = EXCLUDED.created_at_ms, " + + "updated_at_ms = EXCLUDED.updated_at_ms, " + + "prev_record_hash = EXCLUDED.prev_record_hash, " + + "record_signature = EXCLUDED.record_signature, " + + "raw_data_base64 = EXCLUDED.raw_data_base64, " + + "last_synced_at_ms = EXCLUDED.last_synced_at_ms " + + "WHERE EXCLUDED.record_number >= solana_user_pda_current.record_number"; + + try (PreparedStatement statement = + connection.prepareStatement(sql)) { + + long nowMs = + System.currentTimeMillis(); + + for (ShineUsersCodec.UserPdaSnapshot snapshot : snapshots) { + + bindCurrentSnapshot( + statement, + snapshot, + nowMs + ); + statement.addBatch(); + } + + statement.executeBatch(); + } + } + + private void bindSnapshot( + PreparedStatement statement, + ShineUsersCodec.UserPdaSnapshot snapshot, + long nowMs + ) throws Exception { + + statement.setString( + 1, + snapshot.lastTxSignature() + ); + statement.setLong( + 2, + snapshot.slot() + ); + statement.setNull( + 3, + Types.BIGINT + ); + statement.setString( + 4, + snapshot.pdaAddress() + ); + statement.setString( + 5, + snapshot.login() + ); + statement.setInt( + 6, + snapshot.recordNumber() + ); + statement.setString( + 7, + snapshot.recoveryKey() + ); + statement.setString( + 8, + snapshot.rootKey() + ); + statement.setString( + 9, + snapshot.clientKey() + ); + statement.setString( + 10, + snapshot.blockchainName() + ); + statement.setString( + 11, + snapshot.blockchainKey() + ); + statement.setLong( + 12, + snapshot.paidLimitBytes() + ); + statement.setLong( + 13, + snapshot.usedBytes() + ); + statement.setInt( + 14, + snapshot.lastBlockNumber() + ); + statement.setString( + 15, + snapshot.lastBlockHash() + ); + statement.setString( + 16, + snapshot.lastBlockSignature() + ); + statement.setString( + 17, + snapshot.arweaveTxId() + ); + statement.setBoolean( + 18, + snapshot.isServer() + ); + statement.setInt( + 19, + snapshot.addressFormatType() + ); + statement.setInt( + 20, + snapshot.addressFormatVersion() + ); + statement.setString( + 21, + snapshot.serverAddress() + ); + statement.setString( + 22, + writeJson( + snapshot.syncServers() + ) + ); + statement.setString( + 23, + writeJson( + snapshot.accessServers() + ) + ); + statement.setInt( + 24, + snapshot.sessionsMode() + ); + statement.setString( + 25, + writeJson( + snapshot.sessions() + ) + ); + statement.setInt( + 26, + snapshot.trustedCount() + ); + statement.setLong( + 27, + snapshot.createdAtMs() + ); + statement.setLong( + 28, + snapshot.updatedAtMs() + ); + statement.setString( + 29, + snapshot.prevRecordHash() + ); + statement.setString( + 30, + snapshot.recordSignature() + ); + statement.setString( + 31, + snapshot.rawDataBase64() + ); + statement.setLong( + 32, + nowMs + ); + } + + private void bindCurrentSnapshot( + PreparedStatement statement, + ShineUsersCodec.UserPdaSnapshot snapshot, + long nowMs + ) throws Exception { + + statement.setString(1, snapshot.pdaAddress()); + statement.setString(2, snapshot.login()); + statement.setInt(3, snapshot.recordNumber()); + statement.setLong(4, snapshot.slot()); + statement.setString(5, snapshot.lastTxSignature()); + statement.setString(6, snapshot.recoveryKey()); + statement.setString(7, snapshot.rootKey()); + statement.setString(8, snapshot.clientKey()); + statement.setString(9, snapshot.blockchainName()); + statement.setString(10, snapshot.blockchainKey()); + statement.setLong(11, snapshot.paidLimitBytes()); + statement.setLong(12, snapshot.usedBytes()); + statement.setInt(13, snapshot.lastBlockNumber()); + statement.setString(14, snapshot.lastBlockHash()); + statement.setString(15, snapshot.lastBlockSignature()); + statement.setString(16, snapshot.arweaveTxId()); + statement.setBoolean(17, snapshot.isServer()); + statement.setInt(18, snapshot.addressFormatType()); + statement.setInt(19, snapshot.addressFormatVersion()); + statement.setString(20, snapshot.serverAddress()); + statement.setString(21, writeJson(snapshot.syncServers())); + statement.setString(22, writeJson(snapshot.accessServers())); + statement.setInt(23, snapshot.sessionsMode()); + statement.setString(24, writeJson(snapshot.sessions())); + statement.setInt(25, snapshot.trustedCount()); + statement.setLong(26, snapshot.createdAtMs()); + statement.setLong(27, snapshot.updatedAtMs()); + statement.setString(28, snapshot.prevRecordHash()); + statement.setString(29, snapshot.recordSignature()); + statement.setString(30, snapshot.rawDataBase64()); + statement.setLong(31, nowMs); + statement.setLong(32, nowMs); + } + + private ShineUsersCodec.UserPdaSnapshot mapSnapshot( + ResultSet resultSet + ) throws Exception { + + List syncServers = + Arrays.asList( + mapper.readValue( + resultSet.getString("sync_servers_json"), + String[].class + ) + ); + + List accessServers = + Arrays.asList( + mapper.readValue( + resultSet.getString("access_servers_json"), + String[].class + ) + ); + + List sessions = + Arrays.asList( + mapper.readValue( + resultSet.getString("sessions_json"), + ShineUsersCodec.UserSessionSnapshot[].class + ) + ); + + return new ShineUsersCodec.UserPdaSnapshot( + resultSet.getString("pda_address"), + resultSet.getString("login"), + resultSet.getInt("record_number"), + resultSet.getLong("slot"), + resultSet.getString("last_tx_signature"), + resultSet.getString("recovery_key"), + resultSet.getString("root_key"), + resultSet.getString("client_key"), + resultSet.getString("blockchain_name"), + resultSet.getString("blockchain_key"), + resultSet.getLong("paid_limit_bytes"), + resultSet.getLong("used_bytes"), + resultSet.getInt("last_block_number"), + resultSet.getString("last_block_hash"), + resultSet.getString("last_block_signature"), + resultSet.getString("arweave_tx_id"), + resultSet.getBoolean("is_server"), + resultSet.getInt("address_format_type"), + resultSet.getInt("address_format_version"), + resultSet.getString("server_address"), + List.copyOf(syncServers), + List.copyOf(accessServers), + resultSet.getInt("sessions_mode"), + List.copyOf(sessions), + resultSet.getInt("trusted_count"), + resultSet.getLong("created_at_ms"), + resultSet.getLong("updated_at_ms"), + resultSet.getString("prev_record_hash"), + resultSet.getString("record_signature"), + resultSet.getString("raw_data_base64") + ); + } + + private void upsertSyncState( + SyncStateSnapshot snapshot + ) throws Exception { + + try (Connection connection = + openConnection()) { + upsertSyncState( + connection, + snapshot + ); + } + } + + private void upsertSyncState( + Connection connection, + SyncStateSnapshot snapshot + ) throws Exception { + + String sql = + "INSERT INTO solana_sync_state (" + + "id, status, ready, last_poll_at_ms, " + + "last_successful_poll_at_ms, last_seen_signature, " + + "last_seen_slot, last_relevant_signature, " + + "last_relevant_slot, last_error, economy_config_version, " + + "registration_fee_lamports, lamports_per_limit_step, " + + "start_bonus_limit, updated_at_ms" + + ") VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (id) DO UPDATE SET " + + "status = EXCLUDED.status, " + + "ready = EXCLUDED.ready, " + + "last_poll_at_ms = EXCLUDED.last_poll_at_ms, " + + "last_successful_poll_at_ms = EXCLUDED.last_successful_poll_at_ms, " + + "last_seen_signature = EXCLUDED.last_seen_signature, " + + "last_seen_slot = EXCLUDED.last_seen_slot, " + + "last_relevant_signature = EXCLUDED.last_relevant_signature, " + + "last_relevant_slot = EXCLUDED.last_relevant_slot, " + + "last_error = EXCLUDED.last_error, " + + "economy_config_version = EXCLUDED.economy_config_version, " + + "registration_fee_lamports = EXCLUDED.registration_fee_lamports, " + + "lamports_per_limit_step = EXCLUDED.lamports_per_limit_step, " + + "start_bonus_limit = EXCLUDED.start_bonus_limit, " + + "updated_at_ms = EXCLUDED.updated_at_ms"; + + try (PreparedStatement statement = + connection.prepareStatement(sql)) { + + statement.setString(1, snapshot.status()); + statement.setBoolean(2, snapshot.ready()); + bindNullableLong(statement, 3, snapshot.lastPollAtMs()); + bindNullableLong(statement, 4, snapshot.lastSuccessfulPollAtMs()); + statement.setString(5, snapshot.lastSeenSignature()); + bindNullableLong(statement, 6, snapshot.lastSeenSlot()); + statement.setString(7, snapshot.lastRelevantSignature()); + bindNullableLong(statement, 8, snapshot.lastRelevantSlot()); + statement.setString(9, snapshot.lastError()); + + if (snapshot.economyConfigState() == null) { + statement.setNull(10, Types.INTEGER); + statement.setNull(11, Types.BIGINT); + statement.setNull(12, Types.BIGINT); + statement.setNull(13, Types.BIGINT); + } else { + statement.setInt(10, snapshot.economyConfigState().version()); + statement.setLong(11, snapshot.economyConfigState().registrationFeeLamports()); + statement.setLong(12, snapshot.economyConfigState().lamportsPerLimitStep()); + statement.setLong(13, snapshot.economyConfigState().startBonusLimit()); + } + + bindNullableLong(statement, 14, snapshot.updatedAtMs()); + statement.executeUpdate(); + } + } + + private void migrate() + throws Exception { + + try (Connection connection = + openConnection(); + Statement statement = + connection.createStatement()) { + + statement.executeUpdate( + "CREATE TABLE IF NOT EXISTS solana_sync_state (" + + "id INTEGER PRIMARY KEY CHECK (id = 1), " + + "status TEXT NOT NULL, " + + "ready BOOLEAN NOT NULL DEFAULT FALSE, " + + "last_poll_at_ms BIGINT, " + + "last_successful_poll_at_ms BIGINT, " + + "last_seen_signature TEXT, " + + "last_seen_slot BIGINT, " + + "last_relevant_signature TEXT, " + + "last_relevant_slot BIGINT, " + + "last_error TEXT, " + + "economy_config_version INTEGER, " + + "registration_fee_lamports BIGINT, " + + "lamports_per_limit_step BIGINT, " + + "start_bonus_limit BIGINT, " + + "updated_at_ms BIGINT" + + ")" + ); + + statement.executeUpdate( + "INSERT INTO solana_sync_state (id, status, ready) " + + "VALUES (1, 'EMPTY', FALSE) " + + "ON CONFLICT (id) DO NOTHING" + ); + + statement.executeUpdate( + "CREATE TABLE IF NOT EXISTS solana_sync_tx_history (" + + "signature TEXT PRIMARY KEY, " + + "slot BIGINT NOT NULL, " + + "block_time BIGINT, " + + "tx_kind TEXT NOT NULL, " + + "is_relevant BOOLEAN NOT NULL, " + + "affected_pda_address TEXT, " + + "affected_login TEXT, " + + "raw_summary_json TEXT NOT NULL, " + + "processed_at_ms BIGINT NOT NULL" + + ")" + ); + + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_sync_tx_history_slot " + + "ON solana_sync_tx_history(slot)" + ); + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_sync_tx_history_relevant " + + "ON solana_sync_tx_history(is_relevant)" + ); + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_sync_tx_history_login " + + "ON solana_sync_tx_history(affected_login)" + ); + + statement.executeUpdate( + "CREATE TABLE IF NOT EXISTS solana_user_pda_current (" + + "pda_address TEXT PRIMARY KEY, " + + "login TEXT NOT NULL UNIQUE, " + + "record_number INTEGER NOT NULL, " + + "slot BIGINT NOT NULL, " + + "last_tx_signature TEXT NOT NULL, " + + "recovery_key TEXT NOT NULL, " + + "root_key TEXT NOT NULL, " + + "client_key TEXT NOT NULL, " + + "blockchain_name TEXT NOT NULL, " + + "blockchain_key TEXT NOT NULL, " + + "paid_limit_bytes BIGINT NOT NULL, " + + "used_bytes BIGINT NOT NULL, " + + "last_block_number INTEGER NOT NULL, " + + "last_block_hash TEXT NOT NULL, " + + "last_block_signature TEXT NOT NULL, " + + "arweave_tx_id TEXT NOT NULL, " + + "is_server BOOLEAN NOT NULL, " + + "address_format_type INTEGER NOT NULL, " + + "address_format_version INTEGER NOT NULL, " + + "server_address TEXT NOT NULL, " + + "sync_servers_json TEXT NOT NULL, " + + "access_servers_json TEXT NOT NULL, " + + "sessions_mode INTEGER NOT NULL, " + + "sessions_json TEXT NOT NULL, " + + "trusted_count INTEGER NOT NULL, " + + "created_at_ms BIGINT NOT NULL, " + + "updated_at_ms BIGINT NOT NULL, " + + "prev_record_hash TEXT NOT NULL, " + + "record_signature TEXT NOT NULL, " + + "raw_data_base64 TEXT NOT NULL, " + + "first_seen_at_ms BIGINT NOT NULL, " + + "last_synced_at_ms BIGINT NOT NULL" + + ")" + ); + + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot " + + "ON solana_user_pda_current(slot)" + ); + + statement.executeUpdate( + "CREATE TABLE IF NOT EXISTS solana_user_pda_history (" + + "id BIGSERIAL PRIMARY KEY, " + + "tx_signature TEXT NOT NULL, " + + "slot BIGINT NOT NULL, " + + "block_time BIGINT, " + + "pda_address TEXT NOT NULL, " + + "login TEXT NOT NULL, " + + "record_number INTEGER NOT NULL, " + + "recovery_key TEXT NOT NULL, " + + "root_key TEXT NOT NULL, " + + "client_key TEXT NOT NULL, " + + "blockchain_name TEXT NOT NULL, " + + "blockchain_key TEXT NOT NULL, " + + "paid_limit_bytes BIGINT NOT NULL, " + + "used_bytes BIGINT NOT NULL, " + + "last_block_number INTEGER NOT NULL, " + + "last_block_hash TEXT NOT NULL, " + + "last_block_signature TEXT NOT NULL, " + + "arweave_tx_id TEXT NOT NULL, " + + "is_server BOOLEAN NOT NULL, " + + "address_format_type INTEGER NOT NULL, " + + "address_format_version INTEGER NOT NULL, " + + "server_address TEXT NOT NULL, " + + "sync_servers_json TEXT NOT NULL, " + + "access_servers_json TEXT NOT NULL, " + + "sessions_mode INTEGER NOT NULL, " + + "sessions_json TEXT NOT NULL, " + + "trusted_count INTEGER NOT NULL, " + + "created_at_ms BIGINT NOT NULL, " + + "updated_at_ms BIGINT NOT NULL, " + + "prev_record_hash TEXT NOT NULL, " + + "record_signature TEXT NOT NULL, " + + "raw_data_base64 TEXT NOT NULL, " + + "saved_at_ms BIGINT NOT NULL, " + + "UNIQUE (pda_address, record_number)" + + ")" + ); + + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_user_pda_history_login " + + "ON solana_user_pda_history(login)" + ); + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_user_pda_history_slot " + + "ON solana_user_pda_history(slot)" + ); + + statement.executeUpdate( + "CREATE TABLE IF NOT EXISTS user_access_servers_current (" + + "user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, " + + "server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, " + + "server_url TEXT NOT NULL, " + + "server_client_key TEXT NOT NULL, " + + "user_record_number INTEGER NOT NULL, " + + "user_updated_at_ms BIGINT NOT NULL, " + + "server_record_number INTEGER NOT NULL, " + + "server_updated_at_ms BIGINT NOT NULL, " + + "refreshed_at_ms BIGINT NOT NULL, " + + "PRIMARY KEY (user_login, server_login)" + + ")" + ); + + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_user_access_servers_user " + + "ON user_access_servers_current(user_login)" + ); + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_user_access_servers_server " + + "ON user_access_servers_current(server_login)" + ); + + statement.execute( + """ + 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 jsonb_array_elements_text( + CASE + WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb + ELSE u.access_servers_json::jsonb + END + ) AS access_server(login_value) + 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; + $$; + """ + ); + + statement.execute( + """ + 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 jsonb_array_elements_text( + CASE + WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb + ELSE u.access_servers_json::jsonb + END + ) AS access_server(login_value) + 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; + $$; + """ + ); + + statement.execute( + """ + CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_all() + RETURNS VOID + LANGUAGE plpgsql + AS $$ + DECLARE + affected_user RECORD; + BEGIN + TRUNCATE TABLE user_access_servers_current; + + FOR affected_user IN + SELECT login + FROM solana_user_pda_current + LOOP + PERFORM shine_refresh_user_access_servers_for_user(affected_user.login); + END LOOP; + END; + $$; + """ + ); + + statement.execute( + """ + CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_row() + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN + PERFORM shine_refresh_user_access_servers_for_user(OLD.login); + PERFORM shine_refresh_user_access_servers_for_server(OLD.login); + RETURN OLD; + END IF; + + IF TG_OP = 'UPDATE' AND LOWER(OLD.login) <> LOWER(NEW.login) THEN + PERFORM shine_refresh_user_access_servers_for_user(OLD.login); + PERFORM shine_refresh_user_access_servers_for_server(OLD.login); + END IF; + + PERFORM shine_refresh_user_access_servers_for_user(NEW.login); + PERFORM shine_refresh_user_access_servers_for_server(NEW.login); + RETURN NEW; + END; + $$; + """ + ); + + statement.execute( + """ + CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate() + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ + BEGIN + TRUNCATE TABLE user_access_servers_current; + RETURN NULL; + END; + $$; + """ + ); + + statement.executeUpdate( + "DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_row ON solana_user_pda_current" + ); + statement.executeUpdate( + "CREATE TRIGGER trg_refresh_user_access_servers_row " + + "AFTER INSERT OR UPDATE OR DELETE ON solana_user_pda_current " + + "FOR EACH ROW " + + "EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_row()" + ); + + statement.executeUpdate( + "DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_truncate ON solana_user_pda_current" + ); + statement.executeUpdate( + "CREATE TRIGGER trg_refresh_user_access_servers_truncate " + + "AFTER TRUNCATE ON solana_user_pda_current " + + "FOR EACH STATEMENT " + + "EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate()" + ); + + statement.execute("SELECT shine_refresh_user_access_servers_all()"); + } + } + + private Connection openConnection() + throws Exception { + return DriverManager.getConnection( + databaseUrl, + databaseUser, + databasePassword + ); + } + + private static void bindNullableLong( + PreparedStatement statement, + int index, + Long value + ) throws SQLException { + if (value == null) { + statement.setNull(index, Types.BIGINT); + } else { + statement.setLong(index, value); + } + } + + private static Long getNullableLong( + ResultSet resultSet, + String column + ) throws SQLException { + long value = resultSet.getLong(column); + return resultSet.wasNull() ? null : value; + } + + private static Integer getNullableInt( + ResultSet resultSet, + String column + ) throws SQLException { + int value = resultSet.getInt(column); + return resultSet.wasNull() ? null : value; + } + + private String writeJson( + Object value + ) throws JsonProcessingException { + return mapper.writeValueAsString(value); + } + + @Override + public void close() { + } + + public record TxHistoryEntry( + String signature, + long slot, + Long blockTime, + String txKind, + boolean relevant, + String affectedPdaAddress, + String affectedLogin, + String rawSummaryJson, + long processedAtMs + ) { + } + + public record SyncStateSnapshot( + String status, + boolean ready, + Long lastPollAtMs, + Long lastSuccessfulPollAtMs, + String lastSeenSignature, + Long lastSeenSlot, + String lastRelevantSignature, + Long lastRelevantSlot, + String lastError, + ShineUsersCodec.EconomyConfigState economyConfigState, + Long updatedAtMs + ) { + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/Base58Util.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/Base58Util.java new file mode 100644 index 00000000..7b985555 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/Base58Util.java @@ -0,0 +1,224 @@ +package sync.util; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +public final class Base58Util { + + private static final char[] ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + .toCharArray(); + + private static final int[] INDEXES = + new int[128]; + + static { + Arrays.fill( + INDEXES, + -1 + ); + + for (int i = 0; i < ALPHABET.length; i++) { + INDEXES[ALPHABET[i]] = i; + } + } + + private Base58Util() { + } + + public static String encode( + byte[] input + ) { + + if (input == null + || input.length == 0) { + return ""; + } + + byte[] copy = + Arrays.copyOf( + input, + input.length + ); + + int zeros = + 0; + + while (zeros < copy.length + && copy[zeros] == 0) { + zeros++; + } + + byte[] encoded = + new byte[copy.length * 2]; + + int outputStart = + encoded.length; + + int inputStart = + zeros; + + while (inputStart < copy.length) { + + int remainder = + divMod58( + copy, + inputStart + ); + + if (copy[inputStart] == 0) { + inputStart++; + } + + encoded[--outputStart] = + (byte) ALPHABET[remainder]; + } + + while (outputStart < encoded.length + && encoded[outputStart] == ALPHABET[0]) { + outputStart++; + } + + while (--zeros >= 0) { + encoded[--outputStart] = + (byte) ALPHABET[0]; + } + + return new String( + encoded, + outputStart, + encoded.length - outputStart, + StandardCharsets.US_ASCII + ); + } + + public static byte[] decode( + String input + ) { + + if (input == null + || input.isBlank()) { + return new byte[0]; + } + + char[] chars = + input.trim() + .toCharArray(); + + byte[] input58 = + new byte[chars.length]; + + for (int i = 0; i < chars.length; i++) { + + char c = chars[i]; + + if (c >= INDEXES.length + || INDEXES[c] < 0) { + throw new IllegalArgumentException( + "Invalid Base58 character: " + c + ); + } + + input58[i] = + (byte) INDEXES[c]; + } + + int zeros = + 0; + + while (zeros < input58.length + && input58[zeros] == 0) { + zeros++; + } + + byte[] decoded = + new byte[chars.length]; + + int outputStart = + decoded.length; + + int inputStart = + zeros; + + while (inputStart < input58.length) { + + int remainder = + divMod256( + input58, + inputStart + ); + + if (input58[inputStart] == 0) { + inputStart++; + } + + decoded[--outputStart] = + (byte) remainder; + } + + while (outputStart < decoded.length + && decoded[outputStart] == 0) { + outputStart++; + } + + return Arrays.copyOfRange( + decoded, + outputStart - zeros, + decoded.length + ); + } + + private static int divMod58( + byte[] number, + int startAt + ) { + + int remainder = + 0; + + for (int i = startAt; i < number.length; i++) { + + int digit256 = + number[i] & 0xFF; + + int temp = + remainder * 256 + + digit256; + + number[i] = + (byte) (temp / 58); + + remainder = + temp % 58; + } + + return remainder; + } + + private static int divMod256( + byte[] number58, + int startAt + ) { + + int remainder = + 0; + + for (int i = startAt; i < number58.length; i++) { + + int digit58 = + number58[i] & 0xFF; + + int temp = + remainder * 58 + + digit58; + + number58[i] = + (byte) (temp / 256); + + remainder = + temp % 256; + } + + return remainder; + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java new file mode 100644 index 00000000..af065063 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java @@ -0,0 +1,95 @@ +package sync.util; + +import org.bouncycastle.jcajce.provider.digest.SHA256; +import org.bouncycastle.math.ec.rfc8032.Ed25519; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public final class SolanaPdaUtil { + + private static final byte[] PROGRAM_DERIVED_ADDRESS_DOMAIN = + "ProgramDerivedAddress" + .getBytes(StandardCharsets.UTF_8); + + private SolanaPdaUtil() { + } + + public static String findProgramAddress( + List seeds, + String programIdBase58 + ) { + + byte[] programId = + Base58Util.decode( + programIdBase58 + ); + + if (programId.length != 32) { + throw new IllegalArgumentException( + "Program id must decode to 32 bytes" + ); + } + + for (int bump = 255; bump >= 0; bump--) { + + List attemptSeeds = + new ArrayList<>( + seeds + ); + + attemptSeeds.add( + new byte[]{(byte) bump} + ); + + byte[] address = + createProgramAddress( + attemptSeeds, + programId + ); + + if (!isOnCurve(address)) { + return Base58Util.encode( + address + ); + } + } + + throw new IllegalStateException( + "Unable to find viable program address" + ); + } + + private static byte[] createProgramAddress( + List seeds, + byte[] programId + ) { + + SHA256.Digest digest = + new SHA256.Digest(); + + for (byte[] seed : seeds) { + digest.update(seed); + } + + digest.update(programId); + digest.update(PROGRAM_DERIVED_ADDRESS_DOMAIN); + + return digest.digest(); + } + + private static boolean isOnCurve( + byte[] publicKey + ) { + + if (publicKey.length != 32) { + return false; + } + + return Ed25519.validatePublicKeyFull( + publicKey, + 0 + ); + } +} diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java index ddf7f7e2..0b3deebe 100644 --- a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java +++ b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java @@ -4,21 +4,19 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response; import server.logic.ws_protocol.JSON.entyties.Net_Response; -import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import shine.db.dao.BlockchainResyncCleanupDAO; import shine.db.dao.BlockchainStateDAO; import shine.db.dao.SyncServersDAO; -import shine.db.dao.UserCreateDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.SolanaUserPdaCurrentDAO; import shine.db.entities.BlockchainStateEntry; +import shine.db.entities.SolanaUserPdaCurrentEntry; import shine.db.entities.SyncServerEntry; import server.sync.BlockchainResyncGuard; import utils.files.FileStoreUtil; import utils.blockchain.BlockchainNameUtil; -import utils.config.AppConfig; import java.util.concurrent.locks.ReentrantLock; import java.util.List; @@ -53,11 +51,9 @@ public final class PeriodicBlockchainSyncService { private static final Net_AddBlock_Handler ADD_BLOCK_HANDLER = new Net_AddBlock_Handler(); private static final BlockchainStateDAO STATE_DAO = BlockchainStateDAO.getInstance(); private static final SyncServersDAO SYNC_SERVERS_DAO = SyncServersDAO.getInstance(); - private static final UserCreateDAO USER_CREATE_DAO = UserCreateDAO.getInstance(); private static final BlockchainResyncCleanupDAO RESYNC_CLEANUP_DAO = BlockchainResyncCleanupDAO.getInstance(); private static final FileStoreUtil FILE_STORE = FileStoreUtil.getInstance(); - private static final SolanaUsersDAO SOLANA_USERS_DAO = SolanaUsersDAO.getInstance(); - private static final String CONFIG_IMPORT_PROFILE_FROM_PARTNER = "sync.importUserProfileFromPartner.enabled"; + private static final SolanaUserPdaCurrentDAO SOLANA_USER_PDA_CURRENT_DAO = SolanaUserPdaCurrentDAO.getInstance(); private PeriodicBlockchainSyncService() {} @@ -354,18 +350,20 @@ public final class PeriodicBlockchainSyncService { private static boolean ensureLocalChainExists(SyncServerEntry partner, String blockchainName) { try { - if (STATE_DAO.getByBlockchainName(blockchainName) != null) { + BlockchainStateEntry existingState = STATE_DAO.getByBlockchainName(blockchainName); + if (hasValidBlockchainKey(existingState)) { return true; } + + if (ensureLocalChainStateFromCurrentPostgresSnapshot(blockchainName, existingState)) { + return true; + } + String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName); if (login == null || login.isBlank()) { return false; } - if (AppConfig.getInstance().getBoolean(CONFIG_IMPORT_PROFILE_FROM_PARTNER, false)) { - return importUserProfileFromPartner(partner, login); - } - SolanaUserPdaImportService.findOrImportByLogin(login); - return STATE_DAO.getByBlockchainName(blockchainName) != null; + return false; } catch (Exception e) { log.warn("Periodic blockchain sync: failed to ensure local chain exists for blockchainName={} reason={}", blockchainName, String.valueOf(e)); @@ -373,65 +371,38 @@ public final class PeriodicBlockchainSyncService { } } - private static boolean importUserProfileFromPartner(SyncServerEntry partner, String login) throws Exception { - if (partner == null || partner.getServerAddress() == null || partner.getServerAddress().isBlank()) { + private static boolean ensureLocalChainStateFromCurrentPostgresSnapshot(String blockchainName, + BlockchainStateEntry existingState) throws Exception { + SolanaUserPdaCurrentEntry currentUser = SOLANA_USER_PDA_CURRENT_DAO.getByBlockchainName(blockchainName); + if (currentUser == null) { return false; } - RemoteBlockchainSyncClient.RemoteSyncUserProfile profile = - REMOTE.getSyncUserProfile(partner.getServerAddress(), login); - if (profile == null) { - log.warn("Periodic blockchain sync: partner has no sync profile for login={} partner={}", - login, normalize(partner.getLogin())); - return false; - } + BlockchainStateEntry state = new BlockchainStateEntry(); + state.setBlockchainName(currentUser.getBlockchainName()); + state.setLogin(currentUser.getLogin()); + state.setBlockchainKey(currentUser.getBlockchainKey()); + state.setSizeLimit(currentUser.getPaidLimitBytes() > 0 ? currentUser.getPaidLimitBytes() : 100_000L); + state.setFileSizeBytes(existingState == null ? 0L : existingState.getFileSizeBytes()); + state.setLastBlockNumber(existingState == null ? -1 : existingState.getLastBlockNumber()); + state.setLastBlockHash(existingState == null ? null : existingState.getLastBlockHash()); + state.setUpdatedAtMs(System.currentTimeMillis()); - long now = System.currentTimeMillis(); - long sizeLimit = profile.blockchainSizeLimitBytes() > 0 ? profile.blockchainSizeLimitBytes() : 100_000L; - BlockchainStateEntry state = buildStateFromProfile(profile, sizeLimit, now); - - if (SOLANA_USERS_DAO.existsByLogin(profile.login())) { + if (existingState == null) { STATE_DAO.insertIfMissing(state); - return STATE_DAO.getByBlockchainName(profile.blockchainName()) != null; + } else { + STATE_DAO.upsert(state); } - boolean inserted = USER_CREATE_DAO.insertUserWithBlockchain( - profile.login(), - profile.blockchainName(), - profile.solanaKey(), - profile.blockchainKey(), - profile.clientKey(), - sizeLimit, - now - ); - - if (inserted) { - return STATE_DAO.getByBlockchainName(profile.blockchainName()) != null; - } - - // Если пользователь уже успел существовать локально, но chain_state отсутствует, - // добиваем только state и не пытаемся пересоздать identity. - if (SOLANA_USERS_DAO.existsByLogin(profile.login())) { - STATE_DAO.insertIfMissing(state); - return STATE_DAO.getByBlockchainName(profile.blockchainName()) != null; - } - - return false; + return hasValidBlockchainKey(STATE_DAO.getByBlockchainName(blockchainName)); } - private static BlockchainStateEntry buildStateFromProfile(RemoteBlockchainSyncClient.RemoteSyncUserProfile profile, - long sizeLimit, - long nowMs) { - BlockchainStateEntry state = new BlockchainStateEntry(); - state.setBlockchainName(profile.blockchainName()); - state.setLogin(profile.login()); - state.setBlockchainKey(profile.blockchainKey()); - state.setSizeLimit(sizeLimit); - state.setFileSizeBytes(0L); - state.setLastBlockNumber(-1); - state.setLastBlockHash(null); - state.setUpdatedAtMs(nowMs); - return state; + private static boolean hasValidBlockchainKey(BlockchainStateEntry state) { + if (state == null) { + return false; + } + byte[] keyBytes = state.getBlockchainKeyBytes(); + return keyBytes != null && keyBytes.length == 32; } private static String normalize(String value) { diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicDmSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicDmSyncService.java new file mode 100644 index 00000000..94b6c626 --- /dev/null +++ b/SHiNE-server/src/main/java/server/sync/PeriodicDmSyncService.java @@ -0,0 +1,185 @@ +package server.sync; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.messages.DmSyncApplySupport; +import shine.db.dao.DmSyncPeerStateDAO; +import shine.db.dao.UserAccessServersCurrentDAO; +import shine.db.entities.DmSyncPeerStateEntry; +import shine.db.entities.UserAccessServerRouteEntry; +import utils.config.AppConfig; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +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; + +/** + * Периодическая догоняющая синхронизация личной переписки между access-серверами пользователя. + */ +public final class PeriodicDmSyncService { + private static final Logger log = LoggerFactory.getLogger(PeriodicDmSyncService.class); + private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login"; + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + + private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient(); + private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance(); + private static final DmSyncPeerStateDAO STATE_DAO = DmSyncPeerStateDAO.getInstance(); + + private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "periodic-dm-sync"); + t.setDaemon(true); + return t; + } + }); + + private PeriodicDmSyncService() {} + + public static void startOrLog() { + if (!isEnabled()) { + log.info("Periodic DM sync disabled by dm.sync.enabled=false"); + return; + } + if (!STARTED.compareAndSet(false, true)) { + return; + } + long initialDelaySec = configLong("dm.sync.initialDelaySeconds", 60L, 0L, 3600L); + long periodHours = configLong("dm.sync.periodHours", 6L, 1L, 168L); + EXECUTOR.scheduleWithFixedDelay( + PeriodicDmSyncService::runCycleSafe, + initialDelaySec, + TimeUnit.HOURS.toSeconds(periodHours), + TimeUnit.SECONDS + ); + log.info("Periodic DM sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours); + } + + private static void runCycleSafe() { + try { + runCycle(); + } catch (Exception e) { + log.error("Periodic DM sync failed unexpectedly", e); + } + } + + private static void runCycle() throws Exception { + String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)); + if (ownServerLogin == null) { + log.warn("Periodic DM sync skipped: {} is empty", SERVER_LOGIN_CONFIG); + return; + } + + List ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin); + Set owners = new LinkedHashSet<>(ownersRaw); + if (owners.isEmpty()) { + log.info("Periodic DM sync skipped: no local access-server users for {}", ownServerLogin); + return; + } + + int syncedPeers = 0; + int appliedEvents = 0; + for (String ownerLogin : owners) { + List routes = ACCESS_DAO.listByUserLogin(ownerLogin); + for (UserAccessServerRouteEntry route : routes) { + if (route == null) continue; + String remoteLogin = normalize(route.getServerLogin()); + String remoteUrl = route.getServerUrl(); + if (remoteLogin == null || remoteUrl == null || remoteUrl.isBlank()) continue; + if (remoteLogin.equals(ownServerLogin)) continue; + + try { + appliedEvents += syncOwnerFromRemote(ownerLogin, route); + syncedPeers++; + } catch (Exception e) { + STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e)); + log.warn("Periodic DM sync peer failed: owner={} remoteServer={} reason={}", + ownerLogin, route.getServerLogin(), String.valueOf(e)); + } + } + } + log.info("Periodic DM sync cycle finished: owners={} syncedPeers={} appliedEvents={}", + owners.size(), syncedPeers, appliedEvents); + } + + private static int syncOwnerFromRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception { + int limit = (int) configLong("dm.sync.batchLimit", 500L, 1L, 500L); + int maxBytes = (int) configLong("dm.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L); + int maxPages = (int) configLong("dm.sync.maxPagesPerPeer", 50L, 1L, 500L); + + DmSyncPeerStateEntry state = STATE_DAO.getOrCreate(ownerLogin, route.getServerLogin(), route.getServerUrl()); + long cursorStoredAtMs = state.getCursorStoredAtMs(); + String cursorMessageKey = state.getCursorMessageKey() == null ? "" : state.getCursorMessageKey(); + int applied = 0; + boolean bootstrapCompleted = false; + + for (int page = 0; page < maxPages; page++) { + RemoteDmSyncClient.RemoteDmBatch batch = REMOTE.dmSyncBatch( + route.getServerUrl(), + ownerLogin, + cursorStoredAtMs, + cursorMessageKey, + limit, + maxBytes + ); + + for (RemoteDmSyncClient.RemoteDmItem item : batch.items()) { + if (item == null || item.blobB64() == null || item.blobB64().isBlank()) continue; + DmSyncApplySupport.ApplyResult result = DmSyncApplySupport.applySyncedBlob(ownerLogin, item.blobB64()); + if (result.status().applied()) { + applied++; + } + } + + cursorStoredAtMs = Math.max(cursorStoredAtMs, batch.nextStoredAtMs()); + cursorMessageKey = batch.nextMessageKey() == null ? "" : batch.nextMessageKey(); + bootstrapCompleted = !batch.hasMore(); + STATE_DAO.updateSuccess( + ownerLogin, + route.getServerLogin(), + route.getServerUrl(), + cursorStoredAtMs, + cursorMessageKey, + bootstrapCompleted + ); + + if (!batch.hasMore() || batch.items().isEmpty()) { + break; + } + } + + if (!bootstrapCompleted) { + log.info("Periodic DM sync peer paused by page limit: owner={} remoteServer={} maxPages={}", + ownerLogin, route.getServerLogin(), maxPages); + } + return applied; + } + + private static boolean isEnabled() { + String raw = AppConfig.getInstance().getParam("dm.sync.enabled"); + return raw == null || raw.isBlank() || Boolean.parseBoolean(raw.trim()); + } + + 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; + } +} diff --git a/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java b/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java new file mode 100644 index 00000000..491bbb1d --- /dev/null +++ b/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java @@ -0,0 +1,85 @@ +package server.sync; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sync.service.SolanaUsersSyncService; +import utils.config.AppConfig; + +public final class SolanaUsersSyncStartupService { + + private static final Logger log = + LoggerFactory.getLogger( + SolanaUsersSyncStartupService.class + ); + + private static volatile SolanaUsersSyncService service; + + private SolanaUsersSyncStartupService() { + } + + public static synchronized void startOrThrow() + throws Exception { + + if (service != null) { + return; + } + + AppConfig serverConfig = + AppConfig.getInstance(); + + if (!sync.config.AppConfig.isEnabled(serverConfig)) { + log.info( + "Solana users sync is disabled. Param {} is false or absent.", + sync.config.AppConfig.ENABLED_KEY + ); + return; + } + + sync.config.AppConfig syncConfig = + sync.config.AppConfig.fromServerConfig( + serverConfig + ); + + service = + new SolanaUsersSyncService( + syncConfig + ); + + Runtime.getRuntime() + .addShutdownHook( + new Thread( + SolanaUsersSyncStartupService::closeQuietly, + "solana-users-sync-shutdown" + ) + ); + + log.info( + "Starting Solana users sync before remaining server startup..." + ); + + service.start(); + service.awaitReady(); + + log.info( + "Solana users sync is READY. Server startup may continue." + ); + } + + public static synchronized void closeQuietly() { + + if (service == null) { + return; + } + + try { + service.close(); + } catch (Exception exception) { + log.warn( + "Failed to close Solana users sync service cleanly", + exception + ); + } finally { + service = null; + } + } +} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index d318b2e2..42a9aff2 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -8,6 +8,8 @@ import org.slf4j.LoggerFactory; import server.debug.DebugApiConfigurator; import server.sync.BlockchainResyncRecoveryOnStartup; import server.sync.PeriodicBlockchainSyncService; +import server.sync.PeriodicDmSyncService; +import server.sync.SolanaUsersSyncStartupService; import server.sync.SyncServersBootstrapService; import utils.config.AppConfig; @@ -49,6 +51,11 @@ public final class WsServer { throw e; } + // ============================================================ + // 1.0) Синхронизация пользовательских Solana PDA + // ============================================================ + SolanaUsersSyncStartupService.startOrThrow(); + // ============================================================ // 1) Настройки порта // ============================================================ @@ -96,6 +103,7 @@ public final class WsServer { server.start(); log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); + PeriodicDmSyncService.startOrLog(); server.join(); } } diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index b910adbd..17aa12c4 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -1,8 +1,18 @@ server.1port=7070 -db.path=data/shine.sqlite +db.url= +db.user= +db.password= server.SHiNE.login=shineupme solana.cluster=mainnet-beta solana.rpcUrl=https://api.mainnet-beta.solana.com +solana.users.sync.enabled=false +solana.users.sync.rpcUrl= +solana.users.sync.wsUrl= +solana.users.sync.programId=SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6 +solana.users.sync.databaseUrl= +solana.users.sync.dbUser= +solana.users.sync.dbPassword= +solana.users.sync.pollIntervalSeconds=300 # ------------------------------------------------------------ # Межсерверная синхронизация: как создавать локальную запись пользователя, @@ -10,7 +20,7 @@ solana.rpcUrl=https://api.mainnet-beta.solana.com # false - брать профиль пользователя напрямую из Solana PDA (обычный режим). # true - не ходить в Solana RPC, а запрашивать у сервера-партнёра специальный # sync-профиль пользователя и по нему локально создавать -# solana_users + blockchain_state. +# current user runtime-проекцию + blockchain_state. # Эта настройка нужна как временный обход лимитов Solana RPC (например 429), # чтобы чистый сервер мог восстановить цепочки от партнёра без зависимости # от внешнего Solana endpoint. @@ -23,6 +33,14 @@ sync.importUserProfileFromPartner.enabled=false # Если какое-то значение не задано, сервер вернёт пустую строку. # ------------------------------------------------------------ server.version=${projectVersion} + +# Межсерверная догоняющая синхронизация личных сообщений. +dm.sync.enabled=true +dm.sync.initialDelaySeconds=60 +dm.sync.periodHours=6 +dm.sync.batchLimit=500 +dm.sync.batchMaxBytes=3000000 +dm.sync.maxPagesPerPeer=50 server.info.url= server.info.physicalRegion= server.info.description= diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_01_AddUser.java b/SHiNE-server/src/test/java/test/it/cases/IT_01_AddUser.java deleted file mode 100644 index cd132733..00000000 --- a/SHiNE-server/src/test/java/test/it/cases/IT_01_AddUser.java +++ /dev/null @@ -1,365 +0,0 @@ -package test.it.cases; - -import test.it.utils.TestConfig; -import test.it.utils.TestIds; -import test.it.utils.json.JsonBuilders; -import test.it.utils.json.JsonParsers; -import test.it.utils.log.TestResult; -import test.it.utils.ws.WsSession; - -import java.time.Duration; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.fail; - -/** - * IT_01_AddUser - * Создаёт 3 пользователей: TestUser1/2/3 (200 OK или 409 USER_ALREADY_EXISTS). - * - * Обновление: - * - теперь AddUser может вернуть 409 не только USER_ALREADY_EXISTS, - * но и BLOCKCHAIN_ALREADY_EXISTS / BLOCKCHAIN_STATE_ALREADY_EXISTS. - * - дополнительно проверяем GetUser (status=200 всегда). - * - добавлен SearchUsers: поиск по префиксу (первые 3 символа). - */ -public class IT_01_AddUser { - - public static void main(String[] args) { - String summary = run(); - System.out.println(summary); - } - - public static String run() { - TestResult r = new TestResult("IT_01_AddUser"); - - Duration t = Duration.ofSeconds(5); - - try (WsSession ws = WsSession.open()) { - - checkPingAndServerInfo(r, ws, t); - - r.ok("AddUser USER1: " + TestConfig.LOGIN()); - String resp1 = ws.call("AddUser#USER1", JsonBuilders.addUser(TestConfig.LOGIN()), t); - checkAddUser200or409(r, resp1); - checkGetUserMustExist(r, ws, TestConfig.LOGIN(), t); - - r.ok("AddUser USER2: " + TestConfig.LOGIN2()); - String resp2 = ws.call("AddUser#USER2", JsonBuilders.addUser(TestConfig.LOGIN2()), t); - checkAddUser200or409(r, resp2); - checkGetUserMustExist(r, ws, TestConfig.LOGIN2(), t); - - r.ok("AddUser USER3: " + TestConfig.LOGIN3()); - String resp3 = ws.call("AddUser#USER3", JsonBuilders.addUser(TestConfig.LOGIN3()), t); - checkAddUser200or409(r, resp3); - checkGetUserMustExist(r, ws, TestConfig.LOGIN3(), t); - - // Доп: проверяем case-insensitive поиск в GetUser - String mixed = mixCase(TestConfig.LOGIN()); - r.ok("GetUser case-insensitive: запрос=" + mixed + " (должен найти " + TestConfig.LOGIN() + ")"); - checkGetUserMustExist(r, ws, mixed, t); - - // Доп: проверяем "не существует" (но status=200) - String missing = "NoSuchUser_987654321"; - r.ok("GetUser missing: " + missing); - checkGetUserMustNotExist(r, ws, missing, t); - - // SearchUsers: один раз ищем по первым трём символам логина USER1 - String prefix3 = first3(TestConfig.LOGIN()); - String prefix3Mixed = mixCase(prefix3); - r.ok("SearchUsers: prefix(3)='" + prefix3Mixed + "' (должен вернуть список и содержать " + TestConfig.LOGIN() + ")"); - checkSearchUsersMustContain(r, ws, prefix3Mixed, TestConfig.LOGIN(), t); - - checkNegativeRequests(r, ws, t); - - } catch (Throwable e) { - r.fail("IT_01_AddUser упал: " + e.getMessage()); - } - - return r.summaryLine(); - } - - private static void checkPingAndServerInfo(TestResult r, WsSession ws, Duration t) { - String pingResp = ws.call("Ping", JsonBuilders.ping(System.currentTimeMillis()), t); - if (JsonParsers.status(pingResp) != 200 || !Boolean.TRUE.equals(JsonParsers.ok(pingResp))) { - r.fail("Ping: ожидали status=200 и ok=true, resp=" + pingResp); - fail("Ping unexpected response"); - } - - Long serverTs = JsonParsers.pingTs(pingResp); - if (serverTs == null || serverTs <= 0) { - r.fail("Ping: сервер не вернул ts, resp=" + pingResp); - fail("Ping missing ts"); - } - r.ok("Ping: ok, serverTs=" + serverTs); - - String infoResp = ws.call("GetServerInfo", JsonBuilders.getServerInfo(), t); - if (JsonParsers.status(infoResp) != 200 || !Boolean.TRUE.equals(JsonParsers.ok(infoResp))) { - r.fail("GetServerInfo: ожидали status=200 и ok=true, resp=" + infoResp); - fail("GetServerInfo unexpected response"); - } - if (!JsonParsers.payloadIsObject(infoResp)) { - r.fail("GetServerInfo: payload должен быть объектом, resp=" + infoResp); - fail("GetServerInfo payload is not object"); - } - - r.ok("GetServerInfo: ok, url='" + safe(JsonParsers.payloadText(infoResp, "url")) - + "', version='" + safe(JsonParsers.payloadText(infoResp, "version")) - + "', physicalRegion='" + safe(JsonParsers.payloadText(infoResp, "physicalRegion")) - + "', description='" + safe(JsonParsers.payloadText(infoResp, "description")) - + "', origin='" + safe(JsonParsers.payloadText(infoResp, "origin")) - + "', extraInfo='" + safe(JsonParsers.payloadText(infoResp, "extraInfo")) + "'"); - } - - private static void checkAddUser200or409(TestResult r, String resp) { - int st = JsonParsers.status(resp); - if (st == 200) { - r.ok("AddUser: status=200 (создан)"); - return; - } - if (st == 409) { - String code = JsonParsers.errorCode(resp); - - // раньше был только USER_ALREADY_EXISTS, теперь добавились ещё варианты - if ("USER_ALREADY_EXISTS".equals(code)) { - r.ok("AddUser: status=409 USER_ALREADY_EXISTS (уже был)"); - return; - } - if ("BLOCKCHAIN_ALREADY_EXISTS".equals(code)) { - r.ok("AddUser: status=409 BLOCKCHAIN_ALREADY_EXISTS (blockchainName уже занят)"); - return; - } - if ("BLOCKCHAIN_STATE_ALREADY_EXISTS".equals(code)) { - r.ok("AddUser: status=409 BLOCKCHAIN_STATE_ALREADY_EXISTS (blockchain_state уже есть)"); - return; - } - - r.fail("AddUser: status=409 но code=" + code + ", resp=" + resp); - fail("AddUser unexpected 409 code=" + code); - } - r.fail("AddUser: неожиданный status=" + st + ", resp=" + resp); - fail("AddUser unexpected status=" + st); - } - - private static void checkGetUserMustExist(TestResult r, WsSession ws, String loginQuery, Duration t) { - String resp = ws.call("GetUser#" + loginQuery, JsonBuilders.getUser(loginQuery), t); - - int st = JsonParsers.status(resp); - if (st != 200) { - r.fail("GetUser: ожидали status=200, получили " + st + ", resp=" + resp); - fail("GetUser unexpected status=" + st); - } - - Boolean exists = JsonParsers.exists(resp); - if (exists == null || !exists) { - r.fail("GetUser: ожидали exists=true, resp=" + resp); - fail("GetUser expected exists=true"); - } - - // Проверяем, что сервер возвращает данные - String login = JsonParsers.userLogin(resp); - String blockchainName = JsonParsers.userBlockchainName(resp); - String solanaKey = JsonParsers.userSolanaKey(resp); - String blockchainKey = JsonParsers.userBlockchainKey(resp); - String clientKey = JsonParsers.userClientKey(resp); - - if (isBlank(login) || isBlank(blockchainName) || isBlank(solanaKey) || isBlank(blockchainKey) || isBlank(clientKey)) { - r.fail("GetUser: exists=true, но поля пустые/неполные, resp=" + resp); - fail("GetUser returned incomplete user data"); - } - - // ВАЖНО: - // Поиск делается без учета регистра, но login/blockchainName должны вернуться как в БД. - // Для тех логинов, которые мы создаем в тесте, это ровно TestConfig.LOGIN*(). - // Поэтому если запрос был смешанный регистр — сравниваем не с loginQuery, а с "каноничным" логином из конфига. - String canonical = canonicalLogin(loginQuery); - if (canonical != null) { - if (!login.equals(canonical)) { - r.fail("GetUser: login должен вернуться как в БД. expected=" + canonical + ", got=" + login + ", resp=" + resp); - fail("GetUser wrong login case"); - } - - String expectedBch = TestConfig.getBlockchainName(canonical); - if (!blockchainName.equals(expectedBch)) { - r.fail("GetUser: blockchainName должен вернуться как в БД. expected=" + expectedBch + ", got=" + blockchainName + ", resp=" + resp); - fail("GetUser wrong blockchainName"); - } - - // ключи должны совпадать с теми, что AddUser использует при регистрации - String expSol = TestConfig.solanaPublicKeyB64(canonical); - String expBchKey = TestConfig.blockchainPublicKeyB64(canonical); - String expDev = TestConfig.clientPublicKeyB64(canonical); - - if (!solanaKey.equals(expSol)) { - r.fail("GetUser: solanaKey mismatch, resp=" + resp); - fail("GetUser solanaKey mismatch"); - } - if (!blockchainKey.equals(expBchKey)) { - r.fail("GetUser: blockchainKey mismatch, resp=" + resp); - fail("GetUser blockchainKey mismatch"); - } - if (!clientKey.equals(expDev)) { - r.fail("GetUser: clientKey mismatch, resp=" + resp); - fail("GetUser clientKey mismatch"); - } - } - - r.ok("GetUser: exists=true, login=" + login + ", blockchainName=" + blockchainName); - } - - private static void checkGetUserMustNotExist(TestResult r, WsSession ws, String loginQuery, Duration t) { - String resp = ws.call("GetUser#" + loginQuery, JsonBuilders.getUser(loginQuery), t); - - int st = JsonParsers.status(resp); - if (st != 200) { - r.fail("GetUser(not exist): ожидали status=200, получили " + st + ", resp=" + resp); - fail("GetUser(not exist) unexpected status=" + st); - } - - Boolean exists = JsonParsers.exists(resp); - if (exists == null) { - r.fail("GetUser(not exist): payload.exists отсутствует, resp=" + resp); - fail("GetUser(not exist) missing exists"); - } - if (exists) { - r.fail("GetUser(not exist): ожидали exists=false, resp=" + resp); - fail("GetUser(not exist) expected exists=false"); - } - - r.ok("GetUser: exists=false (ok)"); - } - - private static void checkSearchUsersMustContain(TestResult r, WsSession ws, String prefix, String expectedLogin, Duration t) { - String resp = ws.call("SearchUsers#" + prefix, JsonBuilders.searchUsers(prefix), t); - - int st = JsonParsers.status(resp); - if (st != 200) { - r.fail("SearchUsers: ожидали status=200, получили " + st + ", resp=" + resp); - fail("SearchUsers unexpected status=" + st); - } - - List logins = JsonParsers.searchLogins(resp); - if (logins == null || logins.isEmpty()) { - r.fail("SearchUsers: ожидали непустой список, resp=" + resp); - fail("SearchUsers expected non-empty list"); - } - - // ВАЖНО: ожидаемый логин должен быть в ответе в регистре БД (каноничный expectedLogin) - boolean found = false; - for (String s : logins) { - if (expectedLogin.equals(s)) { - found = true; - break; - } - } - if (!found) { - r.fail("SearchUsers: ожидаемый логин не найден. expected=" + expectedLogin + ", got=" + logins + ", resp=" + resp); - fail("SearchUsers expected login not found"); - } - - r.ok("SearchUsers: ok, prefix=" + prefix + ", results=" + logins.size() + ", contains=" + expectedLogin); - } - - private static void checkNegativeRequests(TestResult r, WsSession ws, Duration t) { - String badAddUserReqId = TestIds.next("bad-adduser"); - String badAddUser = """ - { - "op": "AddUser", - "requestId": "%s", - "payload": { - "login": "", - "blockchainName": "%s", - "solanaKey": "%s", - "blockchainKey": "%s", - "clientKey": "%s", - "bchLimit": %d - } - } - """.formatted( - badAddUserReqId, - TestConfig.BCH_NAME(), - TestConfig.SOLANA_PUBKEY_B64(), - TestConfig.BLOCKCHAIN_PUBKEY_B64(), - TestConfig.DEVICE_PUBKEY_B64(), - TestConfig.TEST_BCH_LIMIT - ); - String badAddUserResp = ws.call("AddUser#NEGATIVE", badAddUser, t); - assertErrorFormat(badAddUserResp, "AddUser", badAddUserReqId, "BAD_FIELDS"); - r.ok("Negative AddUser: error format OK"); - - String badGetUserReqId = TestIds.next("bad-getuser"); - String badGetUser = """ - { - "op": "GetUser", - "requestId": "%s", - "payload": { - "login": "" - } - } - """.formatted(badGetUserReqId); - String badGetUserResp = ws.call("GetUser#NEGATIVE", badGetUser, t); - assertErrorFormat(badGetUserResp, "GetUser", badGetUserReqId, "BAD_FIELDS"); - r.ok("Negative GetUser: error format OK"); - - String badSearchReqId = TestIds.next("bad-searchusers"); - String badSearch = """ - { - "op": "SearchUsers", - "requestId": "%s", - "payload": { - "prefix": "" - } - } - """.formatted(badSearchReqId); - String badSearchResp = ws.call("SearchUsers#NEGATIVE", badSearch, t); - assertErrorFormat(badSearchResp, "SearchUsers", badSearchReqId, "BAD_FIELDS"); - r.ok("Negative SearchUsers: error format OK"); - } - - private static void assertErrorFormat(String resp, String op, String requestId, String code) { - int status = JsonParsers.status(resp); - if (status >= 200 && status < 300) fail("Expected non-2xx status: " + resp); - if (!Boolean.FALSE.equals(JsonParsers.ok(resp))) fail("Expected ok=false: " + resp); - if (!op.equals(JsonParsers.op(resp))) fail("Unexpected op: " + resp); - if (!requestId.equals(JsonParsers.requestId(resp))) fail("Unexpected requestId: " + resp); - if (!code.equals(JsonParsers.errorCode(resp))) fail("Unexpected error code: " + resp); - if (!JsonParsers.payloadIsObject(resp)) fail("payload must be object: " + resp); - if (JsonParsers.payloadSize(resp) != 0) fail("error payload must be empty object: " + resp); - if (isBlank(JsonParsers.message(resp))) fail("error message must be present: " + resp); - } - - private static String canonicalLogin(String anyCaseLogin) { - if (anyCaseLogin == null) return null; - String x = anyCaseLogin.trim(); - if (x.isEmpty()) return null; - - // Привязка только к нашим тестовым логинам, чтобы не гадать. - if (x.equalsIgnoreCase(TestConfig.LOGIN())) return TestConfig.LOGIN(); - if (x.equalsIgnoreCase(TestConfig.LOGIN2())) return TestConfig.LOGIN2(); - if (x.equalsIgnoreCase(TestConfig.LOGIN3())) return TestConfig.LOGIN3(); - - return null; - } - - private static String mixCase(String s) { - if (s == null) return null; - String x = s.trim(); - if (x.length() < 2) return x; - // простой "микс" без рандома, чтобы тест был детерминированный - return Character.toUpperCase(x.charAt(0)) + x.substring(1).toLowerCase(); - } - - private static String first3(String s) { - if (s == null) return ""; - String x = s.trim(); - if (x.length() <= 3) return x; - return x.substring(0, 3); - } - - private static boolean isBlank(String s) { - return s == null || s.trim().isEmpty(); - } - - private static String safe(String s) { - return s == null ? "" : s; - } -} diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_02_Sessions.java b/SHiNE-server/src/test/java/test/it/cases/IT_02_Sessions.java index adde6f78..35a261e3 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_02_Sessions.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_02_Sessions.java @@ -32,8 +32,7 @@ public class IT_02_Sessions { private static final String LOGIN = TestConfig.LOGIN(); public static void main(String[] args) { - TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> сначала запускаю IT_01_AddUser"); - System.out.println(IT_01_AddUser.run()); + TestLog.info("Standalone: этот тест требует заранее существующего пользователя в актуальном PDA snapshot"); String summary = run(); System.out.println(summary); } diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java index d40f4b6f..b767688c 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java @@ -34,8 +34,7 @@ import static org.junit.jupiter.api.Assertions.*; public class IT_03_AddBlock_NoAuth { public static void main(String[] args) { - TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> запускаю IT_01_AddUser"); - System.out.println(IT_01_AddUser.run()); + TestLog.info("Standalone: этот тест требует заранее существующих пользователей в актуальном PDA snapshot"); String summary = run(); System.out.println(summary); } diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_04_UserParams_NoAuth.java b/SHiNE-server/src/test/java/test/it/cases/IT_04_UserParams_NoAuth.java index 3f050f82..74bcd7a6 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_04_UserParams_NoAuth.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_04_UserParams_NoAuth.java @@ -28,8 +28,7 @@ public class IT_04_UserParams_NoAuth { private static final ObjectMapper M = new ObjectMapper(); public static void main(String[] args) { - TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> сначала запускаю IT_01_AddUser"); - System.out.println(IT_01_AddUser.run()); + TestLog.info("Standalone: этот тест требует заранее существующего пользователя в актуальном PDA snapshot"); String summary = run(); System.out.println(summary); } @@ -283,4 +282,4 @@ public class IT_04_UserParams_NoAuth { return null; } } -} \ No newline at end of file +} diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_06_ChannelsApi.java b/SHiNE-server/src/test/java/test/it/cases/IT_06_ChannelsApi.java index 80055668..f45208c5 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_06_ChannelsApi.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_06_ChannelsApi.java @@ -57,11 +57,12 @@ public class IT_06_ChannelsApi { JsonNode first = messages.get(0); int blockNumber = first.path("messageRef").path("blockNumber").asInt(-1); String blockHash = first.path("messageRef").path("blockHash").asText(""); + String messageBlockchainName = first.path("authorBlockchainName").asText(""); - if (blockNumber > 0 && !blockHash.isBlank()) { + if (!messageBlockchainName.isBlank() && blockNumber >= 0 && !blockHash.isBlank()) { String threadResp = ws.call( "GetMessageThread", - JsonBuilders.getMessageThread(bchName, blockNumber, blockHash, 20, 2, 50), + JsonBuilders.getMessageThread(messageBlockchainName, blockNumber, blockHash, 20, 2, 50), t ); check200(r, threadResp, "GetMessageThread"); @@ -73,7 +74,7 @@ public class IT_06_ChannelsApi { } r.ok("GetMessageThread: payload shape OK"); } else { - r.ok("GetMessageThread: пропущено, у первого сообщения нет корректного ref"); + r.ok("GetMessageThread: пропущено, у первого сообщения нет полного messageRef/authorBlockchainName"); } } else { r.ok("GetMessageThread: пропущено, в канале нет сообщений"); diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java index dcaaff39..381e3d1c 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java @@ -7,8 +7,7 @@ import test.it.utils.log.TestLog; import test.it.utils.log.TestResult; import test.it.utils.ws.WsSession; import utils.crypto.Ed25519Util; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; +import shine.db.dao.CurrentUsersDAO; import utils.crypto.HashSHA256Util; import java.time.Duration; @@ -22,7 +21,7 @@ public class IT_07_EspPairing { private static final String LOGIN = TestConfig.LOGIN(); public static void main(String[] args) { - TestLog.info("Standalone: при необходимости локально создаю тестового пользователя напрямую в БД"); + TestLog.info("Standalone: этот тест требует заранее существующего пользователя в актуальном PDA snapshot"); String summary = run(); System.out.println(summary); } @@ -208,16 +207,10 @@ public class IT_07_EspPairing { } private static void ensureUserSeeded() throws Exception { - if (SolanaUsersDAO.getInstance().existsByLogin(LOGIN)) { + if (CurrentUsersDAO.getInstance().existsByLogin(LOGIN)) { return; } - SolanaUserEntry entry = new SolanaUserEntry(); - entry.setLogin(LOGIN); - entry.setBlockchainName(TestConfig.getBlockchainName(LOGIN)); - entry.setSolanaKey(TestConfig.solanaPublicKeyB64(LOGIN)); - entry.setBlockchainKey(TestConfig.blockchainPublicKeyB64(LOGIN)); - entry.setClientKey(TestConfig.clientPublicKeyB64(LOGIN)); - SolanaUsersDAO.getInstance().insert(entry); + throw new IllegalStateException("Тестовый пользователь отсутствует в актуальном PDA snapshot: " + LOGIN); } private static String derivePairingHash(String login, String password) { diff --git a/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java b/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java index 9b6bcd0a..b08e3cb9 100644 --- a/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java +++ b/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java @@ -7,8 +7,6 @@ import blockchain.body.TextBody; import blockchain.body.UserParamBody; import test.it.blockchain.AddBlockSender; import test.it.blockchain.ChainState; -import test.it.utils.TestIds; -import test.it.utils.json.JsonParsers; import test.it.utils.log.TestResult; import test.it.utils.ws.WsSession; import utils.crypto.Ed25519Util; @@ -25,7 +23,8 @@ import java.util.Objects; /** * Вспомогательные функции для массового заполнения тестовой соц-сети через API. - * Вся запись состояния делается только через AddUser / AddBlock. + * Пользователи должны уже существовать в актуальном PDA snapshot сервера. + * Вся запись блокчейн-состояния делается через AddBlock. */ public final class SeedDataPopulationHelper { @@ -52,7 +51,6 @@ public final class SeedDataPopulationHelper { } public void createUsersAndHeaders(List users) { - for (UserSpec user : users) createUserViaApi(user.login); for (UserSpec user : users) initHeader(user.login); } @@ -159,48 +157,6 @@ public final class SeedDataPopulationHelper { ), timeout); } - private void createUserViaApi(String login) { - String requestId = TestIds.next("seed_adduser"); - String req = """ - { - "op": "AddUser", - "requestId": "%s", - "payload": { - "login": "%s", - "blockchainName": "%s", - "solanaKey": "%s", - "blockchainKey": "%s", - "clientKey": "%s", - "bchLimit": 50000000 - } - } - """.formatted( - requestId, - login, - bch(login), - keys.solanaPublicB64, - keys.blockchainPublicB64, - keys.clientPublicB64 - ); - - String resp = ws.call("AddUser#" + login, req, timeout); - int status = JsonParsers.status(resp); - if (status == 200) { - result.ok("AddUser " + login + ": created"); - return; - } - if (status == 409) { - String code = JsonParsers.errorCode(resp); - if ("USER_ALREADY_EXISTS".equals(code) - || "BLOCKCHAIN_ALREADY_EXISTS".equals(code) - || "BLOCKCHAIN_STATE_ALREADY_EXISTS".equals(code)) { - result.ok("AddUser " + login + ": already exists (" + code + ")"); - return; - } - } - throw new IllegalStateException("AddUser failed for " + login + ": status=" + status + ", resp=" + resp); - } - private void initHeader(String login) { ChainState state = new ChainState(); AddBlockSender sender = new AddBlockSender(ws, state, login, bch(login), keys.blockchainPrivate32); diff --git a/SHiNE-server/src/test/java/test/it/cases/Seed_TestDataPopulation.java b/SHiNE-server/src/test/java/test/it/cases/Seed_TestDataPopulation.java index 97d5d11c..40569b8e 100644 --- a/SHiNE-server/src/test/java/test/it/cases/Seed_TestDataPopulation.java +++ b/SHiNE-server/src/test/java/test/it/cases/Seed_TestDataPopulation.java @@ -12,7 +12,8 @@ import static org.junit.jupiter.api.Assertions.fail; * Seed_TestDataPopulation * * Заполняет тестовую соц-сеть через API: - * - пользователи + HEADER через AddUser/AddBlock; + * - пользователи должны уже существовать в актуальном PDA snapshot; + * - HEADER и остальные блоки пишутся через AddBlock; * - параметры профиля (имя, фамилия, пол, адрес, web, phone, official, shine) через AddBlock USER_PARAM; * - связи (close friend/contact/follow/parent/child/sibling) через AddBlock CONNECTION; * - посты через AddBlock TEXT_POST. @@ -34,7 +35,7 @@ public class Seed_TestDataPopulation { SeedDataPopulationHelper seed = new SeedDataPopulationHelper(ws, result, timeout, PASSWORD); // ----------------------------------------------------------------- - // 1) Пользователи и профили + // 1) Заголовки и профили // ----------------------------------------------------------------- List users = buildUsers(); seed.createUsersAndHeaders(users); diff --git a/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java b/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java index 9c1c9ce8..ae9eca17 100644 --- a/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java +++ b/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java @@ -1,6 +1,5 @@ package test.it.runner; -import test.it.cases.IT_01_AddUser; import test.it.cases.IT_00_TechnicalRequests; import test.it.cases.IT_02_Sessions; import test.it.cases.IT_03_AddBlock_NoAuth; @@ -44,9 +43,6 @@ public class IT_RunAllMain { String s0 = IT_00_TechnicalRequests.run(); summaries.add(s0); if (s0.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } - String s1 = IT_01_AddUser.run(); summaries.add(s1); - if (s1.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } - String s2 = IT_02_Sessions.run(); summaries.add(s2); if (s2.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } diff --git a/SHiNE-server/src/test/java/test/it/suite/IT_00_Suite.java b/SHiNE-server/src/test/java/test/it/suite/IT_00_Suite.java index a7c7516f..b0f29901 100644 --- a/SHiNE-server/src/test/java/test/it/suite/IT_00_Suite.java +++ b/SHiNE-server/src/test/java/test/it/suite/IT_00_Suite.java @@ -2,7 +2,6 @@ package test.it.suite; import org.junit.platform.suite.api.SelectClasses; import org.junit.platform.suite.api.Suite; -import test.it.cases.IT_01_AddUser; import test.it.cases.IT_02_Sessions; import test.it.cases.IT_03_AddBlock_NoAuth; import test.it.cases.Seed_TestDataPopulation; @@ -15,7 +14,6 @@ import test.it.cases.Seed_TestDataPopulation; */ @Suite @SelectClasses({ - IT_01_AddUser.class, IT_02_Sessions.class, IT_03_AddBlock_NoAuth.class, Seed_TestDataPopulation.class diff --git a/SHiNE-server/src/test/java/test/it/utils/TestConfig.java b/SHiNE-server/src/test/java/test/it/utils/TestConfig.java index 5554a2c2..f62395e8 100644 --- a/SHiNE-server/src/test/java/test/it/utils/TestConfig.java +++ b/SHiNE-server/src/test/java/test/it/utils/TestConfig.java @@ -126,12 +126,12 @@ public final class TestConfig { public static String BCH_NAME2() { return getBlockchainName(LOGIN2()); } public static String BCH_NAME3() { return getBlockchainName(LOGIN3()); } - /** solanaKey для AddUser: публичный ключ Solana-пользователя */ + /** публичный ключ Solana-пользователя */ public static String SOLANA_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN()); } public static String SOLANA2_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN2()); } public static String SOLANA3_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN3()); } - /** blockchainKey для AddUser: публичный ключ блокчейна */ + /** публичный ключ блокчейна */ public static String BLOCKCHAIN_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN()); } public static String BLOCKCHAIN2_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN2()); } public static String BLOCKCHAIN3_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN3()); } diff --git a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java index 07683c2d..20ce116b 100644 --- a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java +++ b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java @@ -11,40 +11,6 @@ import java.util.Base64; public final class JsonBuilders { private JsonBuilders() {} - // ---------------- AddUser ---------------- - - public static String addUser(String login) { - String requestId = TestIds.next("adduser"); - String blockchainName = TestConfig.getBlockchainName(login); - - String solanaKeyB64 = TestConfig.solanaPublicKeyB64(login); - String blockchainKeyB64 = TestConfig.blockchainPublicKeyB64(login); - String clientKeyB64 = TestConfig.clientPublicKeyB64(login); - - return """ - { - "op": "AddUser", - "requestId": "%s", - "payload": { - "login": "%s", - "blockchainName": "%s", - "solanaKey": "%s", - "blockchainKey": "%s", - "clientKey": "%s", - "bchLimit": %d - } - } - """.formatted( - requestId, - login, - blockchainName, - solanaKeyB64, - blockchainKeyB64, - clientKeyB64, - TestConfig.TEST_BCH_LIMIT - ); - } - // ---------------- GetUser ---------------- public static String getUser(String login) { diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9393aae8..42e7a809 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -67,7 +67,7 @@ The SHiNE Java server uses third-party dependencies from Maven Central, includin - Jackson (`com.fasterxml.jackson.core:jackson-databind`) - Apache-2.0 - Logback (`ch.qos.logback:logback-classic`) - EPL-1.0 / LGPL-2.1 - SLF4J (`org.slf4j:slf4j-api`) - MIT -- SQLite JDBC (`org.xerial:sqlite-jdbc`) - Apache-2.0 +- PostgreSQL JDBC (`org.postgresql:postgresql`) - BSD-2-Clause - Web Push Java library (`nl.martijndwars:web-push`) - Apache-2.0 - JUnit (`org.junit:*`) - EPL-2.0 diff --git a/TODO/README.md b/TODO/README.md index 6d4c72ff..6a13506f 100644 --- a/TODO/README.md +++ b/TODO/README.md @@ -49,7 +49,7 @@ - `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений. - `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи. - `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать. -- `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - подготовить перевод серверной БД с `SQLite` на `PostgreSQL` для более серьёзной конкурентной нагрузки и дальнейшего масштабирования. +- `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - завершить зачистку хвостов после перевода серверной БД с `SQLite` на `PostgreSQL`. ### dao_запуск diff --git a/TODO/medium/2026-07-22_переход_с_sqlite_на_postgresql.md b/TODO/medium/2026-07-22_переход_с_sqlite_на_postgresql.md index 3be89265..72c097a1 100644 --- a/TODO/medium/2026-07-22_переход_с_sqlite_на_postgresql.md +++ b/TODO/medium/2026-07-22_переход_с_sqlite_на_postgresql.md @@ -2,35 +2,56 @@ ## Зачем -Текущая серверная база на `SQLite` удобна для простого односерверного режима, но она хуже подходит для большого числа параллельных записей, роста нагрузки и дальнейшего масштабирования сервера. +Переход runtime-сервера на `PostgreSQL` уже выполнен, но после него остались хвосты в документации, именах, комментариях и части прямых SQL-запросов. -`PostgreSQL` нужен как следующий уровень серверной БД для более надёжной конкурентной записи, более предсказуемой работы под нагрузкой и дальнейшего роста проекта. +Этот TODO теперь нужен не для самого перехода, а для доведения проекта до полностью консистентного состояния после ухода от `SQLite`. ## Что сделать -- Подготовить план переноса серверной БД с `SQLite` на `PostgreSQL`. -- Найти все места, где код завязан на особенности `SQLite`. -- Проверить все DAO и SQL-запросы на совместимость с `PostgreSQL`. -- Продумать схему миграции существующей production/test базы без потери данных. -- Отдельно проверить транзакции, `UPSERT`, индексы, case-insensitive сравнения и миграции схемы. -- После этого подготовить отдельный этап внедрения и переключения сервера. +- Дочистить документацию, где ещё описан `SQLite` как текущий runtime. +- Убрать или переименовать legacy-названия и комментарии, которые уже не соответствуют PostgreSQL runtime. +- Постепенно перенести оставшиеся прямые SQL-запросы из хэндлеров в DAO/service. +- Проверить case-insensitive сравнения, уникальные ограничения и индексы уже в чисто PostgreSQL модели. +- Отдельно пройтись по TODO/служебным документам и убрать ссылки на удалённые SQLite-классы как на актуальный код. ## Что уже есть в коде - Доступ к БД в основном проходит через DAO-слой, а не полностью размазан по проекту. - Основная серверная логика уже разделена по модулям. -- Но SQL и миграции сейчас написаны под `SQLite` и потребуют отдельного прохода. +- Runtime-сервер уже работает только с `PostgreSQL`. +- Пустая БД инициализируется автоматически через `schema_v1`. ## Откуда продолжать -- Начать с инвентаризации всех DAO и схемы БД. -- После этого сделать отдельный документ с оценкой объёма работ по переносу. -- Затем решить, будет ли это: - - полный перевод сервера на `PostgreSQL`; - - или поддержка двух драйверов на переходный период. +- Продолжать с зачистки legacy-документации и комментариев. +- Затем добрать оставшиеся прямые SQL-запросы вне DAO. +- После этого можно отдельно решать вопрос косметического переименования `*V2`, `DbController` и других переходных сущностей. ## Что потом обновить - Серверную документацию по БД и миграциям. - Инструкции по локальному запуску сервера. - Скрипты деплоя и настройки окружения. + +## Что временно отключено и что вернуть потом + +- В серверном runtime временно снята проверка `channelName must not contain only digits` + в `SHiNE-server/shine-server-db/src/main/java/shine/db/channels/ChannelNameRules.java`. +- Причина: на боевой истории уже есть блоки с числовыми именами каналов, и сервер + должен уметь с нуля восстановить `blockchain_state` и `.bch`, подтягивая старые + блоки от других sync-серверов. +- Что осталось как текущее поведение: + - UI по-прежнему не даёт создать новый канал только из цифр; + - сервер принимает такие имена, чтобы не ломать replay старых блоков. +- Что нужно сделать отдельным следующим шагом: + - вернуть серверное продуктовое правило для новых каналов; + - сделать это совместимо со старой историей, чтобы импорт/реплей существующих + блоков не падал на старых числовых channel name. +- Какие документы обновить при возврате: + - `docs/libs/shine-server-bd/POSTGRES_RUNTIME_SCHEMA_V1.md`; + - UI/серверные документы по правилам имён каналов, если появится отдельная спецификация. +- С какого сценария продолжать: + - повторить cold start тест на `t2`: пустая PostgreSQL schema, удалённые `.bch`, + новый запуск, ожидание полной синхронизации от `t1`/`t3`. +- Последняя полная рабочая точка с этим временным компромиссом: + - ветка `migration-postgres`, коммит будет создан после этой записи. diff --git a/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md b/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md index e4b1bcb9..2b04642a 100644 --- a/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md +++ b/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md @@ -11,7 +11,7 @@ ## Что именно потом сделать -- удалить временную миграцию `migrateToV11()` из [SqliteDbController.java](/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java); +- найти историческое место, где была добавлена временная миграция `migrateToV11()`, и убрать её остатки из runtime-логики/документации; - удалить helper `clearLegacySignedMessagesForDmV11(...)`; - поднять версию схемы дальше обычным образом уже без destructive-cleanup; - при необходимости заменить это на нормальную точечную миграцию старых DM-записей или совсем убрать поддержку старой истории. diff --git a/VERSION.properties b/VERSION.properties index b7ad849d..dd9a30d5 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.3.0 -server.version=1.3.0 +client.version=1.4.0 +server.version=1.4.0 diff --git a/build.gradle b/build.gradle index 8014732a..28a0756f 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,8 @@ tasks.jar { tasks.processResources { filteringCharset = 'UTF-8' + inputs.file(appVersionFile) + inputs.property('projectVersion', project.version.toString()) filesMatching('application.properties') { expand(projectVersion: project.version.toString()) } @@ -81,6 +83,7 @@ dependencies { implementation project(':shine-server-net-protocol') // Модуль отвечающий за протокол (классы Net..Request/Response implementation project(':shine-server-net-server') // Хэндлеры для обработки сетевых запросов + implementation project(':shine-server-solana-users-sync') diff --git a/deploy/POSTGRESQL_SERVERS_STANDARD.md b/deploy/POSTGRESQL_SERVERS_STANDARD.md new file mode 100644 index 00000000..911abef0 --- /dev/null +++ b/deploy/POSTGRESQL_SERVERS_STANDARD.md @@ -0,0 +1,223 @@ +# PostgreSQL для серверов SHiNE + +Этот документ фиксирует целевой стандарт PostgreSQL, который должен использоваться на всех серверных контурах SHiNE после ухода от SQLite. + +Важно: + +- целевой стандарт для SHiNE: `PostgreSQL 18.x`; +- текущий факт на production `shineup.me` и `server2.shineup.me` на дату `2026-07-28`: `PostgreSQL 18.4`; +- текущий факт на test/devnet `t1` / `t2` / `t3` / `t4` на дату `2026-07-28`: `PostgreSQL 18.4`. + +## Цель + +На каждом сервере SHiNE должна быть отдельная локальная PostgreSQL-база: + +- запускается в Docker; +- использует `postgres:18`; +- хранит данные не внутри контейнера, а в примонтированной папке хоста; +- доступна только локально на сервере и из Docker-сети; +- не публикуется в интернет; +- автоматически перезапускается после reboot/crash; +- имеет healthcheck; +- использует отдельную БД приложения и отдельного пользователя приложения; +- пароли и секреты хранятся только в локальном `.env`/override-конфиге на сервере и не коммитятся в git. + +## Базовый стандарт + +- Версия PostgreSQL: `18.x` +- Docker image: `postgres:18` +- Имена по умолчанию: + - контейнер/сервис: `shine-postgres` + - база приложения: `shine_server_db` + - пользователь приложения: `shine_server` +- Политика перезапуска: `unless-stopped` +- Проверка готовности: `pg_isready` + +## Сетевой доступ + +PostgreSQL не должна быть доступна из интернета. + +Разрешённые варианты: + +- публиковать порт только на loopback: + - `127.0.0.1:5432:5432` +- либо не публиковать порт вообще, если клиент тоже живёт в Docker и ходит только по внутренней сети + +Запрещено: + +- `0.0.0.0:5432:5432` +- открытие `5432/tcp` через внешний firewall/NAT/public ingress + +Рекомендация по умолчанию для SHiNE: + +- использовать `127.0.0.1:5432:5432` + +Это даёт: + +- локальную диагностику через `psql` на самом сервере; +- отсутствие внешнего доступа из интернета; +- совместимость с приложением, если оно работает не в Docker. + +## Хранение данных + +Данные PostgreSQL должны храниться в локальной папке хоста, а не во внутреннем Docker volume контейнера. + +Причины: + +- проще бэкапить; +- проще переносить между серверами; +- проще контролировать место хранения; +- одинаковая схема для dev/test/production; +- ниже риск потерять данные при пересоздании контейнера. + +Рекомендуемая схема каталогов на сервере: + +Production: + +```text +/home/player/SHiNE/postgres/data +``` + +Test/devnet: + +```text +/home/player/tX/postgres/data +``` + +Если на одном сервере появится несколько баз SHiNE-сервисов, раскладывать их по отдельным каталогам: + +```text +/home/player/SHiNE/postgres/data +/home/player/SHiNE/postgres/analytics_db +/home/player/SHiNE/postgres/other_service_db +``` + +## Пользователи и права + +Нужны два уровня доступа: + +- системный `postgres` superuser для администрирования; +- рабочий пользователь приложения `shine_server` для подключения самого SHiNE server. + +Требования: + +- приложение не должно работать под `postgres`; +- `shine_server` не должен быть `superuser`; +- `shine_server` должен владеть своей БД `shine_server_db`; +- для миграций по умолчанию использовать пользователя приложения, если ему хватает прав; +- административные операции выполнять отдельно под `postgres`. + +## Секреты + +В git нельзя хранить: + +- `.env` с реальными паролями; +- connection strings с паролями; +- SQL-файлы с зашитыми production/test паролями. + +Хранить на сервере локально, например: + +```text +/home/player/SHiNE/postgres/.env +``` + +или в другом root-only каталоге секретов хоста. + +Минимально нужны: + +- пароль `postgres` +- пароль `shine_server` + +Допустимо, но не рекомендуется, временно использовать одинаковый пароль для локального dev. Для production и постоянных test-серверов лучше разные пароли. + +## Пример целевого compose + +Ниже пример целевой схемы, которую можно брать за основу для серверов: + +```yaml +services: + shine-postgres: + image: postgres:18 + container_name: shine-postgres + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_DB: ${POSTGRES_SUPERUSER_DB} + POSTGRES_USER: ${POSTGRES_SUPERUSER} + POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASSWORD} + ports: + - "127.0.0.1:5432:5432" + volumes: + - ${SHINE_POSTGRES_DATA_DIR}:/var/lib/postgresql + - ./initdb:/docker-entrypoint-initdb.d:ro + healthcheck: + test: + - CMD-SHELL + - pg_isready -U "$${POSTGRES_SUPERUSER}" -d "$${POSTGRES_SUPERUSER_DB}" + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s +``` + +## Рекомендуемые переменные окружения + +```dotenv +POSTGRES_SUPERUSER_DB=postgres +POSTGRES_SUPERUSER=postgres +POSTGRES_SUPERUSER_PASSWORD=change_me +SHINE_APP_DB=shine_server_db +SHINE_APP_USER=shine_server +SHINE_APP_PASSWORD=change_me_too +SHINE_POSTGRES_DATA_DIR=/home/player/SHiNE/postgres/data +``` + +Для test/devnet путь адаптировать под конкретный контур: + +```dotenv +SHINE_POSTGRES_DATA_DIR=/home/player/t2/postgres/data +``` + +Важно для `postgres:18+`: + +- bind-mount нужно делать на `/var/lib/postgresql`, а не на `/var/lib/postgresql/data`; +- если смонтировать старый путь `/var/lib/postgresql/data`, контейнер `postgres:18` может уйти в restart-loop ещё до инициализации БД; +- после первого старта PostgreSQL сам создаст внутри host-каталога структуру вида `18/docker`. + +## Подключение приложения + +SHiNE server должен подключаться именно к: + +- host: `127.0.0.1` +- port: `5432` +- db: `shine_server_db` +- user: `shine_server` + +Не подключать серверное приложение под `postgres`, кроме одноразовых административных операций вручную. + +## Бэкапы + +Так как данные лежат в bind-mount каталоге хоста, сама папка с данными должна попадать в серверную backup-стратегию. + +Но для PostgreSQL предпочтителен не только файловый backup, а как минимум один из вариантов: + +- регулярный `pg_dump` +- либо полноценный backup-скрипт с остановкой приложения/согласованным snapshot + +Минимальное требование: + +- перед серьёзными миграциями иметь свежий backup БД; +- перед production rollout новой серверной версии иметь проверяемый backup. + +## Для будущей миграции SHiNE + +При переводе нового серверного контура SHiNE с SQLite на PostgreSQL считать обязательным: + +- сначала поднять PostgreSQL по этому стандарту; +- затем добавить серверные конфиги подключения; +- затем прогнать миграции схемы; +- только потом переключать runtime приложения на PostgreSQL. + +Для новых серверов этот документ использовать как обязательный шаблон. +На `2026-07-28` и production, и test/devnet контуры SHiNE уже работают на `PostgreSQL 18.4`. diff --git a/deploy/PRODUCTION_SERVERS.md b/deploy/PRODUCTION_SERVERS.md index 0d9b82d9..f9152cc9 100644 --- a/deploy/PRODUCTION_SERVERS.md +++ b/deploy/PRODUCTION_SERVERS.md @@ -2,6 +2,13 @@ Production-контуров два. В документах и скриптах использовать домены, а не IP: физический VPS можно заменить без изменения deploy-логики. +Текущее фактическое состояние на `2026-07-28`: + +- `shineup.me` и `server2.shineup.me` оба уже переведены на `PostgreSQL 18.4`; +- оба production используют локальный Docker-контейнер `shine-postgres`; +- оба production работают без `SQLite`; +- оба production используют Helius mainnet RPC/WS для Solana users sync и runtime Solana RPC. + ## Основной production - Домен: `shineup.me` @@ -16,6 +23,17 @@ Production-контуров два. В документах и скриптах - Caddy site: `shineup.me` - WebSocket: `/ws` -> `127.0.0.1:7070` - Solana cluster: `mainnet-beta` +- Solana RPC/WS: Helius mainnet +- Runtime DB: PostgreSQL в Docker +- PostgreSQL container: `shine-postgres` +- PostgreSQL image: `postgres:18` +- PostgreSQL engine version на `2026-07-28`: `18.4` +- PostgreSQL data dir: `/home/player/SHiNE/postgres` +- PostgreSQL app DB: `shine_main_db` +- PostgreSQL app user: `shine_main` +- Web push: отдельный production VAPID key для `shineup.me` +- TURN для звонков: `turn1.shineup.me`, `turn2.shineup.me` +- SQLite runtime больше не используется Deploy: @@ -38,6 +56,17 @@ bash deploy/scripts/production_shineupme_ui.sh - Caddy site: `server2.shineup.me` - WebSocket: `/ws` -> `127.0.0.1:7070` - Solana cluster: `mainnet-beta` +- Solana RPC/WS: Helius mainnet +- Runtime DB: PostgreSQL в Docker +- PostgreSQL container: `shine-postgres` +- PostgreSQL image: `postgres:18` +- PostgreSQL engine version на `2026-07-28`: `18.4` +- PostgreSQL data dir: `/home/player/SHiNE/postgres` +- PostgreSQL app DB: `shine_server2_db` +- PostgreSQL app user: `shine_server2` +- Web push: отдельный production VAPID key для `server2.shineup.me` +- TURN для звонков: `turn1.shineup.me`, `turn3.shineup.me` +- SQLite runtime больше не используется Deploy: diff --git a/deploy/README.md b/deploy/README.md index 78bd95ba..19bd61ea 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -15,11 +15,21 @@ - Второй production: `server2.shineup.me`. - Test/devnet стенд: `t1.shineup.me`, `t2.shineup.me`, `t3.shineup.me`, `t4.shineup.me`. +Краткое текущее состояние на `2026-07-28`: + +- оба production уже на `PostgreSQL 18.4` в отдельных Docker-контейнерах `shine-postgres`; +- весь test/devnet стенд `t1`-`t4` тоже уже на `PostgreSQL 18.4`; +- тестовые контуры используют общий Docker-контейнер `shine-test-postgres`; +- production использует Helius mainnet, test/devnet использует Helius devnet; +- актуальные подробности по всем шести серверам смотреть в `PRODUCTION_SERVERS.md` и `TEST_SERVERS.md`. + ## Основные документы - `PRODUCTION_SERVERS.md` — production-контуры. - `TEST_SERVERS.md` — test/devnet-контуры. - `TURN_SERVERS.md` — TURN-серверы. +- `POSTGRESQL_SERVERS_STANDARD.md` — целевой стандарт PostgreSQL для всех серверов SHiNE. +- `SOLANA_USERS_SYNC_SERVER_SETUP.md` — интеграция синхронизации пользовательских Solana PDA в основной сервер. - `CONFIGURE_TURN_IN_SHINE.md` — как подключить TURN к SHiNE backend. - `SETUP_SERVER_FROM_ZERO.md` — настройка SHiNE-сервера и UI с нуля. - `SETUP_TURN_SERVER.md` — настройка TURN через Caddy/DNS/TLS. diff --git a/deploy/SETUP_SERVER_FROM_ZERO.md b/deploy/SETUP_SERVER_FROM_ZERO.md index de4b6f6d..22dd7c13 100644 --- a/deploy/SETUP_SERVER_FROM_ZERO.md +++ b/deploy/SETUP_SERVER_FROM_ZERO.md @@ -41,14 +41,19 @@ mkdir -p /home/player/tX/UI Каждый сервер должен иметь локальный внешний конфиг в рабочей директории сервиса. +Актуальные production-значения для `shineup.me` и `server2.shineup.me` смотреть в `deploy/PRODUCTION_SERVERS.md`. +Ниже именно шаблон для нового контура, а не снимок текущего production. + Production пример: ```properties server.port=7070 -server.SHiNE.login=shineupme -db.path=data/shine.sqlite +server.SHiNE.login= +db.url=jdbc:postgresql://127.0.0.1:5432/ +db.user= +db.password=CHANGE_ME server.ui.indexPath=/home/player/SHiNE/shine-ui/index.html -server.info.url=https://shineup.me +server.info.url=https:// server.info.origin=production solana.cluster=mainnet-beta ``` @@ -57,15 +62,23 @@ Test/devnet пример: ```properties server.port=7102 -server.SHiNE.login=server_t2 -db.path=data/shine.sqlite +server.SHiNE.login= +db.url=jdbc:postgresql://127.0.0.1:5432/ +db.user= +db.password=CHANGE_ME server.ui.indexPath=/home/player/t2/UI/index.html server.info.url=https://t2.shineup.me server.info.origin=devnet solana.cluster=devnet -solana.rpcUrl=https://api.devnet.solana.com +solana.rpcUrl=https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4 ``` +Рекомендуемый образ PostgreSQL для новых контуров: `postgres:18`. +Для `postgres:18+` bind-mount нужно делать на `/var/lib/postgresql`, а не на старый путь `/var/lib/postgresql/data`. + +Если указан `db.url` и в выбранной БД ещё нет таблицы `db_schema_version`, +сервер сам создаст runtime-схему PostgreSQL при первом старте. + ## 5. Caddy Минимальный site block: diff --git a/deploy/SETUP_TURN_SERVER.md b/deploy/SETUP_TURN_SERVER.md index cb2d291f..d45a625f 100644 --- a/deploy/SETUP_TURN_SERVER.md +++ b/deploy/SETUP_TURN_SERVER.md @@ -25,6 +25,9 @@ sudo apt install -y coturn caddy sudo bash deploy/scripts/setup_turn_coturn.sh --realm turn1.shineup.me --secret CHANGE_ME_LONG_RANDOM_SECRET ``` +По умолчанию этот скрипт настраивает relay-диапазон `49000-53999`. +Это стандарт SHiNE: 5000 UDP-портов под TURN relay. + ## 3. coturn Секреты не хранить в git. Настраивать на сервере через `/etc/turnserver.conf` или отдельный secret/override. @@ -44,6 +47,14 @@ no-multicast-peers no-cli ``` +Для SHiNE держать relay-range большим сразу, чтобы не упираться в `create_relay_ioa_sockets: no available ports`. +Стандартный диапазон: + +```conf +min-port=49000 +max-port=53999 +``` + Включить сервис: ```bash @@ -83,3 +94,4 @@ sudo ss -lntup | grep -E ':(3478|5349)' - TURN credentials и shared secret не коммитить. - Если TURN домен переносится на другой VPS, менять DNS, а не код приложения. - После изменения TURN-адресов обновить серверные/UI настройки, где они используются. +- Не сужать relay-range ниже 5000 UDP-портов без явной причины: для SHiNE это уже приводило к отказам звонков на проде `24 июля 2026`. diff --git a/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md b/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md new file mode 100644 index 00000000..b1f121ac --- /dev/null +++ b/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md @@ -0,0 +1,106 @@ +# Интеграция Solana users sync в сервер SHiNE + +Этот документ фиксирует серверную конфигурацию модуля синхронизации `shine_users` +и базовую инициализацию новой PostgreSQL runtime-схемы сервера. + +## Что уже есть + +- основной сервер запускает `SolanaUsersSyncStartupService` до продолжения startup; +- модуль синхронизации держит актуальными таблицы: + - `solana_sync_state` + - `solana_sync_tx_history` + - `solana_user_pda_current` + - `user_access_servers_current` + - `solana_user_pda_history` +- источник истины по пользовательским PDA: `solana_user_pda_current`. +- `user_access_servers_current` — это вторичная локальная проекция для быстрого роутинга DM по access servers; + она автоматически пересобирается из `solana_user_pda_current`, включая backfill для уже существующих пользователей. + +## Что должно быть настроено в `application.properties` + +Для текущих production-значений `shineup.me` и `server2.shineup.me` смотреть `deploy/PRODUCTION_SERVERS.md`. +Ниже универсальный пример для нового контура. + +```properties +solana.users.sync.enabled=true +solana.users.sync.rpcUrl=https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4 +solana.users.sync.wsUrl=wss://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4 +solana.users.sync.databaseUrl=jdbc:postgresql://127.0.0.1:5432/ +solana.users.sync.dbUser= +solana.users.sync.dbPassword=CHANGE_ME +solana.users.sync.pollIntervalSeconds=300 +``` + +Замечания: + +- `solana.users.sync.enabled=true` обязателен, иначе сервер пропустит startup sync. +- `solana.users.sync.databaseUrl` должен указывать на ту же PostgreSQL БД, где создана серверная runtime-схема. +- `solana.users.sync.wsUrl` задаётся явно, автоматически из `rpcUrl` не строится. + +## Как создать пустую PostgreSQL runtime БД + +SQL-скрипт инициализации лежит в: + +```text +SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +``` + +Пример запуска: + +```bash +psql \ + "postgresql://:CHANGE_ME@127.0.0.1:5432/" \ + -f SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +``` + +Скрипт: + +- создаёт таблицу версии схемы `db_schema_version`; +- ставит `schema_version = 2`; +- создаёт таблицы sync-модуля Solana users; +- создаёт server runtime tables; +- создаёт триггеры и функции автоматической актуализации `user_access_servers_current`; +- не создаёт удалённые legacy-таблицы старого runtime для пользователей и DM; +- использует `signed_messages` как единственную таблицу серверных DM. + +## Как поднять PostgreSQL в Docker + +Шаблоны лежат в: + +```text +deploy/postgres/docker-compose.yml.example +deploy/postgres/.env.example +``` + +Минимальная последовательность: + +```bash +mkdir -p /home/player/SHiNE/postgres +cp deploy/postgres/.env.example /home/player/SHiNE/postgres/.env +cp deploy/postgres/docker-compose.yml.example /home/player/SHiNE/postgres/docker-compose.yml +cd /home/player/SHiNE/postgres +docker compose up -d +``` + +Шаблон `deploy/postgres/docker-compose.yml.example` рассчитан на `postgres:18`. +Для `postgres:18+` он монтирует host-каталог в `/var/lib/postgresql`, это важно для корректного старта контейнера. + +После старта контейнера: + +```bash +cp /path/to/SHiNE-product/application.properties ./application.properties +# задать db.url/db.user/db.password и запустить сервер +``` + +Сложность тут низкая: + +- сам Docker Postgres поднимается просто; +- сервер сам создаёт runtime schema v1, если БД пустая и в ней нет `db_schema_version`; +- основная аккуратность нужна в паролях, bind-mount каталоге и backup; +- для SHiNE важно не открывать `5432` наружу, только `127.0.0.1:5432`. + +## Что пока остаётся как есть + +- `sync_servers` сервер по-прежнему загружает из server PDA в Solana; +- runtime-сервер уже работает только с PostgreSQL; +- дальнейшим отдельным шагом остаются зачистка legacy-документации, переименования и перенос оставшихся прямых SQL-запросов в DAO/service. diff --git a/deploy/TEST_SERVERS.md b/deploy/TEST_SERVERS.md index 95af47d1..164f737c 100644 --- a/deploy/TEST_SERVERS.md +++ b/deploy/TEST_SERVERS.md @@ -9,21 +9,34 @@ Это не production. Все deploy-цели задаются через домены. +Текущее фактическое состояние на `2026-07-28`: + +- все четыре тестовых контура `t1` / `t2` / `t3` / `t4` уже работают без `SQLite`; +- все четыре контура используют `PostgreSQL 18.4`; +- PostgreSQL на test-хосте общий для всех четырёх контуров и запущен в Docker-контейнере `shine-test-postgres`; +- все четыре контура используют один и тот же Helius devnet RPC/WS endpoint; +- во всех четырёх UI включён web push client-код и встроен один и тот же test `VAPID` public key. + ## Общие параметры - SSH: `player@tX.shineup.me` - Solana cluster: `devnet` -- Solana RPC: `https://api.devnet.solana.com` +- Solana RPC: `https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4` +- Solana WS: `wss://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4` - Caddy config: `/etc/caddy/Caddyfile` +- Общий PostgreSQL container: `shine-test-postgres` +- PostgreSQL image: `postgres:18` +- PostgreSQL engine version на `2026-07-28`: `18.4` +- Web push в UI: общий test `VAPID` public key `BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI` ## Инстансы -| Домен | Логин сервера | Сервер | UI | Порт | systemd | -|---|---|---|---|---|---| -| `t1.shineup.me` | `server_t1` | `/home/player/t1/server` | `/home/player/t1/UI` | `7101` | `shine-t1.service` | -| `t2.shineup.me` | `server_t2` | `/home/player/t2/server` | `/home/player/t2/UI` | `7102` | `shine-t2.service` | -| `t3.shineup.me` | `server_t3` | `/home/player/t3/server` | `/home/player/t3/UI` | `7103` | `shine-t3.service` | -| `t4.shineup.me` | `server_t4` | `/home/player/t4/server` | `/home/player/t4/UI` | `7104` | `shine-t4.service` | +| Домен | Логин сервера | Сервер | UI | Порт | systemd | PostgreSQL DB | PostgreSQL user | +|---|---|---|---|---|---|---|---| +| `t1.shineup.me` | `server_t1` | `/home/player/t1/server` | `/home/player/t1/UI` | `7101` | `shine-t1.service` | `shine_t1_db` | `shine_t1` | +| `t2.shineup.me` | `server_t2` | `/home/player/t2/server` | `/home/player/t2/UI` | `7102` | `shine-t2.service` | `shine_t2_db` | `shine_t2` | +| `t3.shineup.me` | `server_t3` | `/home/player/t3/server` | `/home/player/t3/UI` | `7103` | `shine-t3.service` | `shine_t3_db` | `shine_t3` | +| `t4.shineup.me` | `server_t4` | `/home/player/t4/server` | `/home/player/t4/UI` | `7104` | `shine-t4.service` | `shine_t4_db` | `shine_t4` | ## Deploy @@ -54,8 +67,15 @@ curl -I https://t4.shineup.me ```bash sudo systemctl --no-pager --full status caddy shine-t1 shine-t2 shine-t3 shine-t4 +sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' ``` ## Operational-нюанс -Официальный `https://api.devnet.solana.com` может отдавать `HTTP 429`, если несколько инстансов одновременно делают bootstrap PDA/sync-серверов. Если после деплоя какой-то инстанс не подтянул `sync_servers`, рестартовать сервисы по одному с паузой. +Общий Helius devnet endpoint тоже может начать ограничивать запросы, если несколько инстансов одновременно делают тяжёлый bootstrap. Если после деплоя какой-то инстанс не подтянул `sync_servers` или завис на startup sync, рестартовать сервисы по одному с паузой. + +Отдельный нюанс по web push: + +- у `t1`, `t2`, `t3`, `t4` в UI уже есть одинаковый встроенный `VAPID` public key; +- то есть `t3` и `t4` используют тот же web push клиентский код, что и `t1` / `t2`; +- если позже понадобится разделить push-ключи по тестовым контурам, это нужно делать отдельным UI deploy с новым `DEPLOY_WEBPUSH_VAPID_PUBLIC`. diff --git a/deploy/TURN_SERVERS.md b/deploy/TURN_SERVERS.md index 3107cb69..8555456d 100644 --- a/deploy/TURN_SERVERS.md +++ b/deploy/TURN_SERVERS.md @@ -17,6 +17,7 @@ TURN нужен для WebRTC-звонков, когда прямое peer-to-pe - DNS каждого `turnX.shineup.me` указывает на актуальный физический сервер TURN. - TLS/HTTPS и вспомогательная маршрутизация обслуживаются через Caddy, если на сервере есть web endpoint для проверки. - `coturn` слушает TURN/STUN порты согласно локальному `turnserver.conf`. +- Стандартный relay-range SHiNE: `49000-53999` (`5000` UDP-портов). - Секреты TURN не хранить в git. ## Что проверять diff --git a/deploy/postgres/.env.example b/deploy/postgres/.env.example new file mode 100644 index 00000000..3b7b701c --- /dev/null +++ b/deploy/postgres/.env.example @@ -0,0 +1,7 @@ +POSTGRES_SUPERUSER_DB=postgres +POSTGRES_SUPERUSER=postgres +POSTGRES_SUPERUSER_PASSWORD=CHANGE_ME_SUPERUSER +SHINE_APP_DB=shine_server_db +SHINE_APP_USER=shine_server +SHINE_APP_PASSWORD=CHANGE_ME_APP +SHINE_POSTGRES_DATA_DIR=/home/player/SHiNE/postgres/data diff --git a/deploy/postgres/docker-compose.yml.example b/deploy/postgres/docker-compose.yml.example new file mode 100644 index 00000000..509e5ba1 --- /dev/null +++ b/deploy/postgres/docker-compose.yml.example @@ -0,0 +1,23 @@ +services: + shine-postgres: + image: postgres:18 + container_name: shine-postgres + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_DB: ${POSTGRES_SUPERUSER_DB} + POSTGRES_USER: ${POSTGRES_SUPERUSER} + POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASSWORD} + ports: + - "127.0.0.1:5432:5432" + volumes: + - ${SHINE_POSTGRES_DATA_DIR}:/var/lib/postgresql + healthcheck: + test: + - CMD-SHELL + - pg_isready -U "$${POSTGRES_SUPERUSER}" -d "$${POSTGRES_SUPERUSER_DB}" + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s diff --git a/deploy/scripts/deploy_server.sh b/deploy/scripts/deploy_server.sh index e3647e03..f34dba41 100755 --- a/deploy/scripts/deploy_server.sh +++ b/deploy/scripts/deploy_server.sh @@ -57,8 +57,6 @@ WorkingDirectory=$REMOTE_SERVER_DIR ExecStart=/usr/bin/java -Dserver.port=$SERVER_PORT -jar $REMOTE_SERVER_DIR/shine-server.jar Restart=always RestartSec=3 -StandardOutput=append:$REMOTE_LOGS_DIR/app.log -StandardError=append:$REMOTE_LOGS_DIR/app.log [Install] WantedBy=multi-user.target @@ -72,7 +70,6 @@ ssh "$REMOTE_HOST" "set -euo pipefail; \ sudo mv -f '/tmp/$REMOTE_SERVICE_NAME.service' '/etc/systemd/system/$REMOTE_SERVICE_NAME.service'; \ sudo chown root:root '/etc/systemd/system/$REMOTE_SERVICE_NAME.service'; \ sudo chown -R player:player '$REMOTE_SERVER_DIR'; \ - touch '$REMOTE_LOGS_DIR/app.log'; \ sudo systemctl daemon-reload; \ sudo systemctl enable '$REMOTE_SERVICE_NAME'; \ sudo systemctl restart '$REMOTE_SERVICE_NAME'; \ diff --git a/deploy/scripts/deploy_ui.sh b/deploy/scripts/deploy_ui.sh index f1924068..279e2605 100755 --- a/deploy/scripts/deploy_ui.sh +++ b/deploy/scripts/deploy_ui.sh @@ -15,6 +15,7 @@ DEPLOY_SERVER_LOGIN="${DEPLOY_SERVER_LOGIN:-}" DEPLOY_SERVER_ADDRESS="${DEPLOY_SERVER_ADDRESS:-}" DEPLOY_SOLANA_CLUSTER="${DEPLOY_SOLANA_CLUSTER:-}" DEPLOY_SOLANA_ENDPOINT="${DEPLOY_SOLANA_ENDPOINT:-}" +DEPLOY_WEBPUSH_VAPID_PUBLIC="${DEPLOY_WEBPUSH_VAPID_PUBLIC:-}" REQUIRE_BACKUP="${REQUIRE_BACKUP:-0}" BACKUP_DIR="${BACKUP_DIR:-$ROOT_DIR/deploy/backup/archive}" VERSION_FILE="$ROOT_DIR/VERSION.properties" @@ -72,7 +73,7 @@ if [[ -f "$DEPLOY_CONFIG_FILE" ]]; then if [[ -n "$DEPLOY_SOLANA_CLUSTER_NORMALIZED" ]]; then perl -0pi -e "s/export const defaultSolanaCluster = '.*?';/export const defaultSolanaCluster = '$DEPLOY_SOLANA_CLUSTER_NORMALIZED';/s" "$DEPLOY_CONFIG_FILE" fi - if [[ -n "$DEPLOY_SOLANA_ENDPOINT" ]]; then +if [[ -n "$DEPLOY_SOLANA_ENDPOINT" ]]; then perl -0pi -e "s|export const defaultSolanaEndpoint = '.*?';|export const defaultSolanaEndpoint = '$DEPLOY_SOLANA_ENDPOINT';|s" "$DEPLOY_CONFIG_FILE" fi fi @@ -85,6 +86,10 @@ fi perl -0pi -e 's/window\.__SHINE_BUILD_HASH__\s*=\s*'\''[^'\'']*'\'';/window.__SHINE_BUILD_HASH__ = '\''$ENV{BUILD_VERSION}'\'';/' "$INDEX_FILE" perl -0pi -e 's/window\.__SHINE_CLIENT_VERSION__\s*=\s*'\''[^'\'']*'\'';/window.__SHINE_CLIENT_VERSION__ = '\''$ENV{CLIENT_VERSION}'\'';/' "$INDEX_FILE" +if [[ -n "$DEPLOY_WEBPUSH_VAPID_PUBLIC" ]]; then + export DEPLOY_WEBPUSH_VAPID_PUBLIC + perl -0pi -e 's/window\.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__\s*=\s*'\''[^'\'']*'\'';/window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ = '\''$ENV{DEPLOY_WEBPUSH_VAPID_PUBLIC}'\'';/' "$INDEX_FILE" +fi echo "==> Checking SSH connectivity to $REMOTE_HOST" ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE_HOST" "echo SSH OK" >/dev/null diff --git a/deploy/scripts/setup_turn_coturn.sh b/deploy/scripts/setup_turn_coturn.sh index 20494924..a3d7c68b 100755 --- a/deploy/scripts/setup_turn_coturn.sh +++ b/deploy/scripts/setup_turn_coturn.sh @@ -9,8 +9,8 @@ set -euo pipefail SECRET="" REALM="turn1.shineup.me" -MIN_PORT="49160" -MAX_PORT="49200" +MIN_PORT="49000" +MAX_PORT="53999" while [[ $# -gt 0 ]]; do case "$1" in @@ -23,11 +23,11 @@ while [[ $# -gt 0 ]]; do shift 2 ;; --min-port) - MIN_PORT="${2:-49160}" + MIN_PORT="${2:-49000}" shift 2 ;; --max-port) - MAX_PORT="${2:-49200}" + MAX_PORT="${2:-53999}" shift 2 ;; *) @@ -87,6 +87,8 @@ systemctl --no-pager --full status coturn echo echo "coturn настроен." +echo "Стандартный relay-range SHiNE: ${MIN_PORT}-${MAX_PORT}." +echo "Рекомендуемый размер диапазона: не меньше 5000 UDP-портов." echo "Откройте firewall: 3478/tcp, 3478/udp, ${MIN_PORT}-${MAX_PORT}/udp" echo "Для SHiNE-сервера задайте такой же shared-secret в параметре:" echo " -Dcall.ice.turn.sharedSecret=${SECRET}" diff --git a/deploy/scripts/test_t1_ui.sh b/deploy/scripts/test_t1_ui.sh index c097b1fc..1a45b40f 100755 --- a/deploy/scripts/test_t1_ui.sh +++ b/deploy/scripts/test_t1_ui.sh @@ -8,6 +8,6 @@ EXPECTED_CADDY_SITE="t1.shineup.me" \ DEPLOY_SERVER_LOGIN="server_t1" \ DEPLOY_SERVER_ADDRESS="t1.shineup.me" \ DEPLOY_SOLANA_CLUSTER="devnet" \ -DEPLOY_SOLANA_ENDPOINT="https://api.devnet.solana.com" \ +DEPLOY_SOLANA_ENDPOINT="https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4" \ TARGET_URL="https://t1.shineup.me" \ bash "$(dirname "$0")/deploy_ui.sh" diff --git a/deploy/scripts/test_t2_ui.sh b/deploy/scripts/test_t2_ui.sh index fbeeaf61..345ba0d2 100755 --- a/deploy/scripts/test_t2_ui.sh +++ b/deploy/scripts/test_t2_ui.sh @@ -8,6 +8,6 @@ EXPECTED_CADDY_SITE="t2.shineup.me" \ DEPLOY_SERVER_LOGIN="server_t2" \ DEPLOY_SERVER_ADDRESS="t2.shineup.me" \ DEPLOY_SOLANA_CLUSTER="devnet" \ -DEPLOY_SOLANA_ENDPOINT="https://api.devnet.solana.com" \ +DEPLOY_SOLANA_ENDPOINT="https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4" \ TARGET_URL="https://t2.shineup.me" \ bash "$(dirname "$0")/deploy_ui.sh" diff --git a/deploy/scripts/test_t3_ui.sh b/deploy/scripts/test_t3_ui.sh index fd76ef8e..108d4f5b 100755 --- a/deploy/scripts/test_t3_ui.sh +++ b/deploy/scripts/test_t3_ui.sh @@ -8,6 +8,6 @@ EXPECTED_CADDY_SITE="t3.shineup.me" \ DEPLOY_SERVER_LOGIN="server_t3" \ DEPLOY_SERVER_ADDRESS="t3.shineup.me" \ DEPLOY_SOLANA_CLUSTER="devnet" \ -DEPLOY_SOLANA_ENDPOINT="https://api.devnet.solana.com" \ +DEPLOY_SOLANA_ENDPOINT="https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4" \ TARGET_URL="https://t3.shineup.me" \ bash "$(dirname "$0")/deploy_ui.sh" diff --git a/deploy/scripts/test_t4_ui.sh b/deploy/scripts/test_t4_ui.sh index 415f1124..7fb11657 100755 --- a/deploy/scripts/test_t4_ui.sh +++ b/deploy/scripts/test_t4_ui.sh @@ -8,6 +8,6 @@ EXPECTED_CADDY_SITE="t4.shineup.me" \ DEPLOY_SERVER_LOGIN="server_t4" \ DEPLOY_SERVER_ADDRESS="t4.shineup.me" \ DEPLOY_SOLANA_CLUSTER="devnet" \ -DEPLOY_SOLANA_ENDPOINT="https://api.devnet.solana.com" \ +DEPLOY_SOLANA_ENDPOINT="https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4" \ TARGET_URL="https://t4.shineup.me" \ bash "$(dirname "$0")/deploy_ui.sh" diff --git a/docs/API/01_User_Registration_API.md b/docs/API/01_User_Registration_API.md index 1a1bb59c..ba411334 100644 --- a/docs/API/01_User_Registration_API.md +++ b/docs/API/01_User_Registration_API.md @@ -2,13 +2,12 @@ Этот файл описывает раздел API, связанный с проверкой наличия пользователя на сервере и dev/test операциями. -Сейчас здесь три метода: +Сейчас здесь два метода: -- `AddUser` — операция отключена (регистрация только через Solana); - `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных; -- `SearchUsers` — dev/test поиск логинов по префиксу. +- `SearchUsers` — dev/test поиск логинов по префиксу, при необходимости с фильтром только по server PDA. -Регистрация выполняется через Solana (`shine_users`). Сервер при входе может лениво импортировать пользователя из Solana PDA в локальную БД, если записи ещё нет. +Регистрация выполняется только через Solana. ## Статус документа @@ -16,53 +15,7 @@ Текущая регистрация пользователя и текущая проверка, существует пользователь или нет, пока реализованы как серверные dev/test операции. В будущем и регистрация, и проверка identity должны идти напрямую через Solana. ---- - -## 1. Операция `AddUser` - -### Назначение - -Операция отключена. Используется только как явный ответ клиентам старых версий. - -### Запрос - -```json -{ - "op": "AddUser", - "requestId": "reg-001", - "payload": { - "login": "anya", - "blockchainName": "anya-001", - "solanaKey": "BASE64_32_PUBLIC_KEY", - "blockchainKey": "BASE64_32_PUBLIC_KEY", - "clientKey": "BASE64_32_PUBLIC_KEY", - "bchLimit": 1000000 - } -} -``` - -### Пример ответа - -```json -{ - "op": "AddUser", - "requestId": "reg-001", - "status": 410, - "ok": false, - "error": "ADD_USER_DISABLED", - "message": "Серверная регистрация AddUser отключена. Используйте регистрацию через Solana.", - "payload": { - } -} -``` - -### Специфические коды ошибок `AddUser` - -- `410 / ADD_USER_DISABLED` — серверная регистрация отключена, используйте Solana-first flow. - ---- - -## 2. Операция `GetUser` +## 1. Операция `GetUser` ### Назначение @@ -153,11 +106,12 @@ --- -## 3. Операция `SearchUsers` +## 2. Операция `SearchUsers` ### Назначение Поиск пользователей по префиксу логина. Операция зарегистрирована в серверном API и используется как вспомогательная dev/test операция. +При необходимости можно включить фильтр только по server PDA. ### Запрос @@ -166,11 +120,16 @@ "op": "SearchUsers", "requestId": "search-001", "payload": { - "prefix": "an" + "prefix": "an", + "isServer": true } } ``` +- `prefix` — обязательный префикс логина. +- `isServer` — необязательный boolean-флаг; если `true`, сервер вернёт только логины, + у которых в `solana_user_pda_current` стоит `is_server = true`. + ### Успешный ответ ```json @@ -195,7 +154,6 @@ ## 4. Короткое резюме -- `AddUser` — отключен (`410 / ADD_USER_DISABLED`). - `GetUser` — проверка существования пользователя на сервере. - `SearchUsers` — временный поиск пользователей по префиксу. - Регистрация выполняется только через Solana. diff --git a/docs/API/02_Authentication_API.md b/docs/API/02_Authentication_API.md index 735b4b2d..470434b4 100644 --- a/docs/API/02_Authentication_API.md +++ b/docs/API/02_Authentication_API.md @@ -138,7 +138,7 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} Перед проверкой подписи сервер должен: -1. взять актуальный `solana_users.client_key`; +1. взять актуальный `solana_user_pda_current.client_key`; 2. сравнить его с `payload.clientKey`; 3. только потом проверять подпись. diff --git a/docs/API/05_Technical_Requests_API.md b/docs/API/05_Technical_Requests_API.md index 79604bc6..d458d04a 100644 --- a/docs/API/05_Technical_Requests_API.md +++ b/docs/API/05_Technical_Requests_API.md @@ -19,7 +19,7 @@ - `Ping` нужен для регулярной проверки, что соединение всё ещё живо; - `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла. - `ListBlockchainHeads` нужен для сервер-сервер сверки: партнёр получает список heads по всем цепочкам, сравнивает его со своим состоянием и затем добирает недостающие блоки по диапазону. -- `GetSyncUserProfile` нужен для server-to-server режима, когда принимающий сервер хочет создать у себя локальные `solana_users + blockchain_state` без прямого обращения в Solana. Это используется как временный обход ограничений внешнего Solana RPC. +- `GetSyncUserProfile` нужен для server-to-server режима, когда принимающий сервер хочет создать у себя локальную runtime-проекцию пользователя и `blockchain_state` без прямого обращения в Solana. - `SendSignal` нужен для доверенных межсессионных команд одного пользователя. Первое практическое применение — `remote AddBlock via homeserver session`, но формат задуман как общий transport на вырост. Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов. @@ -216,7 +216,7 @@ - `clientKey` - `blockchainSizeLimitBytes` -После этого принимающий сервер может локально создать записи в `solana_users` и `blockchain_state`, а затем уже докачивать блоки через `GetBlockchainBlock`. +После этого принимающий сервер может локально создать runtime-проекцию пользователя и запись в `blockchain_state`, а затем уже докачивать блоки через `GetBlockchainBlock`. Этот запрос доступен без авторизации и предназначен именно для server-to-server sync. diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 8eb08bfd..69a01682 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -12,7 +12,6 @@ | Операция | Раздел документации | Кратко | | --- | --- | --- | -| `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) | | `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна | | `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу | | `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара | @@ -62,6 +61,7 @@ | `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока | | `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон | | `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки | +| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | межсерверная догоняющая синхронизация DM по курсору | | `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога | | `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию | | `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку | diff --git a/docs/API/12_Direct_Messages_Push_Calls_API.md b/docs/API/12_Direct_Messages_Push_Calls_API.md index 114b0d03..f576bb2e 100644 --- a/docs/API/12_Direct_Messages_Push_Calls_API.md +++ b/docs/API/12_Direct_Messages_Push_Calls_API.md @@ -10,7 +10,8 @@ Важно: - legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API; -- для DM v1 нужно использовать только `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`. +- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`; +- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI. ## 1. `UpsertPushToken` @@ -139,11 +140,14 @@ "op": "ReceiveIncomingMessage", "requestId": "dm-in-001", "payload": { - "incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK" + "incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK", + "sourceServerLogin": "server-a" } } ``` +`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику. + ## 5. `DeleteMessage` Принимает один signed DM-блок `type=5` или `type=6`. @@ -241,7 +245,69 @@ Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа. -## 8. `AckSessionDelivery` +## 8. `DmSyncBatch` + +Межсерверная операция для догоняющей синхронизации истории одного пользователя. В текущей реализации не требует авторизации сервера-источника, но удалённый сервер отдаёт данные только если сам является access-сервером `ownerLogin` по `user_access_servers_current`. + +### Запрос + +```json +{ + "op": "DmSyncBatch", + "requestId": "dm-sync-001", + "payload": { + "ownerLogin": "alice", + "afterStoredAtMs": 1774700000000, + "afterMessageKey": "alice|bob|1774699999000|123456780|2", + "limit": 500, + "maxBytes": 3000000 + } +} +``` + +`afterStoredAtMs` и `afterMessageKey` образуют курсор. Если курсора нет, сервер передаёт `0` и пустую строку. `limit` ограничен максимумом `500`. + +### Успешный ответ + +```json +{ + "op": "DmSyncBatch", + "requestId": "dm-sync-001", + "status": 200, + "ok": true, + "payload": { + "ownerLogin": "alice", + "limit": 500, + "rawBytes": 84512, + "hasMore": true, + "nextStoredAtMs": 1774700100000, + "nextMessageKey": "alice|bob|1774700000123|123456789|1", + "items": [ + { + "messageKey": "alice|bob|1774700000123|123456789|1", + "baseKey": "alice|bob|1774700000123|123456789", + "targetLogin": "alice", + "fromLogin": "bob", + "toLogin": "alice", + "messageType": 1, + "timeMs": 1774700000123, + "storedAtMs": 1774700100000, + "blobB64": "BASE64_SIGNED_BLOCK" + } + ] + } +} +``` + +События в `items` идут по `storedAtMs ASC, messageKey ASC`. В пачке могут быть сообщения любых диалогов пользователя, read-receipt и delete/tombstone типов `5/6/7/8`. + +Ошибки: + +- `400 / EMPTY_OWNER_LOGIN` — не передан `ownerLogin` +- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER` — этот сервер не является access-сервером пользователя +- `500 / LOCAL_SERVER_NOT_CONFIGURED` — не настроен `server.SHiNE.login` + +## 9. `AckSessionDelivery` Требует авторизации. Подтверждает доставку в текущую сессию. @@ -257,7 +323,7 @@ } ``` -## 9. Событие `SignedMessageArrived` +## 10. Событие `SignedMessageArrived` Сервер присылает его по WebSocket в активные сессии адресата. @@ -282,15 +348,15 @@ Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`. -## 10. `CallInviteBroadcast` +## 11. `CallInviteBroadcast` Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`. -## 10. `CallSignalToSession` +## 12. `CallSignalToSession` Требует авторизации. Шлёт сигнал звонка в конкретную сессию. -## 11. Замечания +## 13. Замечания - все DM-типы `1..8` используют `SHiNE_DM` - `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE diff --git a/docs/API/14_Test_Free_Avatar_Upload_API.md b/docs/API/14_Test_Free_Avatar_Upload_API.md index 29438ff6..7fb55656 100644 --- a/docs/API/14_Test_Free_Avatar_Upload_API.md +++ b/docs/API/14_Test_Free_Avatar_Upload_API.md @@ -9,7 +9,7 @@ - загрузка идёт через **серверный Arweave-кошелёк**; - лимит на пользователя: по умолчанию `3` загрузки за всё время; -- лимит хранится в SQLite-таблице `test_free_avatar_uploads`; +- лимит хранится в PostgreSQL-таблице `test_free_avatar_uploads`; - если лимит исчерпан, сервер возвращает понятную ошибку; - загружать можно только маленький итоговый файл аватара, по умолчанию до `128 KB`. diff --git a/docs/Blockchain/sync-between-servers.md b/docs/Blockchain/sync-between-servers.md index d4413ed8..07d831b5 100644 --- a/docs/Blockchain/sync-between-servers.md +++ b/docs/Blockchain/sync-between-servers.md @@ -60,7 +60,7 @@ - `ListBlockchainHeads` — список heads всех локальных цепочек партнёра; - `GetBlockchainBlock` — чтение одного конкретного блока партнёра; -- `GetSyncUserProfile` — минимальный профиль пользователя для локального создания `solana_users + blockchain_state` без обращения в Solana RPC. +- `GetSyncUserProfile` — минимальный профиль пользователя для локального создания runtime-проекции пользователя и `blockchain_state` без обращения в Solana RPC. ### 4.3 Как сейчас работает periodic sync @@ -73,7 +73,7 @@ - локальное состояние; 3. если локальная цепочка слабее, сервер по одному блоку вызывает `GetBlockchainBlock`; 4. каждый скачанный блок локально применяется через существующий `AddBlock`; -5. если у сервера ещё нет локальной записи пользователя/цепочки, перед этим подготавливается локальный `solana_users + blockchain_state`. +5. если у сервера ещё нет локальной записи пользователя/цепочки, перед этим подготавливается локальная runtime-проекция пользователя и `blockchain_state`. 6. если во время replay обнаруживается рассинхрон или на одинаковой высоте удалённая цепочка сильнее, запускается полный resync: - цепочка помечается in-memory как `resync in progress`; - создаётся marker-file в `data/`; @@ -110,7 +110,7 @@ Full resync запускается только тогда, когда: Важно: - full resync не делает умный rollback по одному блоку; -- full resync не трогает DM-таблицы и `solana_users`; +- full resync не трогает DM-таблицы и current users слой; - висячие cross-chain ссылки считаются допустимым поведением системы. ### 4.5 Как работает обычный `AddBlock` и его recovery @@ -150,7 +150,7 @@ Full resync запускается только тогда, когда: - из `blockchainName` извлекался `login`; - сервер вызывал import пользователя из Solana PDA; -- по данным PDA локально создавались `solana_users + blockchain_state`. +- по данным PDA локально создавались runtime-проекция пользователя и `blockchain_state`. На практике это упёрлось в ограничение внешнего Solana RPC: при чистом старте и массовой подтяжке чужих цепочек сервер мог получать `HTTP 429`. @@ -159,7 +159,7 @@ Full resync запускается только тогда, когда: - настройка `sync.importUserProfileFromPartner.enabled=true` - в этом режиме сервер **не ходит в Solana RPC** для создания локальной цепочки во время sync; - вместо этого он запрашивает у сервера-партнёра `GetSyncUserProfile` и создаёт локальную запись по данным партнёра. -- если локальный `solana_users` уже существует, sync восстанавливает только `blockchain_state` и не трогает identity-слой. +- если локальная runtime-проекция пользователя уже существует, sync восстанавливает только `blockchain_state` и не трогает user-layer. Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint. diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 84328880..3798f1bb 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -103,7 +103,7 @@ Для DM-проверки сервер использует: -- локальную `solana_users` как кэш; +- локальную runtime-проекцию пользователей как кэш; - Solana PDA как источник истины по `clientKey` и `access_servers`. Если нужного пользователя нет локально, сервер обязан попытаться lazy-import из Solana PDA: @@ -446,11 +446,14 @@ Request: "op": "ReceiveIncomingMessage", "requestId": "req-456", "payload": { - "incomingBlobB64": "..." + "incomingBlobB64": "...", + "sourceServerLogin": "server-a" } } ``` +`sourceServerLogin` необязателен и используется как best-effort подсказка, чтобы сервер при дальнейшей пересылке не отправлял то же событие обратно серверу-источнику. + ### 10.3. `DeleteMessage` Назначение: @@ -559,7 +562,50 @@ UI-следствие для клиента: Идемпотентность обязательна. -### 11.3. Ошибки доставки +### 11.3. Догоняющая синхронизация истории + +Для восстановления пропущенных DM-событий между access-серверами используется отдельная операция: + +- `DmSyncBatch` + +Сервер-получатель синхронизации запрашивает у другого access-сервера историю одного пользователя по курсору: + +- `ownerLogin`; +- `afterStoredAtMs`; +- `afterMessageKey`; +- `limit`, максимум `500`; +- `maxBytes`, ограничение суммарного размера raw-блоков пачки. + +Удалённый сервер отдаёт все DM-события, относящиеся к этому пользователю: + +- контентные копии и read-receipt по `target_login`; +- tombstone типов `5/6/7/8`, где пользователь участвует как `fromLogin` или `toLogin`. + +Порядок пачки: + +- `created_at_ms ASC`; +- `message_key ASC`. + +Курсор хранится локально для пары: + +- пользователь; +- удалённый access-сервер. + +При первом добавлении сервера или отсутствии курсора синхронизация стартует с `0` и постепенно подтягивает всю доступную историю пачками. + +При применении событий, полученных через `DmSyncBatch`, сервер: + +- проверяет формат `SHiNE_DM`; +- проверяет подпись; +- применяет существующие правила ревизий, read-receipt и tombstone; +- не отправляет realtime/push-уведомления клиентам; +- не запускает повторный fan-out, чтобы не создавать циклы. + +Плановый sync запускается фоном после старта WebSocket-сервера и повторяется раз в 6 часов. + +В текущей реализации межсерверная авторизация для `DmSyncBatch` ещё не включена. Сервер отдаёт пачку только если сам локально является access-сервером `ownerLogin` по актуальной таблице `user_access_servers_current`. + +### 11.4. Ошибки доставки Если часть серверов временно недоступна: @@ -571,7 +617,7 @@ UI-следствие для клиента: Основная таблица остаётся: -- `signed_messages_v2` +- `signed_messages` В ней должны сохраняться: @@ -590,6 +636,12 @@ UI-следствие для клиента: Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются. +Для догоняющей межсерверной синхронизации дополнительно используются: + +- индекс по `target_login`, `created_at_ms`, `message_key`; +- отдельные индексы по delete-событиям для `from_login` и `to_login`; +- таблица `dm_sync_peer_state` с курсором чтения для пары `ownerLogin + remoteServerLogin`. + ## 13. Что обязательно должно измениться в коде относительно v0.5 - сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`; @@ -610,4 +662,4 @@ UI-следствие для клиента: - хранение отдельного `keyId` шифрования в DM; - ротация `clientKey`; - финальная конкретная UI-реализация массовой перешифровки; -- физическая полная реализация DM federation в текущем коде. +- межсерверная авторизация `DmSyncBatch`. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index 538cb19f..98b75add 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -327,3 +327,5 @@ ReadReceiptBody_v1_0 ## 13. Примечание о поддержке В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`. + +Межсерверная операция `DmSyncBatch` не вводит новый байтовый формат DM. Она передаёт уже сохранённые raw-контейнеры `SHiNE_DM` в Base64 вместе с серверными метаданными курсора (`storedAtMs`, `messageKey`), а принимающий сервер заново проверяет подпись и применяет тот же контейнер по его `messageType`. diff --git a/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md b/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md new file mode 100644 index 00000000..132922e6 --- /dev/null +++ b/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md @@ -0,0 +1,559 @@ +# Модуль синхронизации `shine_users` из Solana + +## Назначение + +Этот модуль нужен для локальной серверной синхронизации всех пользовательских PDA программы `shine_users`. + +Цель модуля: + +- при старте получить актуальное состояние всех пользовательских PDA; +- дальше держать локальную копию в актуальном состоянии; +- хранить историю просмотренных транзакций синхронизации; +- хранить историю всех версий пользовательских PDA; +- уметь после рестарта продолжать синхронизацию без потери изменений; +- в будущем без большого переписывания встраиваться в основной SHiNE-сервер и блокировать дальнейший startup до входа в состояние `READY`. + +На текущем этапе модуль должен запускаться как отдельный Java-процесс со своим `main`, но внутренняя структура должна быть такой, чтобы потом его можно было перенести в сервер как обычный lifecycle-сервис. + +На дату `2026-07-24` в основном сервере SHiNE уже есть серверная интеграция startup-уровня: + +- сервер запускает sync-модуль до продолжения собственного startup; +- server startup ждёт входа модуля в состояние `READY`; +- актуальный источник истины по пользователям для нового PostgreSQL runtime-слоя: `solana_user_pda_current`. +- для быстрого локального маршрутизационного lookup серверов доступа поддерживается вторичная проекция + `user_access_servers_current`, которая автоматически пересобирается из `solana_user_pda_current`. + +--- + +## Базовая идея + +Модуль использует два источника данных: + +1. realtime-поток через Solana WebSocket `programSubscribe` для программы `shine_users`; +2. историю транзакций через `getSignaturesForAddress(economy_config_pda)`. + +Ключевая договорённость: + +- каждая транзакция, которая создаёт или обновляет пользовательский `user_pda`, обязательно читает `users_economy_config_pda`; +- значит, история по `users_economy_config_pda` является полным журналом всех релевантных `create/update user_pda` транзакций; +- дополнительные транзакции, которые тоже читают или меняют `users_economy_config_pda`, допустимы и не мешают: модуль должен уметь распознавать, что такая транзакция не относится к изменению пользовательского PDA. + +`users_economy_config_pda` вычисляется из: + +- `program_id = SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` +- seed = `shine_users_economy_config` + +Источник в коде контракта: + +- `settings::USERS_ECONOMY_CONFIG_SEED = b"shine_users_economy_config"` +- `create_user_pda` и `update_user_pda` принимают `users_economy_config_pda` как обязательный аккаунт. + +--- + +## Что считается состоянием готовности + +Модуль должен иметь внутреннее состояние готовности `READY`. + +В `READY` он входит только после того, как: + +1. установлено websocket-подключение; +2. выполнена начальная актуализация истории; +3. локальная таблица актуальных PDA приведена в консистентное состояние; +4. таблица состояния синхронизации обновлена. + +Пока модуль не вошёл в `READY`, будущий сервер при встраивании не должен продолжать собственный startup. + +Для этого модуль должен поддерживать: + +- `start()` +- `awaitReady()` +- `isReady()` +- `close()` + +Для отдельного процесса `main` допустимо также печатать явный лог о входе в `READY`, но главным механизмом для будущего сервера должен быть Java API, а не парсинг логов. + +--- + +## Почему нельзя опираться только на WebSocket + +WebSocket `programSubscribe` хорош для realtime, но недостаточен как единственный источник истины: + +- если websocket временно оборвался, часть событий может быть пропущена; +- если соединение формально живо, но какое-то событие было потеряно, это не всегда можно заметить сразу; +- если в интервале не было транзакций, websocket по определению ничего не пришлёт. + +Поэтому нужен второй защитный контур: + +- периодический аудит истории по `users_economy_config_pda`; +- запуск такого аудита раз в 5 минут. + +--- + +## Общий lifecycle модуля + +### 1. Старт процесса + +При старте процесса модуль: + +1. читает конфиг; +2. инициализирует локальную PostgreSQL БД; +3. создаёт RPC-клиент; +4. создаёт WebSocket-клиент; +5. создаёт coordinator/service слой; +6. запускает realtime-подписку; +7. после подтверждённой подписки выполняет начальную актуализацию истории; +8. после успешной актуализации выставляет `READY`. + +### 2. Работа в фоне + +После входа в `READY` модуль одновременно: + +- принимает realtime-обновления по websocket; +- раз в 5 минут запускает проверку истории через RPC; +- пишет журнал транзакций; +- пишет историю версий пользовательских PDA; +- обновляет актуальный снимок пользовательских PDA. + +### 3. Реконнект + +Если websocket оборвался: + +1. запускается reconnect; +2. после успешного переподключения выполняется повторная актуализация истории; +3. модуль снова возвращается в нормальный режим. + +### 4. Остановка + +При остановке: + +- закрывается websocket; +- останавливаются фоновые scheduler/worker потоки; +- закрывается RPC-клиент; +- закрывается БД. + +--- + +## Источник истории: только по сигнатурам + +Основной checkpoint модуля должен строиться по сигнатурам транзакций, а не по времени. + +Хранить нужно: + +- последнюю просмотренную сигнатуру; +- последний просмотренный слот; +- последнюю релевантную сигнатуру; +- время последней успешной актуализации. + +Время хранится только как вспомогательная диагностика. Продолжение истории должно идти по сигнатурам и слотам. + +--- + +## Поведение начальной актуализации + +### Сценарий A: локальная БД пустая + +Если локальная БД ещё не содержит синхронизированных данных: + +1. выполняется первичная загрузка пользовательских PDA; +2. после этого фиксируется начальная точка истории; +3. модуль переходит в `READY`. + +Предпочтительный вариант: + +- использовать историю транзакций по `users_economy_config_pda` как основной механизм синхронизации; +- full snapshot всех program accounts остаётся аварийным fallback, а не штатным путём. + +### Сценарий B: локальная БД уже есть + +Если БД не пуста: + +1. берётся последняя обработанная сигнатура; +2. через `getSignaturesForAddress(users_economy_config_pda)` вытягивается история после неё; +3. новые транзакции разбираются и применяются; +4. после этого модуль входит в `READY`. + +### Сценарий C: новых транзакций не было + +Если после последней сигнатуры новых транзакций нет: + +- это не ошибка; +- модуль всё равно обновляет `last_poll_at` и `last_successful_poll_at`; +- фиксирует, что актуализация успешно проверена; +- может переходить в `READY`. + +--- + +## Периодическая актуализация раз в 5 минут + +Каждые 5 минут модуль должен: + +1. вызвать `getSignaturesForAddress(users_economy_config_pda)`; +2. получить новые сигнатуры после последней сохранённой точки; +3. сохранить все найденные транзакции в журнал истории; +4. выделить релевантные транзакции `create/update user_pda`; +5. для релевантных транзакций извлечь адрес PDA и логин; +6. подтянуть актуальное состояние затронутых PDA; +7. обновить: + - `solana_user_pda_current` + - `user_access_servers_current` + - `solana_user_pda_history` + - `solana_sync_state` + +Если новых транзакций нет: + +- модуль ничего не меняет в зеркале PDA; +- но помечает, что polling выполнен успешно и состояние истории актуализировано. + +--- + +## Что делать с нерелевантными транзакциями + +История должна храниться полностью, включая нерелевантные транзакции. + +Примеры нерелевантных транзакций: + +- `update_users_economy_config` +- служебные транзакции, где `economy_config_pda` присутствовал, но пользовательский `user_pda` не менялся + +Такие транзакции: + +- сохраняются в `solana_sync_tx_history`; +- помечаются `is_relevant = 0`; +- не приводят к обновлению пользовательских PDA. + +Это важно для аудита и отладки. + +--- + +## Как распознавать тип транзакции + +Для каждой транзакции из истории нужно определить её тип. + +Минимальный набор типов: + +- `create_user_pda` +- `update_user_pda` +- `update_users_economy_config` +- `init_users_economy_config` +- `other` + +Также для каждой транзакции нужно определять: + +- `is_relevant = 1`, если транзакция создаёт или обновляет пользовательский `user_pda`; +- `is_relevant = 0`, если это транзакция истории, но она не меняет пользовательские PDA. + +Для релевантных транзакций нужно дополнительно извлекать: + +- `affected_pda_address` +- `affected_login` + +Если транзакция затрагивает несколько пользовательских PDA, архитектура должна не запрещать хранить несколько связей, но на первом этапе можно исходить из одной пользовательской записи на одну транзакцию, если это соответствует текущему контракту. + +--- + +## Локальные таблицы + +Модуль должен использовать три основные таблицы. + +### 1. `solana_sync_state` + +Одна строка состояния синхронизации. + +Назначение: + +- хранить текущий checkpoint истории; +- хранить время последней успешной актуализации; +- хранить технический статус синка. + +Пример полей: + +- `id INTEGER PRIMARY KEY CHECK (id = 1)` +- `status TEXT NOT NULL` +- `ready INTEGER NOT NULL DEFAULT 0` +- `last_poll_at_ms INTEGER` +- `last_successful_poll_at_ms INTEGER` +- `last_seen_signature TEXT` +- `last_seen_slot INTEGER` +- `last_relevant_signature TEXT` +- `last_relevant_slot INTEGER` +- `last_error TEXT` +- `updated_at_ms INTEGER NOT NULL` + +### 2. `solana_sync_tx_history` + +Append-only журнал всех просмотренных транзакций по `users_economy_config_pda`. + +Назначение: + +- хранить полную историю опроса; +- фиксировать, какие tx были релевантны; +- хранить связь tx -> пользовательский PDA / login; +- упрощать аудит и диагностику. + +Пример полей: + +- `signature TEXT PRIMARY KEY` +- `slot INTEGER NOT NULL` +- `block_time INTEGER` +- `tx_kind TEXT NOT NULL` +- `is_relevant INTEGER NOT NULL` +- `affected_pda_address TEXT` +- `affected_login TEXT` +- `processed_at_ms INTEGER NOT NULL` +- `raw_summary_json TEXT NOT NULL` + +Индексы: + +- по `slot` +- по `is_relevant` +- по `affected_login` +- по `affected_pda_address` + +### 3. `solana_user_pda_current` + +Текущее актуальное состояние каждого пользовательского PDA. + +Назначение: + +- быстрый lookup текущих данных пользователя; +- будущая интеграция с сервером; +- опорная таблица для поиска текущих ключей и полей PDA. + +Пример полей: + +- `pda_address TEXT PRIMARY KEY` +- `login TEXT NOT NULL` +- `record_number INTEGER NOT NULL` +- `slot INTEGER NOT NULL` +- `last_tx_signature TEXT NOT NULL` +- `blockchain_name TEXT NOT NULL` +- `blockchain_key TEXT NOT NULL` +- `client_key TEXT NOT NULL` +- `paid_limit_bytes INTEGER NOT NULL` +- `used_bytes INTEGER NOT NULL` +- `last_block_number INTEGER NOT NULL` +- `last_block_hash TEXT` +- `arweave_tx_id TEXT` +- `is_server INTEGER NOT NULL DEFAULT 0` +- `server_address TEXT` +- `sync_servers_json TEXT NOT NULL DEFAULT '[]'` +- `access_servers_json TEXT NOT NULL DEFAULT '[]'` +- `sessions_json TEXT NOT NULL DEFAULT '[]'` +- `trusted_count INTEGER NOT NULL DEFAULT 0` +- `created_at_ms INTEGER NOT NULL` +- `updated_at_ms INTEGER NOT NULL` +- `raw_data_base64 TEXT NOT NULL` +- `first_seen_at_ms INTEGER NOT NULL` +- `last_synced_at_ms INTEGER NOT NULL` + +Индексы: + +- уникальный индекс на `login` +- индекс на `slot` +- индекс на `last_tx_signature` + +### 4. `solana_user_pda_history` + +Append-only история всех версий пользовательских PDA. + +Назначение: + +- хранить все старые публичные ключи и прочие поля прошлых версий; +- позволять видеть, когда и какая версия записи была актуальна; +- позволять разбирать изменения пользователя во времени. + +Пример полей: + +- `id INTEGER PRIMARY KEY` +- `tx_signature TEXT NOT NULL` +- `slot INTEGER NOT NULL` +- `block_time INTEGER` +- `pda_address TEXT NOT NULL` +- `login TEXT NOT NULL` +- `record_number INTEGER NOT NULL` +- `blockchain_name TEXT NOT NULL` +- `blockchain_key TEXT NOT NULL` +- `client_key TEXT NOT NULL` +- `paid_limit_bytes INTEGER NOT NULL` +- `used_bytes INTEGER NOT NULL` +- `last_block_number INTEGER NOT NULL` +- `last_block_hash TEXT` +- `arweave_tx_id TEXT` +- `is_server INTEGER NOT NULL DEFAULT 0` +- `server_address TEXT` +- `sync_servers_json TEXT NOT NULL DEFAULT '[]'` +- `access_servers_json TEXT NOT NULL DEFAULT '[]'` +- `sessions_json TEXT NOT NULL DEFAULT '[]'` +- `trusted_count INTEGER NOT NULL DEFAULT 0` +- `created_at_ms INTEGER NOT NULL` +- `updated_at_ms INTEGER NOT NULL` +- `raw_data_base64 TEXT NOT NULL` +- `saved_at_ms INTEGER NOT NULL` + +Индексы: + +- уникальный индекс на `(pda_address, record_number)` +- индекс на `login` +- индекс на `slot` +- индекс на `tx_signature` + +--- + +## Зачем нужны и `current`, и `history` + +Нужны обе таблицы: + +- `solana_user_pda_current` хранит только последнюю актуальную версию и удобна для быстрых запросов; +- `solana_user_pda_history` хранит все версии и нужна для расследований и просмотра старых публичных ключей. + +При обработке новой релевантной транзакции: + +1. новая версия всегда добавляется в `solana_user_pda_history`; +2. актуальная строка в `solana_user_pda_current` вставляется или обновляется. + +--- + +## Что именно считается “историей пользователя” + +История нужна не для секретных паролей, а для публичных данных PDA. + +В частности, история должна позволять видеть старые значения: + +- `client_key` +- `blockchain_key` +- `sessions` +- `access_servers` +- `sync_servers` +- `server_address` +- других публичных полей PDA + +Секретные приватные ключи или настоящие пользовательские пароли этот модуль не хранит и хранить не должен. + +--- + +## Логирование + +Модуль должен использовать нормальный logger, а не `System.out.println`. + +Требования к логированию: + +- совместимость с будущей интеграцией в общие логи сервера; +- явные уровни `INFO`, `WARN`, `ERROR`, `DEBUG`; +- короткие, но диагностичные сообщения; +- каждый важный переход состояния должен логироваться. + +Что обязательно логировать: + +- старт процесса; +- чтение конфига; +- вычисление `users_economy_config_pda`; +- старт websocket-подписки; +- успешную подписку; +- начало initial sync; +- окончание initial sync; +- вход в `READY`; +- periodic poll; +- число найденных сигнатур; +- число релевантных tx; +- число обновлённых PDA; +- реконнекты websocket; +- ошибки RPC/WS; +- переход в `FAILED`, если он будет. + +--- + +## Конфигурация + +В конфиге должны остаться только обязательные для постоянной эксплуатации параметры: + +```text +SOLANA_RPC_URL= +SOLANA_WS_URL= +SOLANA_PROGRAM_ID=SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6 + +DATABASE_URL=jdbc:postgresql://127.0.0.1:5432/shine_server_db +PGUSER=shine_server +PGPASSWORD= +SYNC_POLL_INTERVAL_SECONDS=300 +``` + +Дополнительно: + +- `SOLANA_COMMITMENT` не нужен как параметр; +- commitment должен быть зафиксирован в коде как `confirmed`; +- `HELIUS_API_KEY` не нужен; +- `SOLANA_NETWORK` не нужен. + +--- + +## Поведение при ошибках + +### Ошибка periodic poll + +Если periodic poll не удался: + +- это логируется как `WARN` или `ERROR`; +- `solana_sync_state.last_error` обновляется; +- модуль не должен сразу завершаться, если reconnect/retry ещё возможны. + +### Ошибка initial sync + +Если initial sync не удался: + +- модуль не должен выставлять `READY`; +- отдельный процесс должен завершаться с ошибкой или оставаться в `FAILED`, в зависимости от выбранного runtime-режима; +- при встраивании в сервер основной startup должен считаться неуспешным. + +### Аварийный fallback + +Даже если основная логика опирается на историю по `users_economy_config_pda`, аварийный full snapshot всех `user_pda` можно оставить как последний защитный fallback на случай повреждённого или неполного RPC-ответа. + +Но это должен быть именно крайний защитный сценарий, а не штатный рабочий путь. + +--- + +## Почему хранить всю tx-историю полезно + +Полный журнал `solana_sync_tx_history` нужен не только для самого синка, но и для эксплуатации: + +- видно, что именно вернул RPC; +- видно, какие tx были признаны релевантными; +- видно, какие tx были проигнорированы и почему; +- можно поднимать старые кейсы без повторного запроса в Solana; +- упрощается аудит и отладка после инцидентов. + +--- + +## Что должно получиться в итоге + +В результате модуль должен работать так: + +1. стартует как отдельный Java-процесс; +2. подписывается на realtime-обновления `shine_users`; +3. делает начальную актуализацию через историю `users_economy_config_pda`; +4. входит в `READY`; +5. раз в 5 минут делает дополнительный audit истории; +6. хранит: + - состояние синка; + - полную историю просмотренных транзакций; + - актуальное зеркало всех пользовательских PDA; + - историю всех версий пользовательских PDA; +7. после будущего переноса в SHiNE-сервер может использоваться как блокирующий startup-модуль: + - сначала синхронизируется Solana; + - потом сервер продолжает запуск остальных подсистем. + +--- + +## Следующий шаг реализации + +После утверждения этого документа модуль нужно доработать в коде: + +1. упростить конфиг; +2. заменить текущее логирование на logger; +3. выделить lifecycle-сервис с `awaitReady()`; +4. реализовать вычисление `users_economy_config_pda`; +5. реализовать polling истории по сигнатурам; +6. добавить runtime-таблицы PostgreSQL; +7. добавить запись в `current` и `history`; +8. добавить periodic guard раз в 5 минут; +9. сохранить отдельный `main` для запуска как процесса. diff --git a/docs/libs/shine-server-bd/DOC.md b/docs/libs/shine-server-bd/DOC.md index aae2dfaa..ad2c2483 100644 --- a/docs/libs/shine-server-bd/DOC.md +++ b/docs/libs/shine-server-bd/DOC.md @@ -1,17 +1,12 @@ shine-server-bd — это библиотека реалезующая всю работу с БД: -хранит пользователей/сессии/параметры/кэш IP→гео и данные блокчейна (состояние + блоки), предоставляя единый SqliteDbController для соединений, набор DAO под каждую таблицу (Singleton, методы с Connection для транзакций и без Connection — сами открывают/закрывают), и простые entity-модели как контейнеры данных для маппинга ResultSet↔Java. +хранит пользователей/сессии/параметры/кэш IP→гео и данные блокчейна (состояние + блоки), предоставляя единый PostgreSQL runtime-контроллер соединений, набор DAO под каждую таблицу (Singleton, методы с Connection для транзакций и без Connection — сами открывают/закрывают), и простые entity-модели как контейнеры данных для маппинга ResultSet↔Java. Логика структуры классов (в двух словах): -shine.db.SqliteDbController — один вход в БД: читает db.path, при отсутствии файла создаёт БД, выдаёт новые Connection и настраивает PRAGMA. -shine.db.DatabaseInitializer — разовая сборка схемы (таблицы + индексы). +shine.db.DbController / shine.db.PostgresDbController — вход в runtime БД: читает `db.url/db.user/db.password`, подключается только к PostgreSQL и выдаёт новые `Connection`. +shine.db.DatabaseInitializer — проверяет наличие `db_schema_version` и при пустой БД автоматически накатывает `postgres/schema_v1.sql`. shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getClientKeyByte()). -shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, SolanaUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO; плюс “сервисные” DAO: - -UserCreateDAO — атомарная регистрация пользователя в транзакции (BEGIN IMMEDIATE + rollback/commit). -// Временное решение позволяющее регистрировать новых пользователей -// атомарно и добавляет запись и в solana_users и в BlockchainState - +shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, CurrentUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO, SignedMessagesDAO; плюс сервисные DAO под recovery/resync. diff --git a/docs/libs/shine-server-bd/POSTGRES_RUNTIME_SCHEMA_V1.md b/docs/libs/shine-server-bd/POSTGRES_RUNTIME_SCHEMA_V1.md new file mode 100644 index 00000000..1f7e787a --- /dev/null +++ b/docs/libs/shine-server-bd/POSTGRES_RUNTIME_SCHEMA_V1.md @@ -0,0 +1,91 @@ +# PostgreSQL runtime schema v1 + +Дата фиксации: `2026-07-24` + +## Назначение + +Это целевая серверная runtime-схема PostgreSQL для SHiNE без опоры на SQLite. + +Схема `v1` нужна как стартовая точка большого механического переноса DAO и runtime-запросов +с существующей SQLite-логики на PostgreSQL. + +## Ключевые решения + +- Источник истины по пользователям: `solana_user_pda_current`. +- Legacy-таблицы старого runtime для пользователей и личных сообщений в новой схеме не создаются. +- Основная таблица серверных личных сообщений: `signed_messages`. +- Таблица `blockchain_state` сохраняется как runtime-state таблица сервера: + она не является identity-слоем и не мигрируется как legacy SQLite data. +- Триггеры по `blocks` сохраняются и переписываются под PostgreSQL. + +## Таблицы sync-модуля Solana users + +- `solana_sync_state` +- `solana_sync_tx_history` +- `solana_user_pda_current` +- `solana_user_pda_history` + +## Таблицы server runtime + +- `db_schema_version` +- `active_sessions` +- `esp_pairing_settings` +- `esp_pairing_requests` +- `users_params` +- `ip_geo_cache` +- `test_free_avatar_uploads` +- `sync_servers` +- `blockchain_state` +- `blocks` +- `connections_state` +- `message_stats` +- `reactions_state` +- `channel_names_state` +- `chat200_state` +- `chat200_members_state` +- `user_push_tokens` +- `signed_direct_message_replay` +- `signed_direct_messages_history` +- `signed_messages` +- `signed_message_session_delivery` + +## Триггеры + +Схема `v1` уже включает PostgreSQL-версии триггеров: + +- `trg_blocks_line_integrity_bi` +- `trg_blocks_connection_state_ai` +- `trg_blocks_message_stats_like_ai` +- `trg_blocks_message_stats_reply_ai` +- `trg_blocks_edit_apply_ai` + +## Что не входит в v1 + +- полная зачистка legacy-документации, старых названий и TODO-хвостов; +- переименование Java DAO/классов `*V2` в runtime-коде; +- перенос прямых SQL-запросов из хэндлеров в DAO/service; +- переключение всего runtime-кода на новый `DbProvider`. + +Это отдельные механические шаги поверх уже утверждённой схемы. + +## Совместимость со старыми блоками каналов + +В runtime-сервере сознательно нет жёсткой серверной проверки +`channelName must not contain only digits`. + +Причина: в уже существующей истории блокчейна есть каналы с числовыми именами, +и при холодном восстановлении сервера с пустой БД и без `.bch` такие блоки должны +успешно переигрываться от других sync-серверов. + +Сейчас правило "новый публичный канал не должен состоять только из цифр" остаётся +на уровне UI/продуктовых требований и должно быть позже возвращено на сервере +отдельным совместимым способом, который не ломает replay исторических блоков. + +## Инициализация пустой БД + +Если сервер подключается к PostgreSQL через `db.url=jdbc:postgresql:...` и в выбранной БД ещё нет таблицы `db_schema_version`, +он сам автоматически накатывает `schema_v1.sql` из classpath-ресурса: + +- ресурс: `shine-server-db/src/main/resources/postgres/schema_v1.sql` +- признак пустой схемы: отсутствует `db_schema_version` +- стартовая версия схемы: `1` diff --git a/docs/libs/shine-server-config/doc.md b/docs/libs/shine-server-config/doc.md index e2f49003..929add26 100644 --- a/docs/libs/shine-server-config/doc.md +++ b/docs/libs/shine-server-config/doc.md @@ -4,4 +4,6 @@ shine-server-config Настройки: server.port=7070 — порт запуска сервера -db.path=data/shine.sqlite — путь к SQLite базе данных \ No newline at end of file +db.url=jdbc:postgresql://127.0.0.1:5432/shine_server_db — JDBC URL PostgreSQL +db.user=shine_server — пользователь PostgreSQL +db.password=... — пароль пользователя PostgreSQL diff --git a/docs/libs/shine-server-protocol/doc.md b/docs/libs/shine-server-protocol/doc.md index 9156ecbb..3bb3562d 100644 --- a/docs/libs/shine-server-protocol/doc.md +++ b/docs/libs/shine-server-protocol/doc.md @@ -42,11 +42,11 @@ auth/ — авторизация и сессии blockchain/ — AddBlock -tempToTest/ — AddUser (временный, потом уйдёт в блокчейн-логику) +tempToTest/ — временные dev/test операции (`GetUser`, `SearchUsers`) ConnectionContext Состояние одного WebSocket-подключения (login, session, authStatus). ActiveConnectionsRegistry Глобальный реестр активных авторизованных соединений -(нужно для закрытия других сессий). \ No newline at end of file +(нужно для закрытия других сессий). diff --git a/settings.gradle b/settings.gradle index 32854cbf..008cebfb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,6 +8,7 @@ include 'shine-server-blockchain' include 'shine-server-db' include 'shine-server-net-protocol' include 'shine-server-net-server' +include 'shine-server-solana-users-sync' project(':shine-server-log').projectDir = file('SHiNE-server/shine-server-log') project(':shine-server-config').projectDir = file('SHiNE-server/shine-server-config') @@ -17,3 +18,4 @@ project(':shine-server-blockchain').projectDir = file('SHiNE-server/shine-server project(':shine-server-db').projectDir = file('SHiNE-server/shine-server-db') project(':shine-server-net-protocol').projectDir = file('SHiNE-server/shine-server-net-protocol') project(':shine-server-net-server').projectDir = file('SHiNE-server/shine-server-net-server') +project(':shine-server-solana-users-sync').projectDir = file('SHiNE-server/shine-server-solana-users-sync') diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 173978d9..403b7b2c 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -56,6 +56,7 @@ import * as profileView from './pages/profile-view.js?v=202607150910'; import * as profileEditView from './pages/profile-edit-view.js'; import * as walletView from './pages/wallet-view.js?v=202606281930'; import * as settingsView from './pages/settings-view.js'; +import * as accessServersView from './pages/access-servers-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js'; import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240'; import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300'; @@ -116,6 +117,7 @@ const routes = { 'profile-edit-view': profileEditView, 'wallet-view': walletView, 'settings-view': settingsView, + 'access-servers-view': accessServersView, 'developer-settings-view': developerSettingsView, 'server-settings-view': serverSettingsView, 'remote-addblock-session-view': remoteAddBlockSessionView, diff --git a/shine-UI/js/pages/access-servers-view.js b/shine-UI/js/pages/access-servers-view.js new file mode 100644 index 00000000..ce7d7277 --- /dev/null +++ b/shine-UI/js/pages/access-servers-view.js @@ -0,0 +1,703 @@ +import { renderHeader } from '../components/header.js'; +import { authService, state } from '../state.js'; +import { base64ToBytes, bytesToBase58, publicKeyB64FromPkcs8Ed25519 } from '../services/crypto-utils.js'; +import { loadEncryptedUserSecrets } from '../services/key-vault.js'; +import { resolveShineServerByServerLogin } from '../services/shine-server-resolver.js'; +import { readShineUserPda, updateShineUserPdaOnSolana } from '../services/shine-user-pda-service.js'; +import { getTopupSiteUrl } from '../services/solana-wallet-service.js'; + +export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' }; + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function normalizeLogin(value) { + return String(value || '').trim().toLowerCase(); +} + +function equalBytes(a, b) { + const left = a instanceof Uint8Array ? a : new Uint8Array(a || []); + const right = b instanceof Uint8Array ? b : new Uint8Array(b || []); + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i += 1) { + if (left[i] !== right[i]) return false; + } + return true; +} + +function uniqueLogins(values) { + const seen = new Set(); + const result = []; + (Array.isArray(values) ? values : []).forEach((value) => { + const login = normalizeLogin(value); + if (!login || seen.has(login)) return; + seen.add(login); + result.push(login); + }); + return result; +} + +function shortenSignature(value) { + const text = String(value || '').trim(); + if (text.length <= 16) return text; + return `${text.slice(0, 8)}...${text.slice(-8)}`; +} + +function isInsufficientFundsForRentError(error) { + const text = [ + error?.message, + error?.transactionMessage, + Array.isArray(error?.logs) ? error.logs.join('\n') : '', + Array.isArray(error?.transactionLogs) ? error.transactionLogs.join('\n') : '', + Array.isArray(error?.simulationLogs) ? error.simulationLogs.join('\n') : '', + ].filter(Boolean).join('\n').toLowerCase(); + return text.includes('insufficient funds') && text.includes('rent'); +} + +function clientAddressFromPublicB64(publicKeyB64) { + const bytes = base64ToBytes(publicKeyB64); + if (bytes.length !== 32) throw new Error('client public key должен быть 32 байта'); + return bytesToBase58(bytes); +} + +async function clientAddressFromPrivatePkcs8(privatePkcs8B64) { + return clientAddressFromPublicB64(await publicKeyB64FromPkcs8Ed25519(privatePkcs8B64)); +} + +function createConfirmModal() { + const root = document.getElementById('modal-root'); + if (!(root instanceof HTMLElement)) return null; + + let onConfirm = null; + let onCancel = null; + + const close = () => { + root.innerHTML = ''; + onConfirm = null; + onCancel = null; + }; + + return { + open({ + title, + text, + note = '', + confirmLabel = 'Да', + cancelLabel = 'Нет', + onConfirm: confirmHandler, + onCancel: cancelHandler, + }) { + root.innerHTML = ` + + `; + const modal = root.querySelector('#access-servers-confirm-modal'); + const titleEl = root.querySelector('#access-servers-dialog-title'); + const textEl = root.querySelector('#access-servers-dialog-text'); + const noteEl = root.querySelector('#access-servers-dialog-note'); + const cancelBtn = root.querySelector('#access-servers-dialog-cancel'); + const confirmBtn = root.querySelector('#access-servers-dialog-confirm'); + if (!(modal instanceof HTMLElement) + || !(titleEl instanceof HTMLElement) + || !(textEl instanceof HTMLElement) + || !(noteEl instanceof HTMLElement) + || !(cancelBtn instanceof HTMLButtonElement) + || !(confirmBtn instanceof HTMLButtonElement)) { + close(); + return; + } + + titleEl.textContent = String(title || 'Подтверждение'); + textEl.textContent = String(text || ''); + if (note) { + noteEl.hidden = false; + noteEl.textContent = String(note); + } else { + noteEl.hidden = true; + noteEl.textContent = ''; + } + cancelBtn.textContent = String(cancelLabel || 'Нет'); + confirmBtn.textContent = String(confirmLabel || 'Да'); + onConfirm = confirmHandler || null; + onCancel = cancelHandler || null; + + modal.addEventListener('click', (event) => { + if (event.target === modal) close(); + }); + cancelBtn.addEventListener('click', async () => { + const handler = onCancel; + close(); + if (typeof handler === 'function') await handler(); + }); + confirmBtn.addEventListener('click', async () => { + const handler = onConfirm; + close(); + if (typeof handler === 'function') await handler(); + }); + }, + destroy() { + close(); + }, + }; +} + +function createPasswordModal() { + const root = document.getElementById('modal-root'); + if (!(root instanceof HTMLElement)) return null; + + return { + open({ title, text, note = '' }) { + root.innerHTML = ` + + `; + + return new Promise((resolve) => { + const modal = root.querySelector('#access-servers-password-modal'); + const titleEl = root.querySelector('#access-servers-password-title'); + const textEl = root.querySelector('#access-servers-password-text'); + const noteEl = root.querySelector('#access-servers-password-note'); + const inputEl = root.querySelector('#access-servers-password-input'); + const cancelBtn = root.querySelector('#access-servers-password-cancel'); + const confirmBtn = root.querySelector('#access-servers-password-confirm'); + if (!(modal instanceof HTMLElement) + || !(titleEl instanceof HTMLElement) + || !(textEl instanceof HTMLElement) + || !(noteEl instanceof HTMLElement) + || !(inputEl instanceof HTMLInputElement) + || !(cancelBtn instanceof HTMLButtonElement) + || !(confirmBtn instanceof HTMLButtonElement)) { + root.innerHTML = ''; + resolve(null); + return; + } + + titleEl.textContent = String(title || 'Введите пароль'); + textEl.textContent = String(text || ''); + if (note) { + noteEl.hidden = false; + noteEl.textContent = String(note); + } else { + noteEl.hidden = true; + noteEl.textContent = ''; + } + + const close = (result = null) => { + root.innerHTML = ''; + resolve(result); + }; + + modal.addEventListener('click', (event) => { + if (event.target === modal) close(null); + }); + cancelBtn.addEventListener('click', () => close(null)); + inputEl.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + confirmBtn.click(); + } + }); + confirmBtn.addEventListener('click', () => { + const password = String(inputEl.value || ''); + if (!password.trim()) { + inputEl.focus(); + return; + } + const mode = root.querySelector('input[name="access-servers-key-mode"]:checked'); + close({ + password, + saveRoot: mode instanceof HTMLInputElement && mode.value === 'save', + }); + }); + window.setTimeout(() => inputEl.focus(), 0); + }); + }, + destroy() { + root.innerHTML = ''; + }, + }; +} + +export function render({ navigate }) { + const screen = document.createElement('section'); + screen.className = 'stack'; + + const sessionLogin = normalizeLogin(state.session.login); + const solanaEndpoint = String(state.entrySettings.solanaServer || '').trim(); + const storagePwd = String(state.session.storagePwdInMemory || '').trim(); + + let disposed = false; + let operationBusy = false; + let currentAccessServers = []; + let selectedCandidate = null; + let suggestionsLoading = false; + + const confirmModal = createConfirmModal(); + const passwordModal = createPasswordModal(); + + const introCard = document.createElement('div'); + introCard.className = 'card stack'; + introCard.innerHTML = ` +

Где хранятся личные данные

+

+ Серверы доступа хранят зашифрованную личную переписку пользователя и участвуют в звонках. + Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния, + а доступ пользователя и приватная переписка хранятся только на этих серверах. +

+ `; + + const listCard = document.createElement('div'); + listCard.className = 'card stack'; + const listTitle = document.createElement('p'); + listTitle.className = 'field-label'; + listTitle.textContent = 'Текущий список серверов доступа'; + const listHint = document.createElement('p'); + listHint.className = 'meta-muted'; + listHint.textContent = sessionLogin + ? `Пользователь: @${sessionLogin}` + : 'В текущей сессии не найден логин пользователя.'; + const listBody = document.createElement('div'); + listBody.className = 'stack'; + const listStatus = document.createElement('p'); + listStatus.className = 'meta-muted'; + listStatus.textContent = 'Загрузка данных из PDA...'; + listCard.append(listTitle, listHint, listBody, listStatus); + + const addCard = document.createElement('div'); + addCard.className = 'card stack'; + const addTitle = document.createElement('p'); + addTitle.className = 'field-label'; + addTitle.textContent = 'Добавить сервер доступа'; + const addHint = document.createElement('p'); + addHint.className = 'meta-muted'; + addHint.textContent = 'Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.'; + const addInput = document.createElement('input'); + addInput.className = 'input'; + addInput.type = 'text'; + addInput.autocomplete = 'off'; + addInput.placeholder = 'Например: shineup'; + const suggestEl = document.createElement('div'); + suggestEl.className = 'profile-relative-search-suggest'; + suggestEl.hidden = true; + const addStatus = document.createElement('p'); + addStatus.className = 'meta-muted'; + addStatus.textContent = 'Для изменения списка понадобится подпись root key.'; + const addButton = document.createElement('button'); + addButton.className = 'primary-btn'; + addButton.type = 'button'; + addButton.textContent = 'Добавить сервер'; + addButton.disabled = true; + addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton); + + const refreshAddButton = () => { + addButton.disabled = operationBusy || (!selectedCandidate && !normalizeLogin(addInput.value)); + }; + + const setOperationBusy = (busy) => { + operationBusy = busy; + addInput.disabled = busy; + refreshAddButton(); + listBody.querySelectorAll('button').forEach((button) => { + button.disabled = busy; + }); + }; + + const setSelectedCandidate = (candidate) => { + selectedCandidate = candidate; + refreshAddButton(); + if (candidate) { + addInput.value = candidate.login; + addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`; + } else { + addStatus.textContent = 'Для изменения списка понадобится подпись root key.'; + } + }; + + const renderServerList = () => { + listBody.innerHTML = ''; + if (!currentAccessServers.length) { + const empty = document.createElement('p'); + empty.className = 'meta-muted'; + empty.textContent = 'Список серверов доступа пока пуст.'; + listBody.append(empty); + return; + } + + currentAccessServers.forEach((server, index) => { + const button = document.createElement('button'); + button.className = 'text-btn'; + button.type = 'button'; + button.disabled = operationBusy; + button.innerHTML = ` + + @${escapeHtml(server.login)} + ${escapeHtml(server.url || 'URL не указан')} + + `; + button.addEventListener('click', () => { + if (operationBusy) return; + const isLast = currentAccessServers.length <= 1; + const performDisable = async () => { + const nextList = currentAccessServers + .map((item) => item.login) + .filter((login) => login !== server.login); + try { + await updateAccessServers(nextList, { + statusTarget: listStatus, + successText: `Сервер доступа @${server.login} отключён.`, + inFlightText: `Обновляем PDA и отключаем сервер @${server.login}...`, + }); + } catch { + // Сообщение уже показано в статусе. + } + }; + + const askFinal = (noteText = '') => { + confirmModal?.open({ + title: isLast ? 'Последний сервер доступа' : 'Отключить сервер доступа?', + text: isLast + ? 'Это последний сервер доступа пользователя. Если его отключить, личная переписка пользователя на серверах доступа будет удалена.' + : `Хотите изменить запись в блокчейне Solana и отключить сервер доступа @${server.login}?`, + note: noteText, + confirmLabel: isLast ? 'Понимаю' : 'Да', + cancelLabel: isLast ? 'Отмена' : 'Нет', + onConfirm: performDisable, + }); + }; + + if (isLast) { + confirmModal?.open({ + title: 'Отключить последний сервер?', + text: `У пользователя остался только один сервер доступа: @${server.login}.`, + note: 'После отключения последнего сервера доступа личная переписка пользователя на серверах доступа будет удалена.', + confirmLabel: 'Продолжить', + cancelLabel: 'Отмена', + onConfirm: () => askFinal('Это повторное предупреждение перед записью нового списка серверов в Solana PDA.'), + }); + return; + } + + askFinal(); + }); + listBody.append(button); + if (index < currentAccessServers.length - 1) { + const divider = document.createElement('div'); + divider.style.height = '1px'; + divider.style.background = 'rgba(255,255,255,0.08)'; + listBody.append(divider); + } + }); + }; + + const loadCurrentServers = async () => { + if (!sessionLogin) { + currentAccessServers = []; + renderServerList(); + listStatus.textContent = 'Нет активной пользовательской сессии.'; + return; + } + if (!solanaEndpoint) { + currentAccessServers = []; + renderServerList(); + listStatus.textContent = 'Не задан Solana RPC endpoint.'; + return; + } + + listStatus.textContent = 'Читаем серверы доступа из Solana PDA...'; + try { + const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint }); + const logins = uniqueLogins(parsed?.accessServers); + const rows = []; + for (const login of logins) { + try { + const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint }); + rows.push({ login: resolved.serverLogin, url: resolved.httpBase }); + } catch { + rows.push({ login, url: '' }); + } + } + currentAccessServers = rows; + renderServerList(); + listStatus.textContent = rows.length + ? `Найдено серверов доступа: ${rows.length}` + : 'В PDA пользователя пока нет серверов доступа.'; + } catch (error) { + currentAccessServers = []; + renderServerList(); + listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.'; + } + }; + + const renderSuggestions = (items) => { + suggestEl.innerHTML = ''; + if (!items.length) { + suggestEl.hidden = true; + return; + } + suggestEl.hidden = false; + suggestEl.innerHTML = items.map((item) => ( + `` + )).join(''); + }; + + const showTopupRequiredStatus = (target, clientAddress) => { + const address = String(clientAddress || '').trim(); + const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup'; + target.innerHTML = ` + + Не хватает SOL на client key для оплаты Solana rent/fee при обновлении user PDA. + + ${address ? `Кошелёк: ${escapeHtml(address)}` : ''} + Пополнить DEVNET кошелёк + `; + }; + + const loadSuggestions = async () => { + const prefix = normalizeLogin(addInput.value); + if (suggestionsLoading || prefix.length < 2 || operationBusy) { + suggestEl.hidden = true; + suggestEl.innerHTML = ''; + return; + } + suggestionsLoading = true; + try { + const logins = await authService.searchUsers(prefix, { isServer: true }); + const items = []; + for (const login of logins.slice(0, 6)) { + try { + const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint }); + items.push({ login: resolved.serverLogin, url: resolved.httpBase }); + } catch { + // Если server PDA повреждена, не показываем подсказку. + } + } + if (!disposed) renderSuggestions(items); + } catch (error) { + if (!disposed) { + suggestEl.hidden = true; + suggestEl.innerHTML = ''; + addStatus.textContent = error?.message || 'Не удалось получить список серверов.'; + } + } finally { + suggestionsLoading = false; + } + }; + + const resolveAccessServerSigningMaterial = async (currentPda) => { + if (!sessionLogin) throw new Error('Нет активной пользовательской сессии.'); + if (!storagePwd) throw new Error('В памяти сессии нет storagePwd. Выполните вход заново.'); + + let saved = null; + try { + saved = await loadEncryptedUserSecrets(sessionLogin, storagePwd); + } catch { + saved = null; + } + + const savedRoot = String(saved?.rootKey || '').trim(); + const savedClient = String(saved?.clientKey || '').trim(); + if (savedRoot && savedClient) { + return { + rootPrivatePkcs8B64: savedRoot, + clientPrivatePkcs8B64: savedClient, + clientAddress: await clientAddressFromPrivatePkcs8(savedClient), + }; + } + + const passwordResult = await passwordModal?.open({ + title: 'Нужен пароль для обновления серверов доступа', + text: 'Чтобы изменить список серверов доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.', + note: savedClient + ? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.' + : 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.', + }); + if (!passwordResult) { + throw new Error('Операция отменена пользователем.'); + } + + const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password); + const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64); + const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64); + if (!equalBytes(derivedRootPublic, currentPda.rootKey)) { + throw new Error('Пароль не подходит: root key не совпал с user PDA.'); + } + if (!equalBytes(derivedClientPublic, currentPda.clientKey)) { + throw new Error('Пароль не подходит: client key не совпал с user PDA.'); + } + + if (passwordResult.saveRoot) { + await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, { + saveRoot: true, + saveBlockchain: false, + }); + } + + return { + rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64, + clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64, + clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64), + }; + }; + + const updateAccessServers = async (nextLogins, { + statusTarget, + successText, + inFlightText, + } = {}) => { + const target = statusTarget instanceof HTMLElement ? statusTarget : listStatus; + if (!sessionLogin) throw new Error('Нет активной пользовательской сессии.'); + if (!solanaEndpoint) throw new Error('Не задан Solana RPC endpoint.'); + if (operationBusy) return; + + const normalizedList = uniqueLogins(nextLogins); + setOperationBusy(true); + target.textContent = String(inFlightText || 'Обновляем список серверов доступа...'); + let signingMaterial = null; + try { + const currentPda = await readShineUserPda({ login: sessionLogin, solanaEndpoint }); + signingMaterial = await resolveAccessServerSigningMaterial(currentPda); + const tx = await updateShineUserPdaOnSolana({ + login: sessionLogin, + solanaEndpoint, + rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64, + clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64, + accessServers: normalizedList, + }); + await loadCurrentServers(); + target.textContent = `${String(successText || 'Список серверов доступа обновлён.')} Tx: ${shortenSignature(tx?.signature)}`; + setSelectedCandidate(null); + suggestEl.hidden = true; + suggestEl.innerHTML = ''; + refreshAddButton(); + } catch (error) { + if (isInsufficientFundsForRentError(error)) { + showTopupRequiredStatus(target, signingMaterial?.clientAddress); + } else { + target.textContent = error?.message || 'Не удалось обновить список серверов доступа.'; + } + throw error; + } finally { + setOperationBusy(false); + } + }; + + addInput.addEventListener('input', () => { + setSelectedCandidate(null); + refreshAddButton(); + void loadSuggestions(); + }); + addInput.addEventListener('focus', () => { + void loadSuggestions(); + }); + suggestEl.addEventListener('click', (event) => { + const target = event.target instanceof HTMLElement ? event.target.closest('[data-login]') : null; + if (!(target instanceof HTMLElement)) return; + setSelectedCandidate({ + login: String(target.dataset.login || ''), + url: String(target.dataset.url || ''), + }); + suggestEl.hidden = true; + suggestEl.innerHTML = ''; + }); + + addButton.addEventListener('click', async () => { + if (operationBusy) return; + const login = normalizeLogin(selectedCandidate?.login || addInput.value); + if (!login) { + setSelectedCandidate(null); + addStatus.textContent = 'Сначала укажите логин сервера доступа.'; + return; + } + if (currentAccessServers.some((item) => item.login === login)) { + addStatus.textContent = `Сервер @${login} уже есть в списке.`; + return; + } + + try { + const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint }); + confirmModal?.open({ + title: 'Добавить сервер доступа?', + text: `Вы хотите добавить сервер доступа @${resolved.serverLogin}?`, + note: resolved.httpBase + ? `Адрес сервера: ${resolved.httpBase}\nИзменение будет записано в Solana user PDA.` + : 'Изменение будет записано в Solana user PDA.', + onConfirm: async () => { + const nextList = uniqueLogins([...currentAccessServers.map((item) => item.login), resolved.serverLogin]); + try { + await updateAccessServers(nextList, { + statusTarget: addStatus, + successText: `Сервер доступа @${resolved.serverLogin} добавлен.`, + inFlightText: `Обновляем PDA и добавляем сервер @${resolved.serverLogin}...`, + }); + } catch { + // Сообщение уже показано в статусе. + } + }, + }); + } catch (error) { + addStatus.textContent = error?.message || 'Не удалось проверить выбранный сервер.'; + } + }); + + screen.append( + renderHeader({ + title: 'Серверы доступа', + leftAction: { label: '←', onClick: () => navigate('settings-view') }, + }), + introCard, + listCard, + addCard, + ); + + void loadCurrentServers(); + + screen.cleanup = () => { + disposed = true; + confirmModal?.destroy(); + passwordModal?.destroy(); + }; + + return screen; +} diff --git a/shine-UI/js/pages/add-channel-view.js b/shine-UI/js/pages/add-channel-view.js index d4b3c70b..a073aa3c 100644 --- a/shine-UI/js/pages/add-channel-view.js +++ b/shine-UI/js/pages/add-channel-view.js @@ -45,12 +45,13 @@ export function render({ navigate }) { form.className = 'card stack'; form.innerHTML = ` Создание канала -

Можно использовать только латиницу, цифры, _ и -.

-

Длина названия: от 1 до 32 символов.

+

Разрешены только латинские буквы, цифры, _ и -.

+

Длина названия: от 3 до 32 символов. Название не должно состоять только из цифр.

+

Можно использовать большие и маленькие буквы, но уникальность проверяется без учёта регистра.

Тип канала фиксирован: публичный (1).
- +
diff --git a/shine-UI/js/pages/server-settings-view.js b/shine-UI/js/pages/server-settings-view.js index 13c17c11..e0cbb70b 100644 --- a/shine-UI/js/pages/server-settings-view.js +++ b/shine-UI/js/pages/server-settings-view.js @@ -2,7 +2,7 @@ import { renderHeader } from '../components/header.js'; import { saveEntrySettings, state } from '../state.js'; import { checkServerAvailabilityByKey, resolveAndCheckShineServerLogin } from '../services/server-health-service.js'; -export const pageMeta = { id: 'server-settings-view', title: 'Настройки серверов' }; +export const pageMeta = { id: 'server-settings-view', title: 'Серверы блокчейнов' }; const SERVER_FIELDS = [ { key: 'solanaServer', label: 'Адрес Solana сервера' }, @@ -26,6 +26,16 @@ export function render({ navigate }) { const timers = new Map(); + const introCard = document.createElement('div'); + introCard.className = 'card stack'; + introCard.innerHTML = ` +

Серверы блокчейнов и публичных данных

+

+ Здесь настраиваются Solana, SHiNE и Arweave для чтения публичных данных и доступа к блокчейну. + Эти настройки не меняют список личных серверов доступа пользователя. +

+ `; + const body = document.createElement('div'); body.className = 'card stack'; @@ -168,7 +178,7 @@ export function render({ navigate }) { await saveEntrySettings(draft); navigate('settings-view'); } catch (error) { - window.alert(error?.message || 'Не удалось сохранить настройки серверов.'); + window.alert(error?.message || 'Не удалось сохранить настройки серверов блокчейнов.'); } }); @@ -186,9 +196,10 @@ export function render({ navigate }) { screen.append( renderHeader({ - title: 'Настройки серверов', + title: 'Серверы блокчейнов', leftAction: { label: '←', onClick: () => navigate('settings-view') }, }), + introCard, body, actions, help, diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index a66c16ec..815688e2 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -42,14 +42,26 @@ export function render({ navigate }) { card.innerHTML = ` - + + `; card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view')); card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view')); - card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view')); + card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view')); + card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view')); card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view')); const signOutBtn = card.querySelector('#settings-signout'); diff --git a/shine-UI/js/pages/wallet-view.js b/shine-UI/js/pages/wallet-view.js index 2462493e..cb5813d6 100644 --- a/shine-UI/js/pages/wallet-view.js +++ b/shine-UI/js/pages/wallet-view.js @@ -45,6 +45,16 @@ function lamportsToSolText(lamportsBigInt) { return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 9 }); } +function formatLimitTopupError(error) { + const message = String(error?.message || '').trim(); + const logs = Array.isArray(error?.logs) ? error.logs.join(' | ') : ''; + const haystack = `${message} ${logs}`.toLowerCase(); + if (haystack.includes('insufficient funds for rent')) { + return 'Недостаточно средств на Solana-кошельке для пополнения лимита.'; + } + return `Не удалось увеличить лимит: ${message || 'unknown'}`; +} + function createModeBackButton(renderWalletChoice) { const backBtn = document.createElement('button'); backBtn.className = 'text-btn'; @@ -1492,7 +1502,7 @@ export function render({ navigate }) { await refreshUsage(); } catch (error) { if (modeToken !== activeModeToken) return; - setStatus(`Не удалось увеличить лимит: ${error?.message || 'unknown'}`); + setStatus(formatLimitTopupError(error)); } finally { setButtonsDisabled(false); } diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index fa65c242..46466cd2 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -29,6 +29,7 @@ const PRETTY_PATHS = new Map([ ['network-view', 'network'], ['notifications-view', 'notifications'], ['settings-view', 'settings'], + ['access-servers-view', 'settings/access-servers'], ['server-settings-view', 'settings/servers'], ['developer-settings-view', 'settings/developer'], ['trusted-device-login-settings-view', 'settings/device-login'], @@ -258,6 +259,7 @@ export function getRoute() { if (pageId === 'settings') { const sub = decodePart(segments[1] || '').toLowerCase(); + if (sub === 'access-servers') return { pageId: 'access-servers-view', params: {} }; if (sub === 'servers') return { pageId: 'server-settings-view', params: {} }; if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} }; if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} }; @@ -368,6 +370,7 @@ export function resolveToolbarActive(pageId) { pageId === 'profile-edit-view' || pageId === 'wallet-view' || pageId === 'settings-view' || + pageId === 'access-servers-view' || pageId === 'developer-settings-view' || pageId === 'server-settings-view' || pageId === 'remote-addblock-session-view' || diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 6bc03eeb..4a19d578 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1036,49 +1036,6 @@ export class AuthService { }; } - async registerUser(login, password) { - const cleanLogin = (login || '').trim(); - if (!cleanLogin) throw new Error('Введите логин'); - - const isFree = await this.ensureLoginFree(cleanLogin); - if (!isFree) throw new Error('Этот логин уже занят'); - - const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password); - const clientPair = keyBundle?.clientPair; - - const addResp = await this.ws.request('AddUser', { - login: cleanLogin, - blockchainName: `${cleanLogin}-${BCH_SUFFIX}`, - solanaKey: clientPair.publicKeyB64, - blockchainKey: keyBundle.blockchainPair.publicKeyB64, - clientKey: clientPair.publicKeyB64, - bchLimit: 1000000, - }); - if (addResp.status !== 200) throw opError('AddUser', addResp); - - const session = await this.createAuthSession(cleanLogin, keyBundle); - return { ...session, keyBundle }; - } - - async registerUserWithKeyBundle(login, keyBundle) { - const cleanLogin = (login || '').trim(); - if (!cleanLogin) throw new Error('Введите логин'); - const clientPair = keyBundle?.clientPair; - - const addResp = await this.ws.request('AddUser', { - login: cleanLogin, - blockchainName: `${cleanLogin}-${BCH_SUFFIX}`, - solanaKey: clientPair.publicKeyB64, - blockchainKey: keyBundle.blockchainPair.publicKeyB64, - clientKey: clientPair.publicKeyB64, - bchLimit: 1000000, - }); - if (addResp.status !== 200) throw opError('AddUser', addResp); - - const session = await this.createAuthSession(cleanLogin, keyBundle); - return { ...session, keyBundle }; - } - async createSessionForExistingUser(login, password) { const cleanLogin = (login || '').trim(); if (!cleanLogin) throw new Error('Введите логин'); @@ -2646,8 +2603,12 @@ export class AuthService { return response.payload || {}; } - async searchUsers(prefix) { - const response = await this.ws.request('SearchUsers', { prefix }); + async searchUsers(prefix, options = {}) { + const payload = { prefix }; + if (typeof options?.isServer === 'boolean') { + payload.isServer = options.isServer; + } + const response = await this.ws.request('SearchUsers', payload); if (response.status !== 200) throw opError('SearchUsers', response); return response.payload?.logins || []; } diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index 2bd8e718..c1865340 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -37,6 +37,25 @@ function nowMs() { return Date.now(); } +function isMobileBrowser() { + const uaDataMobile = navigator?.userAgentData?.mobile; + if (typeof uaDataMobile === 'boolean') return uaDataMobile; + const ua = String(navigator?.userAgent || ''); + return /Android|iPhone|iPad|iPod|Mobile/i.test(ua); +} + +function canRouteAudioOutputInBrowser() { + const mediaProto = globalThis?.HTMLMediaElement?.prototype; + return Boolean( + window.isSecureContext + && isMobileBrowser() + && mediaProto + && typeof mediaProto.setSinkId === 'function' + && navigator?.mediaDevices + && typeof navigator.mediaDevices.selectAudioOutput === 'function' + ); +} + function sanitizeTimelineText(value) { return String(value || '') .replace(/\s+/g, ' ') @@ -548,13 +567,23 @@ function getCallStateSnapshot() { if (!call) return null; if (call.localUiDismissed) return null; const callPhase = String(call.phase || '').trim(); + const canToggleSpeaker = Boolean( + callPhase !== 'incoming' + && callPhase !== 'ended' + && canRouteAudioOutputInBrowser() + && call.remoteAudio + ); return { callId: call.callId, peerLogin: call.peerLogin || '', direction: call.direction || 'out', phase: callPhase, statusText: call.statusText || '', + startedAtMs: Number(call.startedAtMs || 0), + connectedAtMs: Number(call.connectedAtMs || 0), muted: Boolean(call.muted), + speakerEnabled: Boolean(call.speakerEnabled), + canToggleSpeaker, canAnswer: callPhase === 'incoming', canDecline: callPhase === 'incoming', canHangup: callPhase !== 'ended' && callPhase !== 'incoming', @@ -658,6 +687,7 @@ function buildCallFactsLine(call, extra = {}) { pcSignalingState: pc?.signalingState || '', hasLocalStream: Boolean(call?.localStream), localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0, + localTrackStates: summarizeMediaSnapshot(buildMediaSnapshot(call)), timeline: buildTimelineSummary(call), ...extra, }; @@ -666,6 +696,60 @@ function buildCallFactsLine(call, extra = {}) { .join(', '); } +function buildMediaSnapshot(call) { + const localStream = call?.localStream || null; + const localTracks = localStream?.getTracks?.() || []; + const remoteAudio = call?.remoteAudio || null; + const remoteStream = remoteAudio?.srcObject || null; + const remoteTracks = remoteStream?.getTracks?.() || []; + return { + hasLocalStream: Boolean(localStream), + localTrackCount: localTracks.length, + localAudioTracksCount: localStream?.getAudioTracks?.()?.length || 0, + localTrackStates: localTracks.map((track) => ({ + kind: String(track?.kind || ''), + enabled: Boolean(track?.enabled), + muted: Boolean(track?.muted), + readyState: String(track?.readyState || ''), + label: String(track?.label || ''), + })), + hasRemoteAudio: Boolean(remoteAudio), + hasRemoteAudioSrcObject: Boolean(remoteStream), + remoteTrackCount: remoteTracks.length, + remoteTrackStates: remoteTracks.map((track) => ({ + kind: String(track?.kind || ''), + enabled: Boolean(track?.enabled), + muted: Boolean(track?.muted), + readyState: String(track?.readyState || ''), + label: String(track?.label || ''), + })), + }; +} + +function summarizeMediaSnapshot(snapshot) { + if (!snapshot) return ''; + const localSummary = Array.isArray(snapshot.localTrackStates) + ? snapshot.localTrackStates.map((track) => ( + `${track.kind}:${track.readyState}:${track.enabled ? 'on' : 'off'}:${track.muted ? 'muted' : 'live'}` + )).join('|') + : ''; + const remoteSummary = Array.isArray(snapshot.remoteTrackStates) + ? snapshot.remoteTrackStates.map((track) => ( + `${track.kind}:${track.readyState}:${track.enabled ? 'on' : 'off'}:${track.muted ? 'muted' : 'live'}` + )).join('|') + : ''; + return [ + `localStream=${snapshot.hasLocalStream ? 1 : 0}`, + `localTracks=${Number(snapshot.localTrackCount || 0)}`, + `localAudio=${Number(snapshot.localAudioTracksCount || 0)}`, + `localStates=${localSummary || '-'}`, + `remoteAudio=${snapshot.hasRemoteAudio ? 1 : 0}`, + `remoteSrc=${snapshot.hasRemoteAudioSrcObject ? 1 : 0}`, + `remoteTracks=${Number(snapshot.remoteTrackCount || 0)}`, + `remoteStates=${remoteSummary || '-'}`, + ].join(';'); +} + function getCallDiagnosticsContext(call) { const pc = call?.pc || null; const nav = typeof navigator !== 'undefined' ? navigator : null; @@ -686,6 +770,7 @@ function getCallDiagnosticsContext(call) { const iceGatheringState = pc?.iceGatheringState || ''; const currentLocalDescType = pc?.localDescription?.type || ''; const currentRemoteDescType = pc?.remoteDescription?.type || ''; + const mediaSnapshot = buildMediaSnapshot(call); return { remoteSessionIdPresent: Boolean(call?.remoteSessionId), @@ -707,6 +792,7 @@ function getCallDiagnosticsContext(call) { localAudioTracksCount: localAudioTracks.length, localAudioTracksEnabledCount: enabledLocalAudioTracks, localAudioTrackLabels: localAudioTracks.map((t) => String(t?.label || '')).join('|'), + localTrackStates: summarizeMediaSnapshot(mediaSnapshot), hasPeerConnection: Boolean(pc), pcConnectionState: pc?.connectionState || '', pcIceConnectionState: pc?.iceConnectionState || '', @@ -808,6 +894,8 @@ async function flushPendingIceCandidates(call) { async function closeMedia(call) { const pc = call?.pc || null; + const beforeSnapshot = buildMediaSnapshot(call); + recordCallTimeline(call, 'media_close_begin', summarizeMediaSnapshot(beforeSnapshot)); try { const senders = pc?.getSenders?.() || []; senders.forEach((sender) => { @@ -822,6 +910,16 @@ async function closeMedia(call) { try { tr?.stop?.(); } catch {} }); } catch {} + try { + if (pc) { + pc.ontrack = null; + pc.onicecandidate = null; + pc.onconnectionstatechange = null; + pc.oniceconnectionstatechange = null; + pc.onsignalingstatechange = null; + pc.onicegatheringstatechange = null; + } + } catch {} try { call.localStream?.getTracks?.()?.forEach((track) => { try { track.enabled = false; } catch {} @@ -830,8 +928,15 @@ async function closeMedia(call) { } catch {} try { if (call.remoteAudio) { + const remoteTracks = call.remoteAudio.srcObject?.getTracks?.() || []; + remoteTracks.forEach((track) => { + try { track.enabled = false; } catch {} + try { track.stop(); } catch {} + }); try { call.remoteAudio.pause?.(); } catch {} try { call.remoteAudio.srcObject = null; } catch {} + try { call.remoteAudio.removeAttribute?.('src'); } catch {} + try { call.remoteAudio.load?.(); } catch {} call.remoteAudio = null; } } catch {} @@ -842,6 +947,14 @@ async function closeMedia(call) { call.connectionRouteLabel = ''; call.connectionRouteDetails = ''; call.pendingRemoteIceCandidates = []; + const afterSnapshot = buildMediaSnapshot(call); + recordCallTimeline(call, 'media_close_end', summarizeMediaSnapshot(afterSnapshot)); + await emitDebug( + call, + 'info', + 'media_closed', + `before=${summarizeMediaSnapshot(beforeSnapshot)} | after=${summarizeMediaSnapshot(afterSnapshot)}`, + ); } function stopReconnectFlow(call) { @@ -1314,8 +1427,10 @@ async function ensurePeerConnection(call) { recordCallTimeline(call, 'remote_track_received', `streams=${evt?.streams?.length || 0}`); const audio = new Audio(); audio.autoplay = true; + audio.playsInline = true; audio.srcObject = evt.streams[0]; call.remoteAudio = audio; + notifyCallState(); }; call.pc = pc; @@ -1516,6 +1631,36 @@ export async function toggleMicMuted() { await setMicMuted(!call.muted); } +export async function toggleSpeakerMode() { + const call = getActiveCall(); + if (!call || !call.remoteAudio || !canRouteAudioOutputInBrowser()) return false; + const audio = call.remoteAudio; + if (Boolean(call.speakerEnabled)) { + try { + await audio.setSinkId(''); + call.speakerEnabled = false; + notifyCallState(); + return true; + } catch { + return false; + } + } + + try { + const options = call.speakerSinkId ? { deviceId: call.speakerSinkId } : undefined; + const selected = await navigator.mediaDevices.selectAudioOutput(options); + const deviceId = String(selected?.deviceId || '').trim(); + if (!deviceId) return false; + await audio.setSinkId(deviceId); + call.speakerSinkId = deviceId; + call.speakerEnabled = true; + notifyCallState(); + return true; + } catch { + return false; + } +} + export async function startDebugConnectionAsResponder({ runId, callId, peerLogin, peerSessionId }) { const cleanCallId = String(callId || '').trim(); const cleanPeerLogin = String(peerLogin || '').trim(); diff --git a/shine-UI/js/services/call-ui-service.js b/shine-UI/js/services/call-ui-service.js index 4fc20aff..6b88253d 100644 --- a/shine-UI/js/services/call-ui-service.js +++ b/shine-UI/js/services/call-ui-service.js @@ -4,45 +4,236 @@ import { hangupActiveCall, setMicMuted, subscribeCallState, + toggleSpeakerMode, } from './call-service.js'; -let shellEl = null; +const MINIMIZED_BAR_HEIGHT_PX = 44; + +let rootEl = null; +let overlayEl = null; let panelEl = null; +let headerEl = null; let titleEl = null; let statusEl = null; +let incomingActionsEl = null; +let activeActionsEl = null; +let speakerBtn = null; let muteBtn = null; let acceptBtn = null; let declineBtn = null; let hangupBtn = null; +let minimizeBtn = null; +let barEl = null; +let barTextEl = null; +let barActionsEl = null; +let barSpeakerBtn = null; +let barMuteBtn = null; +let barHangupBtn = null; let unbind = null; +let tickerId = 0; +let currentSnapshot = null; +let minimized = false; + +function resolveAppShell() { + return document.querySelector('.app-shell') || document.body; +} + +function pad2(value) { + return String(Math.max(0, Number(value) || 0)).padStart(2, '0'); +} + +function formatElapsedMs(ms) { + const totalSec = Math.max(0, Math.floor(Number(ms || 0) / 1000)); + const hours = Math.floor(totalSec / 3600); + const minutes = Math.floor((totalSec % 3600) / 60); + const seconds = totalSec % 60; + if (hours > 0) return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}`; + return `${pad2(minutes)}:${pad2(seconds)}`; +} + +function getSnapshotElapsedMs(snapshot) { + const baseMs = Number(snapshot?.connectedAtMs || snapshot?.startedAtMs || 0); + if (!baseMs || !Number.isFinite(baseMs)) return 0; + return Math.max(0, Date.now() - baseMs); +} + +function buildBarText(snapshot) { + const peer = String(snapshot?.peerLogin || '').trim() || 'пользователь'; + const elapsed = formatElapsedMs(getSnapshotElapsedMs(snapshot)); + return `Звонок с ${peer} · ${elapsed}`; +} + +function stopEvent(event) { + event.preventDefault(); + event.stopPropagation(); +} + +function getIconSvg(name) { + if (name === 'mic-on') { + return ''; + } + if (name === 'mic-off') { + return ''; + } + if (name === 'hangup') { + return ''; + } + if (name === 'minimize') { + return ''; + } + if (name === 'speaker-on') { + return ''; + } + if (name === 'speaker-off') { + return ''; + } + return ''; +} + +function setButtonIcon(button, iconName, label) { + if (!button) return; + button.innerHTML = getIconSvg(iconName); + button.setAttribute('aria-label', label); + button.title = label; +} + +function updateMuteButtons(muted) { + const iconName = muted ? 'mic-off' : 'mic-on'; + const label = muted ? 'Включить микрофон' : 'Выключить микрофон'; + setButtonIcon(muteBtn, iconName, label); + setButtonIcon(barMuteBtn, iconName, label); + if (muteBtn) muteBtn.dataset.muted = muted ? '1' : '0'; + if (barMuteBtn) barMuteBtn.dataset.muted = muted ? '1' : '0'; +} + +function updateSpeakerButtons(enabled) { + const iconName = enabled ? 'speaker-on' : 'speaker-off'; + const label = enabled ? 'Выключить громкую связь' : 'Включить громкую связь'; + setButtonIcon(speakerBtn, iconName, label); + setButtonIcon(barSpeakerBtn, iconName, label); +} + +async function handleMuteClick(event) { + stopEvent(event); + const nowMuted = event.currentTarget?.dataset?.muted === '1'; + await setMicMuted(!nowMuted); +} + +async function handleEndCallClick(event) { + stopEvent(event); + if (!currentSnapshot) return; + if (currentSnapshot.canDecline && !currentSnapshot.canHangup) { + await declineIncomingCall(); + return; + } + await hangupActiveCall(); +} + +async function handleSpeakerClick(event) { + stopEvent(event); + await toggleSpeakerMode(); +} + +function setMinimized(nextValue) { + minimized = Boolean(nextValue); + render(); +} + +function ensureTicker() { + const shouldTick = Boolean(currentSnapshot); + if (shouldTick && !tickerId) { + tickerId = window.setInterval(() => { + if (!currentSnapshot) return; + render(); + }, 1000); + } + if (!shouldTick && tickerId) { + window.clearInterval(tickerId); + tickerId = 0; + } +} function ensureUi() { - if (shellEl) return; + if (rootEl) return; - shellEl = document.createElement('section'); - shellEl.className = 'call-overlay'; - shellEl.hidden = true; + rootEl = document.createElement('section'); + rootEl.className = 'call-ui-root'; + rootEl.hidden = true; + + barEl = document.createElement('div'); + barEl.className = 'call-minimized-bar'; + barEl.hidden = true; + + barTextEl = document.createElement('button'); + barTextEl.type = 'button'; + barTextEl.className = 'call-minimized-bar-text'; + barTextEl.addEventListener('click', () => { + if (!currentSnapshot) return; + setMinimized(false); + }); + + barActionsEl = document.createElement('div'); + barActionsEl.className = 'call-minimized-bar-actions'; + + barSpeakerBtn = document.createElement('button'); + barSpeakerBtn.type = 'button'; + barSpeakerBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary'; + barSpeakerBtn.addEventListener('click', handleSpeakerClick); + + barMuteBtn = document.createElement('button'); + barMuteBtn.type = 'button'; + barMuteBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary'; + barMuteBtn.addEventListener('click', handleMuteClick); + + barHangupBtn = document.createElement('button'); + barHangupBtn.type = 'button'; + barHangupBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--danger'; + barHangupBtn.addEventListener('click', handleEndCallClick); + + barActionsEl.append(barSpeakerBtn, barMuteBtn, barHangupBtn); + barEl.append(barTextEl, barActionsEl); + + overlayEl = document.createElement('div'); + overlayEl.className = 'call-overlay'; + overlayEl.hidden = true; panelEl = document.createElement('div'); panelEl.className = 'call-overlay-panel'; + headerEl = document.createElement('div'); + headerEl.className = 'call-overlay-head'; + titleEl = document.createElement('h2'); titleEl.className = 'call-overlay-title'; + minimizeBtn = document.createElement('button'); + minimizeBtn.type = 'button'; + minimizeBtn.className = 'call-icon-btn call-icon-btn--secondary call-overlay-minimize-btn'; + setButtonIcon(minimizeBtn, 'minimize', 'Свернуть звонок'); + minimizeBtn.addEventListener('click', (event) => { + stopEvent(event); + if (!currentSnapshot) return; + setMinimized(true); + }); + statusEl = document.createElement('div'); statusEl.className = 'call-overlay-status'; - const controls = document.createElement('div'); - controls.className = 'call-overlay-controls'; + incomingActionsEl = document.createElement('div'); + incomingActionsEl.className = 'call-overlay-controls call-overlay-controls--incoming'; + + activeActionsEl = document.createElement('div'); + activeActionsEl.className = 'call-overlay-controls call-overlay-controls--active'; + + speakerBtn = document.createElement('button'); + speakerBtn.type = 'button'; + speakerBtn.className = 'call-icon-btn call-icon-btn--secondary'; + speakerBtn.addEventListener('click', handleSpeakerClick); muteBtn = document.createElement('button'); muteBtn.type = 'button'; - muteBtn.className = 'secondary-btn'; - muteBtn.textContent = 'Микрофон'; - muteBtn.addEventListener('click', async () => { - const nowMuted = muteBtn.dataset.muted === '1'; - await setMicMuted(!nowMuted); - }); + muteBtn.className = 'call-icon-btn call-icon-btn--secondary'; + muteBtn.addEventListener('click', handleMuteClick); acceptBtn = document.createElement('button'); acceptBtn.type = 'button'; @@ -54,51 +245,122 @@ function ensureUi() { declineBtn = document.createElement('button'); declineBtn.type = 'button'; - declineBtn.className = 'destructive-btn'; - declineBtn.textContent = 'Сбросить'; + declineBtn.className = 'destructive-btn call-decline-btn'; + declineBtn.textContent = 'Отклонить'; declineBtn.addEventListener('click', async () => { await declineIncomingCall(); }); hangupBtn = document.createElement('button'); hangupBtn.type = 'button'; - hangupBtn.className = 'destructive-btn'; - hangupBtn.textContent = 'Положить трубку'; - hangupBtn.addEventListener('click', async () => { - await hangupActiveCall(); - }); + hangupBtn.className = 'call-icon-btn call-icon-btn--danger'; + setButtonIcon(hangupBtn, 'hangup', 'Положить трубку'); + hangupBtn.addEventListener('click', handleEndCallClick); - controls.append(muteBtn, acceptBtn, declineBtn, hangupBtn); - panelEl.append(titleEl, statusEl, controls); - shellEl.append(panelEl); - document.body.append(shellEl); + incomingActionsEl.append(acceptBtn, declineBtn); + activeActionsEl.append(speakerBtn, muteBtn, hangupBtn); + headerEl.append(titleEl, minimizeBtn); + panelEl.append(headerEl, statusEl, incomingActionsEl, activeActionsEl); + overlayEl.append(panelEl); + rootEl.append(barEl, overlayEl); + resolveAppShell().append(rootEl); } -function applyCallState(snapshot) { - ensureUi(); +function applyExpandedState(snapshot) { + overlayEl.hidden = false; + barEl.hidden = true; - if (!snapshot) { - shellEl.hidden = true; - return; - } - - shellEl.hidden = false; titleEl.textContent = `Звонок: ${snapshot.peerLogin || 'пользователь'}`; statusEl.textContent = snapshot.statusText || ''; + const incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline); const muted = Boolean(snapshot.muted); - muteBtn.dataset.muted = muted ? '1' : '0'; - muteBtn.textContent = muted ? 'Включить микрофон' : 'Выключить микрофон'; + updateMuteButtons(muted); + updateSpeakerButtons(Boolean(snapshot.speakerEnabled)); + + minimizeBtn.hidden = incomingStage; + minimizeBtn.disabled = incomingStage; + + incomingActionsEl.hidden = !incomingStage; + activeActionsEl.hidden = incomingStage; + + speakerBtn.hidden = !snapshot.canToggleSpeaker; + speakerBtn.disabled = !snapshot.canToggleSpeaker; muteBtn.hidden = !snapshot.canMute; muteBtn.disabled = !snapshot.canMute; - acceptBtn.hidden = !snapshot.canAnswer; - declineBtn.hidden = !snapshot.canDecline; - + acceptBtn.hidden = !incomingStage || !snapshot.canAnswer; + declineBtn.hidden = !incomingStage || !snapshot.canDecline; hangupBtn.hidden = !snapshot.canHangup; } +function applyMinimizedState(snapshot) { + overlayEl.hidden = true; + barEl.hidden = false; + barTextEl.textContent = buildBarText(snapshot); + const incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline); + const muted = Boolean(snapshot.muted); + updateMuteButtons(muted); + updateSpeakerButtons(Boolean(snapshot.speakerEnabled)); + barSpeakerBtn.hidden = incomingStage || !snapshot.canToggleSpeaker; + barSpeakerBtn.disabled = !snapshot.canToggleSpeaker; + barMuteBtn.hidden = incomingStage || !snapshot.canMute; + barMuteBtn.disabled = !snapshot.canMute; + const canEnd = !incomingStage && snapshot.canHangup; + barHangupBtn.hidden = !canEnd; + barHangupBtn.disabled = !canEnd; + setButtonIcon(barHangupBtn, 'hangup', 'Положить трубку'); + barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !canEnd); +} + +function syncShellState() { + const appShell = resolveAppShell(); + if (!appShell) return; + if (currentSnapshot && minimized) { + appShell.classList.add('has-minimized-call'); + const measuredHeight = Math.max( + MINIMIZED_BAR_HEIGHT_PX, + Math.ceil(Number(barEl?.offsetHeight || 0)), + ); + appShell.style.setProperty('--call-minimized-bar-height', `${measuredHeight}px`); + } else { + appShell.classList.remove('has-minimized-call'); + appShell.style.removeProperty('--call-minimized-bar-height'); + } +} + +function render() { + ensureUi(); + rootEl.hidden = !currentSnapshot; + ensureTicker(); + + if (!currentSnapshot) { + syncShellState(); + overlayEl.hidden = true; + barEl.hidden = true; + return; + } + + if (minimized) { + applyMinimizedState(currentSnapshot); + syncShellState(); + return; + } + + applyExpandedState(currentSnapshot); + syncShellState(); +} + +function applyCallState(snapshot) { + ensureUi(); + currentSnapshot = snapshot || null; + if (!currentSnapshot) { + minimized = false; + } + render(); +} + export function initCallUiOverlay() { ensureUi(); if (unbind) { diff --git a/shine-UI/js/services/channel-name-rules.js b/shine-UI/js/services/channel-name-rules.js index e5d7abec..e1248cf7 100644 --- a/shine-UI/js/services/channel-name-rules.js +++ b/shine-UI/js/services/channel-name-rules.js @@ -1,6 +1,7 @@ -const MIN_LEN = 1; +const MIN_LEN = 3; const MAX_LEN = 32; const ALLOWED_CHARS_RE = /^[A-Za-z0-9_-]+$/; +const DIGITS_ONLY_RE = /^[0-9]+$/; export function normalizeChannelDisplayName(value) { if (value == null) return ''; @@ -37,8 +38,8 @@ export function validateChannelDisplayName(value) { if (!ALLOWED_CHARS_RE.test(normalized)) { return { ok: false, code: 'bad_chars', normalized, slug: '' }; } - if (normalized === '0') { - return { ok: false, code: 'reserved', normalized, slug: '' }; + if (DIGITS_ONLY_RE.test(normalized)) { + return { ok: false, code: 'digits_only', normalized, slug: '' }; } const slug = toCanonicalChannelSlug(normalized); @@ -54,13 +55,13 @@ export function channelNameErrorText(code) { case 'blank': return 'Введите название канала.'; case 'too_short': - return 'Название слишком короткое: минимум 1 символ.'; + return 'Название слишком короткое: минимум 3 символа.'; case 'too_long': return 'Название слишком длинное: максимум 32 символа.'; case 'bad_chars': return 'Разрешены только латиница, цифры, _ и -.'; - case 'reserved': - return 'Название "0" зарезервировано.'; + case 'digits_only': + return 'Название канала не должно состоять только из цифр.'; default: return 'Некорректное название канала.'; } diff --git a/shine-UI/js/services/ui-error-texts.js b/shine-UI/js/services/ui-error-texts.js index a5fb39fd..6e46c19e 100644 --- a/shine-UI/js/services/ui-error-texts.js +++ b/shine-UI/js/services/ui-error-texts.js @@ -86,11 +86,12 @@ export function toUserMessage(error, fallback = 'Действие не выпо code === 'BAD_CHANNEL_NAME' || text.includes('channel name must match') || text.includes('channelname contains unsupported') || + text.includes('channelname must not contain only digits') || text.includes('channelname length must be 3..32') || text.includes('channelname length must be 1..32') || text.includes('bad_channel_name') ) { - return 'Некорректное название канала. Разрешены кириллица, латиница, цифры, пробел, _ и - (1..32 символа).'; + return 'Некорректное название канала. Разрешены только латинские буквы, цифры, _ и -. Длина: 3..32. Название не должно состоять только из цифр.'; } if (text.includes('channel name is required') || text.includes('введите имя канала')) { diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 33b33a52..f9ed30b5 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -1733,16 +1733,134 @@ display: none; } -.call-overlay { - position: fixed; +.call-ui-root[hidden] { + display: none; +} + +.call-ui-root { + position: absolute; inset: 0; z-index: 80; + pointer-events: none; +} + +.call-minimized-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: calc(8px + env(safe-area-inset-top)) 14px 8px; + border-bottom: 1px solid rgba(159, 255, 196, 0.28); + background: linear-gradient(180deg, rgba(30, 138, 79, 0.98), rgba(18, 106, 60, 0.98)); + box-shadow: 0 10px 24px rgba(5, 30, 16, 0.28); + pointer-events: auto; +} + +.call-minimized-bar-text { + flex: 1 1 auto; + min-width: 0; + border: 0; + background: transparent; + color: #f3fff8; + font-size: 13px; + font-weight: 700; + line-height: 1.2; + text-align: left; + padding: 0; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.call-minimized-bar-actions { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +.call-icon-btn { + width: 40px; + min-width: 40px; + height: 40px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + border: 1px solid rgba(132, 162, 228, 0.42); + background: linear-gradient(180deg, rgba(19, 35, 63, 0.9), rgba(14, 26, 50, 0.95)); + color: #e7efff; + cursor: pointer; + transition: 0.2s ease; +} + +.call-icon-btn:hover { + transform: translateY(-1px); +} + +.call-icon-btn:disabled { + opacity: 1; + color: #93a6cc; + background: linear-gradient(180deg, rgba(18, 30, 54, 0.8), rgba(11, 20, 39, 0.86)); + border-color: rgba(124, 145, 189, 0.28); + box-shadow: none; + cursor: not-allowed; + transform: none; +} + +.call-icon-btn svg { + width: 20px; + height: 20px; +} + +.call-icon-btn--secondary { + border-color: rgba(132, 162, 228, 0.42); + background: linear-gradient(180deg, rgba(19, 35, 63, 0.9), rgba(14, 26, 50, 0.95)); + color: #e7efff; +} + +.call-icon-btn--secondary:hover { + border-color: rgba(167, 195, 255, 0.76); +} + +.call-icon-btn--danger { + border-color: rgba(255, 129, 147, 0.52); + background: linear-gradient(120deg, rgba(188, 42, 65, 0.96), rgba(142, 28, 47, 0.98)); + color: #fff2f5; +} + +.call-icon-btn--danger:hover { + border-color: rgba(255, 163, 176, 0.82); +} + +.call-icon-btn--bar { + width: 34px; + min-width: 34px; + height: 34px; + border-radius: 10px; +} + +.call-icon-btn--bar svg { + width: 18px; + height: 18px; +} + +.call-overlay { + position: absolute; + inset: 0; display: flex; align-items: flex-end; justify-content: center; padding: 16px; background: rgba(6, 10, 16, 0.55); backdrop-filter: blur(4px); + pointer-events: auto; } .call-overlay-panel { @@ -1756,12 +1874,23 @@ gap: 10px; } +.call-overlay-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + .call-overlay-title { margin: 0; font-size: 20px; color: #eff4ff; } +.call-overlay-minimize-btn { + flex: 0 0 auto; +} + .call-overlay-status { color: #c8d7f6; font-size: 14px; @@ -1771,6 +1900,29 @@ display: flex; flex-wrap: wrap; gap: 8px; + align-items: center; +} + +.call-overlay-controls[hidden], +.call-overlay-controls--incoming[hidden], +.call-overlay-controls--active[hidden], +.call-icon-btn[hidden], +.call-accept-btn[hidden], +.call-decline-btn[hidden], +.call-minimized-bar-actions[hidden] { + display: none !important; +} + +.call-overlay-controls--incoming { + justify-content: flex-start; +} + +.call-overlay-controls--active { + justify-content: flex-start; +} + +.call-decline-btn { + min-width: 120px; } .call-accept-btn { diff --git a/shine-UI/styles/layout.css b/shine-UI/styles/layout.css index 4b44cc08..00d0ddef 100644 --- a/shine-UI/styles/layout.css +++ b/shine-UI/styles/layout.css @@ -24,6 +24,7 @@ body::before { width: min(100vw, 430px); height: 100dvh; position: relative; + --call-minimized-bar-height: 0px; background: transparent; border-left: 1px solid rgba(211, 170, 86, 0.2); border-right: 1px solid rgba(211, 170, 86, 0.2); @@ -39,7 +40,7 @@ body::before { .screen-content { position: absolute; - top: 0; + top: var(--call-minimized-bar-height, 0px); left: 0; right: 0; bottom: 74px; @@ -52,6 +53,10 @@ body::before { padding-bottom: calc(24px + env(safe-area-inset-bottom)); } +.app-shell.has-minimized-call .screen-content { + padding-top: 12px; +} + .screen-content.network-scroll-lock { overflow: hidden; padding: 0;