Compare commits

...
Author SHA256 Message Date
tram 97f90167f1 Добавить поддержку уведомлений 2026-08-21 13:23:41 +03:00
AidarKC 23bdadab56 Merge branch 'расширение-функционала-каналов' 2026-08-20 08:23:19 +04:00
AidarKC 47a4844ce8 Merge remote-tracking branch 'origin/main' into расширение-функционала-каналов 2026-08-20 08:14:52 +04:00
tram 8741be6cba Улучшить раскладку чата при клавиатуре 2026-08-20 07:12:46 +03:00
AidarKC 4656a03ea9 Исправить автоскролл и TDZ в канале 2026-08-19 20:38:03 +04:00
AidarKC 5763eb828e Исправить открытие канала и ключ настройки 2026-08-19 20:34:41 +04:00
AidarKC 6a5c20a165 Добавить user_settings и синхронизацию настроек 2026-08-19 19:45:09 +04:00
AidarKC fac166f186 Удалить legacy-стек прямых сообщений 2026-08-19 18:11:00 +04:00
AidarKC 091291bfa2 Синхронизировать локальный main с origin/main 2026-08-19 14:33:01 +04:00
AidarKC 288fde67e8 Merge ветки расширения функционала каналов в main 2026-08-19 14:31:03 +04:00
AidarKC ce30b77328 Синхронизировать регистр логинов и доработать UI каналов 2026-08-19 14:30:29 +04:00
AidarKC 8c4e404f23 Merge ветки расширения функционала каналов в main 2026-08-12 19:38:17 +04:00
AidarKC e8a4713f6e UI: свайпы вкладок и навигация тредов 2026-08-12 19:37:29 +04:00
AidarKC b4b23afc10 Доработать верхнее меню списка каналов 2026-08-12 17:12:57 +04:00
AidarKC 8f32e82d14 Сократить служебные теги SHiNE и сохранить обратную совместимость 2026-08-12 13:14:23 +04:00
AidarKC cf391bdd80 main: обновить версии до 1.6.0 и 1.5.0 2026-08-12 00:17:48 +04:00
AidarKC 7120d6a9db Merge branch 'расширение-функционала-каналов' into main 2026-08-12 00:17:34 +04:00
AidarKC 4d80ab639e Порядок навели с документацией ТУДУ 2026-08-11 20:20:10 +04:00
AidarKC 95bbd2852e Регистрация: пополнение в том же окне и тестовый top-up 2026-08-11 20:19:46 +04:00
AidarKC 4b0a934e51 Ограничил access servers до двух в UI и сервере 2026-08-11 17:21:07 +04:00
AidarKC ee5d86003b UI: уменьшение splash logo и правки diary preview 2026-08-11 17:03:46 +04:00
AidarKC bc11b60147 UI: правки клавиатуры и дневника 2026-08-11 16:26:31 +04:00
AidarKC 801a4697ea UI: общий верхний и нижний тулбар 2026-08-11 15:22:10 +04:00
AidarKC 628a970e6f Доработать UI звонков и нижний тулбар 2026-08-11 13:29:29 +04:00
AidarKC 566ea942dc Починить включение видео у принимающей стороны 2026-08-11 12:49:35 +04:00
AidarKC e047c2d54e Подготовить обычный звонок к позднему апгрейду в видео 2026-08-11 12:43:17 +04:00
169 changed files with 11922 additions and 3515 deletions
@@ -98,14 +98,9 @@ public final class MsgSubType {
/* ===================== CONNECTION (msg_type=3) ===================== */
/** Добавить в близкие друзья (close friend). */
public static final short CONNECTION_FRIEND = 10;
public static final short CONNECTION_CLOSE_FRIEND = 10;
/** Удалить из близких друзей (close friend). */
public static final short CONNECTION_UNFRIEND = 11;
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
/** Добавить в контакты. */
public static final short CONNECTION_CONTACT = 20;
@@ -66,7 +66,7 @@ import java.util.Objects;
* toBlockHash32=hash32(CREATE_CHANNEL)
*
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
* - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
*
@@ -183,8 +183,8 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
private static boolean isValidSubType(short st) {
int v = st & 0xFFFF;
return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
@@ -14,6 +14,9 @@ public final class ShineSignatureConstants {
/** Подписываемые данные параметра пользователя: prefix + login + param + time_ms + value */
public static final String USER_PARAMETER_PREFIX = "SHiNe/UserParameter:";
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
@@ -21,10 +21,22 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_2 = 2;
public static final int SCHEMA_VERSION_3 = 3;
public static final int SCHEMA_VERSION_4 = 4;
public static final int SCHEMA_VERSION_5 = 5;
public static final int SCHEMA_VERSION_6 = 6;
public static final int SCHEMA_VERSION_7 = 7;
public static final int SCHEMA_VERSION_8 = 8;
public static final int SCHEMA_VERSION_9 = 9;
public static final int SCHEMA_VERSION_10 = 10;
public static final 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";
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
public static final String POSTGRES_MIGRATION_V5_RESOURCE = "postgres/migration_v5.sql";
public static final String POSTGRES_MIGRATION_V6_RESOURCE = "postgres/migration_v6.sql";
public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql";
public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql";
public static final String POSTGRES_MIGRATION_V9_RESOURCE = "postgres/migration_v9.sql";
public static final String POSTGRES_MIGRATION_V10_RESOURCE = "postgres/migration_v10.sql";
private DatabaseInitializer() {}
@@ -43,10 +55,8 @@ public final class DatabaseInitializer {
public static final short REACTION_LIKE = 1;
public static final short REACTION_UNLIKE = 2;
public static final short CONNECTION_FRIEND = 10;
public static final short CONNECTION_UNFRIEND = 11;
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
public static final short CONNECTION_CLOSE_FRIEND = 10;
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
public static final short CONNECTION_CONTACT = 20;
public static final short CONNECTION_UNCONTACT = 21;
@@ -100,6 +110,30 @@ public final class DatabaseInitializer {
}
if (currentVersion < SCHEMA_VERSION_4) {
runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE);
currentVersion = SCHEMA_VERSION_4;
}
if (currentVersion < SCHEMA_VERSION_5) {
runSqlScript(conn, POSTGRES_MIGRATION_V5_RESOURCE);
currentVersion = SCHEMA_VERSION_5;
}
if (currentVersion < SCHEMA_VERSION_6) {
runSqlScript(conn, POSTGRES_MIGRATION_V6_RESOURCE);
currentVersion = SCHEMA_VERSION_6;
}
if (currentVersion < SCHEMA_VERSION_7) {
runSqlScript(conn, POSTGRES_MIGRATION_V7_RESOURCE);
currentVersion = SCHEMA_VERSION_7;
}
if (currentVersion < SCHEMA_VERSION_8) {
runSqlScript(conn, POSTGRES_MIGRATION_V8_RESOURCE);
currentVersion = SCHEMA_VERSION_8;
}
if (currentVersion < SCHEMA_VERSION_9) {
runSqlScript(conn, POSTGRES_MIGRATION_V9_RESOURCE);
currentVersion = SCHEMA_VERSION_9;
}
if (currentVersion < SCHEMA_VERSION_10) {
runSqlScript(conn, POSTGRES_MIGRATION_V10_RESOURCE);
}
}
}
@@ -58,23 +58,17 @@ public final class MsgSubType {
/* ===================== CONNECTION (msg_type=3) ===================== */
/**
* Совпадает с ConnectionBody:
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
* SET: CLOSE_FRIEND=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
* UNSET: UNCLOSE_FRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
*/
/** Добавить в близкие друзья (close friend). */
public static final short CONNECTION_FRIEND = 10;
public static final short CONNECTION_CLOSE_FRIEND = 10;
/** Удалить из близких друзей (close friend). */
public static final short CONNECTION_UNFRIEND = 11;
/** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
/** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
public static final short CONNECTION_UNCLOSE_FRIEND = 11;
/** Добавить в контакты. */
public static final short CONNECTION_CONTACT = 20;
@@ -185,10 +185,10 @@ public final class BlockchainResyncCleanupDAO {
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
String sql = """
UPDATE message_stats
SET likes_count = MAX(
SET likes_count = GREATEST(
0,
likes_count - (
SELECT COUNT(*)
likes_count - COALESCE((
SELECT COUNT(*)::int
FROM reactions_state rs
WHERE rs.from_bch_name = ?
AND rs.reaction_type = ?
@@ -198,7 +198,7 @@ public final class BlockchainResyncCleanupDAO {
AND rs.to_block_number = message_stats.to_block_number
AND rs.to_block_hash = message_stats.to_block_hash
AND rs.to_bch_name <> ?
)
), 0)
)
WHERE EXISTS (
SELECT 1
@@ -237,10 +237,10 @@ public final class BlockchainResyncCleanupDAO {
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
String sql = """
UPDATE message_stats
SET replies_count = MAX(
SET replies_count = GREATEST(
0,
replies_count - COALESCE((
SELECT COUNT(*)
SELECT COUNT(*)::int
FROM blocks b
WHERE b.bch_name = ?
AND b.msg_type = 1
@@ -49,7 +49,7 @@ public final class CurrentUsersDAO {
String sql = """
SELECT 1
FROM %s
WHERE LOWER(login) = LOWER(?)
WHERE normalized_login = LOWER(BTRIM(?))
LIMIT 1
""".formatted(CurrentUsersSql.usersSubquery("su"));
@@ -104,7 +104,7 @@ public final class CurrentUsersDAO {
blockchain_key,
client_key
FROM %s
WHERE LOWER(login) = LOWER(?)
WHERE normalized_login = LOWER(BTRIM(?))
""".formatted(CurrentUsersSql.usersSubquery("su"));
try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -167,7 +167,7 @@ public final class CurrentUsersDAO {
blockchain_key,
client_key
FROM %s
WHERE LOWER(login) LIKE ?
WHERE normalized_login LIKE LOWER(BTRIM(?))
AND (? IS NULL OR is_server = ?)
ORDER BY login
LIMIT 5
@@ -176,7 +176,7 @@ public final class CurrentUsersDAO {
List<CurrentUserEntry> result = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, prefix.toLowerCase() + "%");
ps.setString(1, prefix.trim() + "%");
if (isServer == null) {
ps.setNull(2, Types.BOOLEAN);
ps.setNull(3, Types.BOOLEAN);
@@ -1,53 +0,0 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.DirectMessageEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class DirectMessagesDAO {
private static volatile DirectMessagesDAO instance;
private final DbController db = DbController.getInstance();
private DirectMessagesDAO() {}
public static DirectMessagesDAO getInstance() {
if (instance == null) {
synchronized (DirectMessagesDAO.class) {
if (instance == null) instance = new DirectMessagesDAO();
}
}
return instance;
}
public void insert(DirectMessageEntry entry) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO direct_messages (
message_id, from_login, to_login, text, created_at_ms
) VALUES (?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getMessageId());
ps.setString(2, entry.getFromLogin());
ps.setString(3, entry.getToLogin());
ps.setString(4, entry.getText());
ps.setLong(5, entry.getCreatedAtMs());
ps.executeUpdate();
}
}
}
public boolean existsFromTo(String fromLogin, String toLogin) throws Exception {
try (Connection c = db.getConnection()) {
String sql = "SELECT 1 FROM direct_messages WHERE from_login = ? AND to_login = ? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, fromLogin);
ps.setString(2, toLogin);
return ps.executeQuery().next();
}
}
}
}
@@ -1,47 +0,0 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.SignedDirectMessageHistoryEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class SignedDirectMessagesHistoryDAO {
private static volatile SignedDirectMessagesHistoryDAO instance;
private final DbController db = DbController.getInstance();
private SignedDirectMessagesHistoryDAO() {}
public static SignedDirectMessagesHistoryDAO getInstance() {
if (instance == null) {
synchronized (SignedDirectMessagesHistoryDAO.class) {
if (instance == null) instance = new SignedDirectMessagesHistoryDAO();
}
}
return instance;
}
public void insert(SignedDirectMessageHistoryEntry e) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO signed_direct_messages_history (
message_id, from_login, to_login, target_mode, target_session_id,
message_type, time_ms, nonce, raw_packet, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, e.getMessageId());
ps.setString(2, e.getFromLogin());
ps.setString(3, e.getToLogin());
ps.setInt(4, e.getTargetMode());
ps.setString(5, e.getTargetSessionId());
ps.setInt(6, e.getMessageType());
ps.setLong(7, e.getTimeMs());
ps.setLong(8, e.getNonce());
ps.setBytes(9, e.getRawPacket());
ps.setLong(10, e.getCreatedAtMs());
ps.executeUpdate();
}
}
}
}
@@ -1,51 +0,0 @@
package shine.db.dao;
import shine.db.DbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class SignedDmReplayDAO {
private static volatile SignedDmReplayDAO instance;
private final DbController db = DbController.getInstance();
private SignedDmReplayDAO() {}
public static SignedDmReplayDAO getInstance() {
if (instance == null) {
synchronized (SignedDmReplayDAO.class) {
if (instance == null) instance = new SignedDmReplayDAO();
}
}
return instance;
}
public boolean registerUnique(String fromLogin, long timeMs, long nonce, long nowMs) throws Exception {
cleanupExpired(nowMs - 15L * 60L * 1000L);
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO signed_direct_message_replay (
from_login, time_ms, nonce, created_at_ms
) VALUES (?, ?, ?, ?)
ON CONFLICT DO NOTHING
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, fromLogin);
ps.setLong(2, timeMs);
ps.setLong(3, nonce);
ps.setLong(4, nowMs);
return ps.executeUpdate() > 0;
}
}
}
public void cleanupExpired(long minCreatedAtMs) throws Exception {
try (Connection c = db.getConnection()) {
String sql = "DELETE FROM signed_direct_message_replay WHERE created_at_ms < ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, minCreatedAtMs);
ps.executeUpdate();
}
}
}
}
@@ -0,0 +1,105 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.UserNotificationEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
public final class UserNotificationsStateDAO {
private static volatile UserNotificationsStateDAO instance;
private final DbController db = DbController.getInstance();
private UserNotificationsStateDAO() {}
public static UserNotificationsStateDAO getInstance() {
if (instance == null) {
synchronized (UserNotificationsStateDAO.class) {
if (instance == null) instance = new UserNotificationsStateDAO();
}
}
return instance;
}
public int upsert(Connection c, UserNotificationEntry e) throws SQLException {
String sql = """
INSERT INTO user_notifications_state (
owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
source_block_number, source_block_hash, target_login, target_bch_name,
target_block_number, target_block_hash, source_msg_sub_type, source_text
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash) DO UPDATE SET
created_at_ms = EXCLUDED.created_at_ms,
source_login = EXCLUDED.source_login,
target_login = EXCLUDED.target_login,
target_bch_name = EXCLUDED.target_bch_name,
target_block_number = EXCLUDED.target_block_number,
target_block_hash = EXCLUDED.target_block_hash,
source_msg_sub_type = EXCLUDED.source_msg_sub_type,
source_text = EXCLUDED.source_text
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
ps.setString(i++, e.getOwnerLogin());
ps.setString(i++, e.getNotificationKind());
ps.setLong(i++, e.getCreatedAtMs());
ps.setString(i++, e.getSourceLogin());
ps.setString(i++, e.getSourceBchName());
ps.setInt(i++, e.getSourceBlockNumber());
ps.setBytes(i++, e.getSourceBlockHash());
if (e.getTargetLogin() != null) ps.setString(i++, e.getTargetLogin()); else ps.setNull(i++, Types.VARCHAR);
if (e.getTargetBchName() != null) ps.setString(i++, e.getTargetBchName()); else ps.setNull(i++, Types.VARCHAR);
if (e.getTargetBlockNumber() != null) ps.setInt(i++, e.getTargetBlockNumber()); else ps.setNull(i++, Types.INTEGER);
if (e.getTargetBlockHash() != null) ps.setBytes(i++, e.getTargetBlockHash()); else ps.setNull(i++, Types.BINARY);
ps.setInt(i++, e.getSourceMsgSubType());
ps.setString(i++, e.getSourceText() == null ? "" : e.getSourceText());
return ps.executeUpdate();
}
}
public List<UserNotificationEntry> listByOwnerAndKind(Connection c, String ownerLogin, String kind, int limit) throws SQLException {
String sql = """
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
source_block_number, source_block_hash, target_login, target_bch_name,
target_block_number, target_block_hash, source_msg_sub_type, source_text
FROM user_notifications_state
WHERE owner_login = ? AND notification_kind = ?
ORDER BY created_at_ms DESC, source_block_number DESC
LIMIT ?
""";
List<UserNotificationEntry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setString(2, kind);
ps.setInt(3, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
UserNotificationEntry e = new UserNotificationEntry();
e.setOwnerLogin(rs.getString("owner_login"));
e.setNotificationKind(rs.getString("notification_kind"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
e.setSourceLogin(rs.getString("source_login"));
e.setSourceBchName(rs.getString("source_bch_name"));
e.setSourceBlockNumber(rs.getInt("source_block_number"));
e.setSourceBlockHash(rs.getBytes("source_block_hash"));
e.setTargetLogin(rs.getString("target_login"));
e.setTargetBchName(rs.getString("target_bch_name"));
Object targetBlockNumber = rs.getObject("target_block_number");
e.setTargetBlockNumber(targetBlockNumber == null ? null : ((Number) targetBlockNumber).intValue());
e.setTargetBlockHash(rs.getBytes("target_block_hash"));
e.setSourceMsgSubType(rs.getInt("source_msg_sub_type"));
e.setSourceText(rs.getString("source_text"));
return e;
}
}
@@ -0,0 +1,231 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.UserSettingEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
/**
* UserSettingsDAO — хранение пользовательских настроек.
*
* Правило:
* - уникальность: login + setting_type + setting_key
* - запись обновляется только если time_ms новее
* - synced=true означает, что значение уже дошло до второго сервера
*/
public final class UserSettingsDAO {
private static volatile UserSettingsDAO instance;
private final DbController db = DbController.getInstance();
private UserSettingsDAO() {}
public static UserSettingsDAO getInstance() {
if (instance == null) {
synchronized (UserSettingsDAO.class) {
if (instance == null) instance = new UserSettingsDAO();
}
}
return instance;
}
public int upsertIfNewer(Connection c, UserSettingEntry e) throws SQLException {
String sql = """
INSERT INTO user_settings (
login, setting_type, setting_key, time_ms,
value_text, value_num, client_key, signature, synced
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (login, setting_type, setting_key)
DO UPDATE SET
time_ms = EXCLUDED.time_ms,
value_text = EXCLUDED.value_text,
value_num = EXCLUDED.value_num,
client_key = EXCLUDED.client_key,
signature = EXCLUDED.signature,
synced = EXCLUDED.synced
WHERE user_settings.time_ms < EXCLUDED.time_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, e.getLogin());
ps.setInt(2, e.getSettingType());
ps.setString(3, e.getSettingKey());
ps.setLong(4, e.getTimeMs());
ps.setString(5, e.getValueText() == null ? "" : e.getValueText());
ps.setLong(6, e.getValueNum());
if (e.getClientKey() == null || e.getClientKey().isBlank()) ps.setNull(7, Types.VARCHAR);
else ps.setString(7, e.getClientKey());
if (e.getSignature() == null || e.getSignature().isBlank()) ps.setNull(8, Types.VARCHAR);
else ps.setString(8, e.getSignature());
ps.setBoolean(9, e.isSynced());
return ps.executeUpdate();
}
}
public int upsertIfNewer(UserSettingEntry e) throws SQLException {
try (Connection c = db.getConnection()) {
return upsertIfNewer(c, e);
}
}
public UserSettingEntry getByLoginTypeKey(Connection c, String login, int settingType, String settingKey) throws SQLException {
String sql = """
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
FROM user_settings
WHERE LOWER(login) = LOWER(?) AND setting_type = ? AND setting_key = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, settingType);
ps.setString(3, settingKey);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
public UserSettingEntry getByLoginTypeKey(String login, int settingType, String settingKey) throws SQLException {
try (Connection c = db.getConnection()) {
return getByLoginTypeKey(c, login, settingType, settingKey);
}
}
public List<UserSettingEntry> getByLogin(Connection c, String login) throws SQLException {
String sql = """
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
FROM user_settings
WHERE LOWER(login) = LOWER(?)
ORDER BY time_ms DESC, setting_type ASC, setting_key ASC
""";
List<UserSettingEntry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public List<UserSettingEntry> getByLogin(String login) throws SQLException {
try (Connection c = db.getConnection()) {
return getByLogin(c, login);
}
}
public List<UserSettingEntry> listNewerThan(Connection c, String login, long afterTimeMs, String afterSettingKey, int limit) throws SQLException {
String sql = """
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
FROM user_settings
WHERE LOWER(login) = LOWER(?)
AND (
time_ms > ?
OR (time_ms = ? AND setting_key > ?)
)
ORDER BY time_ms ASC, setting_key ASC
LIMIT ?
""";
List<UserSettingEntry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setLong(2, Math.max(0L, afterTimeMs));
ps.setLong(3, Math.max(0L, afterTimeMs));
ps.setString(4, afterSettingKey == null ? "" : afterSettingKey);
ps.setInt(5, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public List<UserSettingEntry> listUnsyncedByLogin(Connection c, String login, int limit) throws SQLException {
String sql = """
SELECT login, setting_type, setting_key, time_ms, value_text, value_num, client_key, signature, synced
FROM user_settings
WHERE LOWER(login) = LOWER(?)
AND synced = FALSE
ORDER BY time_ms ASC, setting_key ASC
LIMIT ?
""";
List<UserSettingEntry> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, Math.max(1, limit));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
}
return out;
}
public int markSynced(Connection c, String login, int settingType, String settingKey) throws SQLException {
String sql = """
UPDATE user_settings
SET synced = TRUE
WHERE LOWER(login) = LOWER(?)
AND setting_type = ?
AND setting_key = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, settingType);
ps.setString(3, settingKey);
return ps.executeUpdate();
}
}
public int markAllUnsynced(Connection c, String login) throws SQLException {
String sql = """
UPDATE user_settings
SET synced = FALSE
WHERE LOWER(login) = LOWER(?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
return ps.executeUpdate();
}
}
public int markAllUnsynced() throws SQLException {
try (Connection c = db.getConnection()) {
return markAllUnsynced(c);
}
}
public int markAllUnsynced(Connection c) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("UPDATE user_settings SET synced = FALSE")) {
return ps.executeUpdate();
}
}
private static UserSettingEntry mapRow(ResultSet rs) throws SQLException {
UserSettingEntry e = new UserSettingEntry();
e.setLogin(rs.getString("login"));
e.setSettingType(rs.getInt("setting_type"));
e.setSettingKey(rs.getString("setting_key"));
e.setTimeMs(rs.getLong("time_ms"));
e.setValueText(rs.getString("value_text"));
e.setValueNum(rs.getLong("value_num"));
String clientKey = rs.getString("client_key");
if (rs.wasNull()) clientKey = null;
e.setClientKey(clientKey);
String signature = rs.getString("signature");
if (rs.wasNull()) signature = null;
e.setSignature(signature);
e.setSynced(rs.getBoolean("synced"));
return e;
}
}
@@ -0,0 +1,152 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.UserSettingsSyncPeerStateEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public final class UserSettingsSyncPeerStateDAO {
private static volatile UserSettingsSyncPeerStateDAO instance;
private final DbController db = DbController.getInstance();
private UserSettingsSyncPeerStateDAO() {}
public static UserSettingsSyncPeerStateDAO getInstance() {
if (instance == null) {
synchronized (UserSettingsSyncPeerStateDAO.class) {
if (instance == null) instance = new UserSettingsSyncPeerStateDAO();
}
}
return instance;
}
public UserSettingsSyncPeerStateEntry getOrCreate(Connection c, String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
UserSettingsSyncPeerStateEntry existing = get(c, ownerLogin, remoteServerLogin);
if (existing != null) return existing;
long nowMs = System.currentTimeMillis();
String sql = """
INSERT INTO user_settings_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_time_ms, cursor_setting_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?)
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
remote_server_url = EXCLUDED.remote_server_url,
updated_at_ms = EXCLUDED.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl);
ps.setLong(4, nowMs);
ps.executeUpdate();
}
return get(c, ownerLogin, remoteServerLogin);
}
public UserSettingsSyncPeerStateEntry get(Connection c, String ownerLogin, String remoteServerLogin) throws SQLException {
String sql = """
SELECT owner_login, remote_server_login, remote_server_url, cursor_time_ms, cursor_setting_key,
bootstrap_completed, last_sync_at_ms, last_error, updated_at_ms
FROM user_settings_sync_peer_state
WHERE LOWER(owner_login) = LOWER(?) AND LOWER(remote_server_login) = LOWER(?)
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
public int updateSuccess(String ownerLogin, String remoteServerLogin, String remoteServerUrl, long cursorTimeMs, String cursorSettingKey, boolean bootstrapCompleted) throws SQLException {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO user_settings_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_time_ms, cursor_setting_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
remote_server_url = EXCLUDED.remote_server_url,
cursor_time_ms = EXCLUDED.cursor_time_ms,
cursor_setting_key = EXCLUDED.cursor_setting_key,
bootstrap_completed = EXCLUDED.bootstrap_completed,
last_sync_at_ms = EXCLUDED.last_sync_at_ms,
last_error = NULL,
updated_at_ms = EXCLUDED.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
long nowMs = System.currentTimeMillis();
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl);
ps.setLong(4, Math.max(0L, cursorTimeMs));
ps.setString(5, cursorSettingKey == null ? "" : cursorSettingKey);
ps.setBoolean(6, bootstrapCompleted);
ps.setLong(7, nowMs);
ps.setLong(8, nowMs);
return ps.executeUpdate();
}
}
}
public int updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO user_settings_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_time_ms, cursor_setting_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?)
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
remote_server_url = EXCLUDED.remote_server_url,
last_error = EXCLUDED.last_error,
updated_at_ms = EXCLUDED.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
long nowMs = System.currentTimeMillis();
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl);
ps.setString(4, error);
ps.setLong(5, nowMs);
return ps.executeUpdate();
}
}
}
public int clearBootstrap(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
return updateSuccess(ownerLogin, remoteServerLogin, remoteServerUrl, 0L, "", false);
}
public int deleteAllForOwner(String ownerLogin) throws SQLException {
try (Connection c = db.getConnection()) {
try (PreparedStatement ps = c.prepareStatement("DELETE FROM user_settings_sync_peer_state WHERE LOWER(owner_login) = LOWER(?)")) {
ps.setString(1, ownerLogin);
return ps.executeUpdate();
}
}
}
private static UserSettingsSyncPeerStateEntry mapRow(ResultSet rs) throws SQLException {
UserSettingsSyncPeerStateEntry e = new UserSettingsSyncPeerStateEntry();
e.setOwnerLogin(rs.getString("owner_login"));
e.setRemoteServerLogin(rs.getString("remote_server_login"));
e.setRemoteServerUrl(rs.getString("remote_server_url"));
e.setCursorTimeMs(rs.getLong("cursor_time_ms"));
e.setCursorSettingKey(rs.getString("cursor_setting_key"));
e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed"));
long lastSyncAtMs = rs.getLong("last_sync_at_ms");
e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAtMs);
e.setLastError(rs.getString("last_error"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
return e;
}
}
@@ -1,24 +0,0 @@
package shine.db.entities;
public class DirectMessageEntry {
private String messageId;
private String fromLogin;
private String toLogin;
private String text;
private long createdAtMs;
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
}
@@ -1,35 +0,0 @@
package shine.db.entities;
public class SignedDirectMessageHistoryEntry {
private String messageId;
private String fromLogin;
private String toLogin;
private int targetMode;
private String targetSessionId;
private int messageType;
private long timeMs;
private long nonce;
private byte[] rawPacket;
private long createdAtMs;
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public int getTargetMode() { return targetMode; }
public void setTargetMode(int targetMode) { this.targetMode = targetMode; }
public String getTargetSessionId() { return targetSessionId; }
public void setTargetSessionId(String targetSessionId) { this.targetSessionId = targetSessionId; }
public int getMessageType() { return messageType; }
public void setMessageType(int messageType) { this.messageType = messageType; }
public long getTimeMs() { return timeMs; }
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
public long getNonce() { return nonce; }
public void setNonce(long nonce) { this.nonce = nonce; }
public byte[] getRawPacket() { return rawPacket; }
public void setRawPacket(byte[] rawPacket) { this.rawPacket = rawPacket; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
}
@@ -0,0 +1,44 @@
package shine.db.entities;
public class UserNotificationEntry {
private String ownerLogin;
private String notificationKind;
private long createdAtMs;
private String sourceLogin;
private String sourceBchName;
private int sourceBlockNumber;
private byte[] sourceBlockHash;
private String targetLogin;
private String targetBchName;
private Integer targetBlockNumber;
private byte[] targetBlockHash;
private int sourceMsgSubType;
private String sourceText;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public String getNotificationKind() { return notificationKind; }
public void setNotificationKind(String notificationKind) { this.notificationKind = notificationKind; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
public String getSourceLogin() { return sourceLogin; }
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
public String getSourceBchName() { return sourceBchName; }
public void setSourceBchName(String sourceBchName) { this.sourceBchName = sourceBchName; }
public int getSourceBlockNumber() { return sourceBlockNumber; }
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
public byte[] getSourceBlockHash() { return sourceBlockHash; }
public void setSourceBlockHash(byte[] sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
public String getTargetLogin() { return targetLogin; }
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
public String getTargetBchName() { return targetBchName; }
public void setTargetBchName(String targetBchName) { this.targetBchName = targetBchName; }
public Integer getTargetBlockNumber() { return targetBlockNumber; }
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
public byte[] getTargetBlockHash() { return targetBlockHash; }
public void setTargetBlockHash(byte[] targetBlockHash) { this.targetBlockHash = targetBlockHash; }
public int getSourceMsgSubType() { return sourceMsgSubType; }
public void setSourceMsgSubType(int sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
public String getSourceText() { return sourceText; }
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
}
@@ -0,0 +1,68 @@
package shine.db.entities;
/**
* UserSettingEntry — одна пользовательская настройка.
*
* Таблица: user_settings
* - login TEXT NOT NULL
* - setting_type INTEGER NOT NULL
* - setting_key TEXT NOT NULL
* - time_ms BIGINT NOT NULL
* - value_text TEXT NOT NULL
* - value_num BIGINT NOT NULL
* - client_key TEXT NOT NULL
* - signature TEXT NOT NULL
* - synced BOOLEAN NOT NULL
*/
public class UserSettingEntry {
private String login;
private int settingType;
private String settingKey;
private long timeMs;
private String valueText;
private long valueNum;
private String clientKey;
private String signature;
private boolean synced;
public UserSettingEntry() {}
public UserSettingEntry(String login, int settingType, String settingKey, long timeMs, String valueText, long valueNum, String clientKey, String signature, boolean synced) {
this.login = login;
this.settingType = settingType;
this.settingKey = settingKey;
this.timeMs = timeMs;
this.valueText = valueText;
this.valueNum = valueNum;
this.clientKey = clientKey;
this.signature = signature;
this.synced = synced;
}
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public int getSettingType() { return settingType; }
public void setSettingType(int settingType) { this.settingType = settingType; }
public String getSettingKey() { return settingKey; }
public void setSettingKey(String settingKey) { this.settingKey = settingKey; }
public long getTimeMs() { return timeMs; }
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
public String getValueText() { return valueText; }
public void setValueText(String valueText) { this.valueText = valueText; }
public long getValueNum() { return valueNum; }
public void setValueNum(long valueNum) { this.valueNum = valueNum; }
public String getClientKey() { return clientKey; }
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
public boolean isSynced() { return synced; }
public void setSynced(boolean synced) { this.synced = synced; }
}
@@ -0,0 +1,40 @@
package shine.db.entities;
public class UserSettingsSyncPeerStateEntry {
private String ownerLogin;
private String remoteServerLogin;
private String remoteServerUrl;
private long cursorTimeMs;
private String cursorSettingKey;
private boolean bootstrapCompleted;
private Long lastSyncAtMs;
private String lastError;
private long updatedAtMs;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public String getRemoteServerLogin() { return remoteServerLogin; }
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
public String getRemoteServerUrl() { return remoteServerUrl; }
public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; }
public long getCursorTimeMs() { return cursorTimeMs; }
public void setCursorTimeMs(long cursorTimeMs) { this.cursorTimeMs = cursorTimeMs; }
public String getCursorSettingKey() { return cursorSettingKey; }
public void setCursorSettingKey(String cursorSettingKey) { this.cursorSettingKey = cursorSettingKey; }
public boolean isBootstrapCompleted() { return bootstrapCompleted; }
public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; }
public Long getLastSyncAtMs() { return lastSyncAtMs; }
public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
}
@@ -12,6 +12,7 @@ public final class CurrentUsersSql {
(
SELECT
current_users.login AS login,
current_users.normalized_login AS normalized_login,
current_users.blockchain_name AS blockchain_name,
current_users.client_key AS solana_key,
current_users.blockchain_key AS blockchain_key,
@@ -0,0 +1,32 @@
BEGIN;
CREATE TABLE IF NOT EXISTS user_notifications_state (
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
created_at_ms BIGINT NOT NULL,
source_login TEXT NOT NULL,
source_bch_name TEXT NOT NULL,
source_block_number INTEGER NOT NULL CHECK (source_block_number >= 0),
source_block_hash BYTEA NOT NULL,
target_login TEXT,
target_bch_name TEXT,
target_block_number INTEGER,
target_block_hash BYTEA,
source_msg_sub_type INTEGER NOT NULL,
source_text TEXT NOT NULL DEFAULT '',
UNIQUE (owner_login, notification_kind, source_bch_name, source_block_number, source_block_hash)
);
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_kind_time
ON user_notifications_state(owner_login, notification_kind, created_at_ms DESC, source_block_number DESC);
CREATE INDEX IF NOT EXISTS idx_user_notifications_state_owner_time
ON user_notifications_state(owner_login, created_at_ms DESC);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 10, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -53,12 +53,16 @@ BEGIN
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(
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) AS access_server(login_value)
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
AND s.is_server = TRUE
@@ -92,12 +96,16 @@ BEGIN
FOR affected_user IN
SELECT u.login
FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text(
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) AS access_server(login_value)
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
@@ -0,0 +1,83 @@
BEGIN;
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS normalized_login TEXT;
UPDATE solana_user_pda_current
SET normalized_login = LOWER(BTRIM(login))
WHERE normalized_login IS NULL
OR normalized_login <> LOWER(BTRIM(login));
ALTER TABLE solana_user_pda_current
ALTER COLUMN normalized_login SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login
ON solana_user_pda_current(normalized_login);
CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login
ON solana_user_pda_current(normalized_login);
WITH bad_signed_messages AS (
SELECT DISTINCT sm.message_key
FROM signed_messages sm
LEFT JOIN solana_user_pda_current u_from
ON u_from.normalized_login = LOWER(BTRIM(sm.from_login))
LEFT JOIN solana_user_pda_current u_to
ON u_to.normalized_login = LOWER(BTRIM(sm.to_login))
WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login)
OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login)
)
DELETE FROM signed_message_session_delivery d
USING bad_signed_messages bad
WHERE d.message_key = bad.message_key;
WITH bad_signed_messages AS (
SELECT DISTINCT sm.message_key
FROM signed_messages sm
LEFT JOIN solana_user_pda_current u_from
ON u_from.normalized_login = LOWER(BTRIM(sm.from_login))
LEFT JOIN solana_user_pda_current u_to
ON u_to.normalized_login = LOWER(BTRIM(sm.to_login))
WHERE (u_from.login IS NOT NULL AND sm.from_login <> u_from.login)
OR (u_to.login IS NOT NULL AND sm.to_login <> u_to.login)
)
DELETE FROM signed_messages sm
USING bad_signed_messages bad
WHERE sm.message_key = bad.message_key;
DELETE FROM signed_direct_messages_history h
USING solana_user_pda_current u_from,
solana_user_pda_current u_to
WHERE u_from.normalized_login = LOWER(BTRIM(h.from_login))
AND u_to.normalized_login = LOWER(BTRIM(h.to_login))
AND (h.from_login <> u_from.login OR h.to_login <> u_to.login);
DELETE FROM signed_direct_message_replay r
USING solana_user_pda_current u_from
WHERE u_from.normalized_login = LOWER(BTRIM(r.from_login))
AND r.from_login <> u_from.login;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = 'direct_messages'
) THEN
DELETE FROM direct_messages d
USING solana_user_pda_current u_from,
solana_user_pda_current u_to
WHERE u_from.normalized_login = LOWER(BTRIM(d.from_login))
AND u_to.normalized_login = LOWER(BTRIM(d.to_login))
AND (d.from_login <> u_from.login OR d.to_login <> u_to.login);
END IF;
END $$;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 5, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,21 @@
BEGIN;
ALTER TABLE signed_messages
DROP CONSTRAINT IF EXISTS signed_messages_from_login_fkey;
ALTER TABLE signed_messages
DROP CONSTRAINT IF EXISTS signed_messages_to_login_fkey;
ALTER TABLE signed_direct_messages_history
DROP CONSTRAINT IF EXISTS signed_direct_messages_history_from_login_fkey;
ALTER TABLE signed_direct_messages_history
DROP CONSTRAINT IF EXISTS signed_direct_messages_history_to_login_fkey;
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 6, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,16 @@
BEGIN;
ALTER TABLE blocks
DROP CONSTRAINT IF EXISTS blocks_login_fkey;
ALTER TABLE blocks
ADD CONSTRAINT blocks_login_fkey
FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 7, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,16 @@
BEGIN;
ALTER TABLE connections_state
DROP CONSTRAINT IF EXISTS connections_state_login_fkey;
ALTER TABLE connections_state
ADD CONSTRAINT connections_state_login_fkey
FOREIGN KEY (login) REFERENCES solana_user_pda_current(normalized_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 8, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -0,0 +1,47 @@
BEGIN;
CREATE TABLE IF NOT EXISTS user_settings (
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
setting_type INTEGER NOT NULL,
setting_key TEXT NOT NULL,
time_ms BIGINT NOT NULL,
value_text TEXT NOT NULL DEFAULT '',
value_num BIGINT NOT NULL DEFAULT 0,
client_key TEXT NOT NULL DEFAULT '',
signature TEXT NOT NULL DEFAULT '',
synced BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (login, setting_type, setting_key)
);
CREATE INDEX IF NOT EXISTS idx_user_settings_login
ON user_settings(login);
CREATE INDEX IF NOT EXISTS idx_user_settings_synced
ON user_settings(login, synced, time_ms);
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
ON user_settings(setting_type, setting_key);
CREATE TABLE IF NOT EXISTS user_settings_sync_peer_state (
owner_login TEXT NOT NULL,
remote_server_login TEXT NOT NULL,
remote_server_url TEXT NOT NULL,
cursor_time_ms BIGINT NOT NULL DEFAULT 0,
cursor_setting_key TEXT NOT NULL DEFAULT '',
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
last_sync_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, remote_server_login)
);
CREATE INDEX IF NOT EXISTS idx_user_settings_sync_peer_state_owner
ON user_settings_sync_peer_state(owner_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 9, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
VALUES (1, 9, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
@@ -74,6 +74,7 @@ CREATE INDEX IF NOT EXISTS idx_sync_tx_history_login
CREATE TABLE IF NOT EXISTS solana_user_pda_current (
pda_address TEXT PRIMARY KEY,
login TEXT NOT NULL UNIQUE,
normalized_login TEXT NOT NULL,
record_number INTEGER NOT NULL,
slot BIGINT NOT NULL,
last_tx_signature TEXT NOT NULL,
@@ -109,6 +110,12 @@ CREATE TABLE IF NOT EXISTS solana_user_pda_current (
CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot
ON solana_user_pda_current(slot);
CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login
ON solana_user_pda_current(normalized_login);
CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login
ON solana_user_pda_current(normalized_login);
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,
@@ -162,12 +169,16 @@ BEGIN
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(
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) AS access_server(login_value)
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
AND s.is_server = TRUE
@@ -201,12 +212,16 @@ BEGIN
FOR affected_user IN
SELECT u.login
FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text(
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) AS access_server(login_value)
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
@@ -386,6 +401,44 @@ CREATE TABLE IF NOT EXISTS users_params (
CREATE INDEX IF NOT EXISTS idx_users_params_login
ON users_params(login);
CREATE TABLE IF NOT EXISTS user_settings (
login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
setting_type INTEGER NOT NULL,
setting_key TEXT NOT NULL,
time_ms BIGINT NOT NULL,
value_text TEXT NOT NULL DEFAULT '',
value_num BIGINT NOT NULL DEFAULT 0,
client_key TEXT NOT NULL DEFAULT '',
signature TEXT NOT NULL DEFAULT '',
synced BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (login, setting_type, setting_key)
);
CREATE INDEX IF NOT EXISTS idx_user_settings_login
ON user_settings(login);
CREATE INDEX IF NOT EXISTS idx_user_settings_synced
ON user_settings(login, synced, time_ms);
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
ON user_settings(setting_type, setting_key);
CREATE TABLE IF NOT EXISTS user_settings_sync_peer_state (
owner_login TEXT NOT NULL,
remote_server_login TEXT NOT NULL,
remote_server_url TEXT NOT NULL,
cursor_time_ms BIGINT NOT NULL DEFAULT 0,
cursor_setting_key TEXT NOT NULL DEFAULT '',
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
last_sync_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, remote_server_login)
);
CREATE INDEX IF NOT EXISTS idx_user_settings_sync_peer_state_owner
ON user_settings_sync_peer_state(owner_login);
CREATE TABLE IF NOT EXISTS ip_geo_cache (
ip TEXT PRIMARY KEY,
geo TEXT,
@@ -432,7 +485,7 @@ 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),
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_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,
@@ -462,7 +515,7 @@ 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),
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
rel_type INTEGER NOT NULL,
to_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL,
@@ -616,8 +669,8 @@ CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created
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),
from_login TEXT NOT NULL,
to_login TEXT NOT NULL,
target_mode INTEGER NOT NULL,
target_session_id TEXT,
message_type INTEGER NOT NULL,
@@ -634,8 +687,8 @@ 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),
from_login TEXT NOT NULL,
to_login TEXT NOT NULL,
time_ms BIGINT NOT NULL,
nonce BIGINT NOT NULL,
message_type INTEGER NOT NULL,
@@ -60,6 +60,12 @@ import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Han
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request;
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_GetUserSetting_Handler;
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_ListUserSettings_Handler;
import server.logic.ws_protocol.JSON.handlers.userSettings.Net_UpsertUserSetting_Handler;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Request;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Request;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request;
// --- NEW: connections friends lists ---
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler;
@@ -82,19 +88,22 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscrip
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_MarkAllUserSettingsUnsynced_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DmSyncBatch_Handler;
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
import server.logic.ws_protocol.JSON.messages.Net_UserSettingsSyncBatch_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendTestWebPush_Handler;
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
@@ -103,11 +112,12 @@ 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_MarkAllUserSettingsUnsynced_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_UserSettingsSyncBatch_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendTestWebPush_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request;
@@ -182,6 +192,11 @@ public final class JsonHandlerRegistry {
Map.entry("GetUserParam", new Net_GetUserParam_Handler()),
Map.entry("ListUserParams", new Net_ListUserParams_Handler()),
// --- userSettings ---
Map.entry("UpsertUserSetting", new Net_UpsertUserSetting_Handler()),
Map.entry("GetUserSetting", new Net_GetUserSetting_Handler()),
Map.entry("ListUserSettings", new Net_ListUserSettings_Handler()),
// --- connections ---
Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()),
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
@@ -194,6 +209,7 @@ public final class JsonHandlerRegistry {
Map.entry("ListContacts", new Net_ListContacts_Handler()),
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
// --- direct messages / push ---
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
@@ -204,6 +220,8 @@ public final class JsonHandlerRegistry {
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()),
Map.entry("UserSettingsSyncBatch", new Net_UserSettingsSyncBatch_Handler()),
Map.entry("MarkAllUserSettingsUnsynced", new Net_MarkAllUserSettingsUnsynced_Handler()),
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
@@ -264,6 +282,11 @@ public final class JsonHandlerRegistry {
Map.entry("GetUserParam", Net_GetUserParam_Request.class),
Map.entry("ListUserParams", Net_ListUserParams_Request.class),
// --- userSettings ---
Map.entry("UpsertUserSetting", Net_UpsertUserSetting_Request.class),
Map.entry("GetUserSetting", Net_GetUserSetting_Request.class),
Map.entry("ListUserSettings", Net_ListUserSettings_Request.class),
// --- connections ---
Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class),
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
@@ -276,6 +299,7 @@ public final class JsonHandlerRegistry {
Map.entry("ListContacts", Net_ListContacts_Request.class),
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
// --- direct messages / push ---
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
@@ -286,6 +310,8 @@ public final class JsonHandlerRegistry {
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class),
Map.entry("UserSettingsSyncBatch", Net_UserSettingsSyncBatch_Request.class),
Map.entry("MarkAllUserSettingsUnsynced", Net_MarkAllUserSettingsUnsynced_Request.class),
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
@@ -27,6 +27,7 @@ public final class SolanaUserPdaImportService {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final String MAGIC = "SHiNE";
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 2;
private SolanaUserPdaImportService() {}
@@ -87,6 +88,7 @@ public final class SolanaUserPdaImportService {
String serverAddress = safe(serverProfile.serverAddress());
if (serverAddress.isBlank()) continue;
routes.putIfAbsent(normalized, new ParsedServerRoute(normalized, serverAddress));
if (routes.size() >= MAX_EFFECTIVE_ACCESS_SERVERS) break;
}
return new ArrayList<>(routes.values());
}
@@ -234,7 +236,13 @@ public final class SolanaUserPdaImportService {
int n = u8(raw, c++);
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
c += n;
accessServers.add(normalizeLogin(accessServerLogin));
String normalizedAccessServerLogin = normalizeLogin(accessServerLogin);
if (normalizedAccessServerLogin == null || accessServers.contains(normalizedAccessServerLogin)) {
continue;
}
if (accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
accessServers.add(normalizedAccessServerLogin);
}
}
} else if (blockType == 50) {
int sessionsMode = u8(raw, c++);
@@ -5,8 +5,10 @@ import blockchain.BchCryptoVerifier;
import blockchain.MsgSubType;
import blockchain.body.BodyHasLine;
import blockchain.body.BodyHasTarget;
import blockchain.body.ConnectionBody;
import blockchain.body.CreateChannelBody;
import blockchain.body.StatusActionBody;
import blockchain.body.TextReplyBody;
import blockchain.body.TextLineBody;
import blockchain.body.UserParamBody;
import org.slf4j.Logger;
@@ -30,10 +32,12 @@ import shine.db.channels.ChannelNameRules;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.dao.ChannelNameStateDAO;
import shine.db.dao.UserNotificationsStateDAO;
import shine.db.dao.UserParamsDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.BlockEntry;
import shine.db.entities.ChannelNameStateEntry;
import shine.db.entities.UserNotificationEntry;
import shine.db.entities.UserParamEntry;
import utils.blockchain.BlockchainNameUtil;
@@ -61,7 +65,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService();
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO);
private final BlockchainWriter dbWriter = new BlockchainWriter(
blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO, UserNotificationsStateDAO.getInstance());
public Net_AddBlock_Handler() {
ChannelNamesStateBootstrapper.bootstrapOrFailFast();
@@ -573,7 +578,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
);
}
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry);
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
dbWriter.appendBlockAndState(
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
if (chat200CreateSeed != null) {
upsertChat200StateFromCreate(chat200CreateSeed);
@@ -855,6 +862,66 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return b;
}
private UserNotificationEntry buildNotificationEntry(BchBlockEntry block, BlockEntry storedEntry) {
if (block == null || storedEntry == null) return null;
if (storedEntry.getToLogin() == null || storedEntry.getToLogin().isBlank()) return null;
int msgType = block.type & 0xFFFF;
int msgSubType = block.subType & 0xFFFF;
long createdAtMs = block.timestamp * 1000L;
String ownerLogin = storedEntry.getToLogin();
String sourceLogin = storedEntry.getLogin();
// A user must never receive a notification for replying to their own message/thread.
if (ownerLogin.equalsIgnoreCase(sourceLogin)) return null;
if (msgType == 1
&& msgSubType == (MsgSubType.TEXT_REPLY & 0xFFFF)
&& block.body instanceof TextReplyBody replyBody) {
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
entry.setNotificationKind("reply");
entry.setSourceText(replyBody.message == null ? "" : replyBody.message);
return entry;
}
// Connection notifications are intentionally modeled as a generic kind.
// Today only CONNECTION_CLOSE_FRIEND is indexed; future incoming connection types
// can reuse the same notification kind and expose their code via sourceMsgSubType.
if (msgType == 3
&& msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
&& block.body instanceof ConnectionBody) {
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
entry.setNotificationKind("connection");
entry.setSourceText("");
return entry;
}
return null;
}
private UserNotificationEntry baseNotificationEntry(BlockEntry storedEntry,
String ownerLogin,
long createdAtMs,
int msgSubType) {
UserNotificationEntry entry = new UserNotificationEntry();
entry.setOwnerLogin(ownerLogin);
entry.setCreatedAtMs(createdAtMs);
entry.setSourceLogin(storedEntry.getLogin());
entry.setSourceBchName(blockchainNameFromEntry(storedEntry));
entry.setSourceBlockNumber(storedEntry.getBlockNumber());
entry.setSourceBlockHash(storedEntry.getBlockHash());
entry.setTargetLogin(storedEntry.getToLogin());
entry.setTargetBchName(storedEntry.getToBchName());
entry.setTargetBlockNumber(storedEntry.getToBlockNumber());
entry.setTargetBlockHash(storedEntry.getToBlockHash());
entry.setSourceMsgSubType(msgSubType);
return entry;
}
private String blockchainNameFromEntry(BlockEntry entry) {
return entry == null ? null : entry.getBchName();
}
private static String toHex(byte[] bytes) {
if (bytes == null) return "null";
char[] HEX = "0123456789abcdef".toCharArray();
@@ -5,10 +5,12 @@ import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.dao.ChannelNameStateDAO;
import shine.db.dao.UserParamsDAO;
import shine.db.dao.UserNotificationsStateDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.BlockEntry;
import shine.db.entities.ChannelNameStateEntry;
import shine.db.entities.UserParamEntry;
import shine.db.entities.UserNotificationEntry;
import utils.files.FileStoreUtil;
import java.io.IOException;
@@ -41,16 +43,19 @@ public final class BlockchainWriter {
private final BlockchainStateDAO stateDAO;
private final ChannelNameStateDAO channelNameStateDAO;
private final UserParamsDAO userParamsDAO;
private final UserNotificationsStateDAO userNotificationsStateDAO;
private final FileStoreUtil fs = FileStoreUtil.getInstance();
public BlockchainWriter(BlocksDAO blocksDAO,
BlockchainStateDAO stateDAO,
UserParamsDAO userParamsDAO,
ChannelNameStateDAO channelNameStateDAO) {
ChannelNameStateDAO channelNameStateDAO,
UserNotificationsStateDAO userNotificationsStateDAO) {
this.blocksDAO = blocksDAO;
this.stateDAO = stateDAO;
this.userParamsDAO = userParamsDAO;
this.channelNameStateDAO = channelNameStateDAO;
this.userNotificationsStateDAO = userNotificationsStateDAO;
}
public void appendBlockAndState(String blockchainName,
@@ -59,7 +64,8 @@ public final class BlockchainWriter {
BlockEntry be,
UserParamEntry userParamEntry,
ChannelNameStateEntry channelNameStateEntry,
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
ChannelNameStateEntry channelMetaUpdateEntry,
UserNotificationEntry notificationEntry) throws SQLException {
long nowMs = System.currentTimeMillis();
byte[] blockBytes = block.toBytes();
@@ -96,6 +102,12 @@ public final class BlockchainWriter {
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
}
// Notification projection is part of the same SQL transaction as the block.
// If notification indexing fails, the block/state write is rolled back as well.
if (notificationEntry != null) {
userNotificationsStateDAO.upsert(c, notificationEntry);
}
c.commit();
committed = true;
} catch (Exception e) {
@@ -6,6 +6,8 @@ import java.util.Map;
public final class ChannelMetaTextParser {
public static final int MAX_TITLE_CHARS = 50;
public static final int MAX_DESCRIPTION_CHARS = 250;
private static final String LEGACY_PREFIX = "<SHiNE:";
private static final String SHORT_PREFIX = "<S:";
private ChannelMetaTextParser() {}
@@ -19,15 +21,16 @@ public final class ChannelMetaTextParser {
boolean seenAvatar = false;
int offset = 0;
while (text.startsWith("<SHiNE:", offset)) {
while (startsWithTagPrefix(text, offset)) {
int end = text.indexOf('>', offset);
if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag");
String body = text.substring(offset + "<SHiNE:".length(), end);
int prefixLength = tagPrefixLength(text, offset);
String body = text.substring(offset + prefixLength, end);
if (body.startsWith("title;")) {
if (seenTitle) throw new IllegalArgumentException("duplicate_channel_meta_title");
seenTitle = true;
title = parseTitleTag(body);
} else if (body.startsWith("avatar;")) {
} else if (body.startsWith("avatar;") || body.startsWith("ava;")) {
if (seenAvatar) throw new IllegalArgumentException("duplicate_channel_meta_avatar");
seenAvatar = true;
Avatar avatar = parseAvatarTag(body);
@@ -61,11 +64,14 @@ public final class ChannelMetaTextParser {
}
private static Avatar parseAvatarTag(String body) {
Map<String, String> fields = parseFields(body.substring("avatar;".length()));
String rawFields = body.startsWith("ava;")
? body.substring("ava;".length())
: body.substring("avatar;".length());
Map<String, String> fields = parseFields(rawFields);
if (!"1".equals(fields.get("v"))) throw new IllegalArgumentException("bad_channel_meta_avatar_version");
String ar = String.valueOf(fields.getOrDefault("ar", "")).trim();
String sha256 = String.valueOf(fields.getOrDefault("sha256", "")).trim().toLowerCase();
String sizeRaw = String.valueOf(fields.getOrDefault("size", "")).trim();
String sizeRaw = String.valueOf(fields.containsKey("sz") ? fields.get("sz") : fields.getOrDefault("size", "")).trim();
if (!ar.matches("^[A-Za-z0-9_-]{43}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_ar");
if (!sha256.matches("^[0-9a-f]{64}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_sha256");
long size;
@@ -78,6 +84,16 @@ public final class ChannelMetaTextParser {
return new Avatar(ar, sha256, size);
}
private static boolean startsWithTagPrefix(String text, int offset) {
return text.startsWith(LEGACY_PREFIX, offset) || text.startsWith(SHORT_PREFIX, offset);
}
private static int tagPrefixLength(String text, int offset) {
if (text.startsWith(LEGACY_PREFIX, offset)) return LEGACY_PREFIX.length();
if (text.startsWith(SHORT_PREFIX, offset)) return SHORT_PREFIX.length();
return 0;
}
private static Map<String, String> parseFields(String raw) {
Map<String, String> out = new HashMap<>();
for (String part : String.valueOf(raw == null ? "" : raw).split(";")) {
@@ -144,6 +144,40 @@ final class ChannelsReadSupport {
}
}
static String userSettingsChannelKey(String ownerBch, String channelName) {
String bch = ownerBch == null ? "" : ownerBch.trim();
String name = channelName == null ? "" : channelName.trim();
return bch + "/" + name;
}
static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException {
if (viewerLogin == null || viewerLogin.isBlank()) return 0;
String key = userSettingsChannelKey(ownerBch, channelName);
String sql = """
SELECT value_num
FROM user_settings
WHERE LOWER(login) = LOWER(?)
AND setting_type = ?
AND setting_key = ?
LIMIT 1
""";
long lastSeen = messagesCount;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setInt(2, 1);
ps.setString(3, key);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
long value = rs.getLong("value_num");
if (!rs.wasNull()) lastSeen = value;
}
}
}
if (lastSeen < 0) lastSeen = 0;
if (lastSeen > messagesCount) return 0;
return Math.max(0, messagesCount - (int) lastSeen);
}
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
@@ -82,7 +82,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
row.setChannel(channelRef);
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
row.setUnreadCount(0);
row.setUnreadCount(ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
if (lastPost != null) {
@@ -0,0 +1,94 @@
package server.logic.ws_protocol.JSON.handlers.notifications;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
import shine.db.dao.UserNotificationsStateDAO;
import shine.db.entities.UserNotificationEntry;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.UNVERIFIED,
"NOT_AUTHENTICATED",
"Операция доступна только для авторизованных пользователей"
);
}
String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim();
if (login.isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя");
}
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit()));
try (Connection c = DbController.getInstance().getConnection()) {
List<UserNotificationEntry> replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit);
List<UserNotificationEntry> eventRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "connection", limit);
Net_GetNotifications_Response resp = new Net_GetNotifications_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(login);
resp.setReplies(mapRows(replyRows));
resp.setEvents(mapRows(eventRows));
return resp;
} catch (Exception e) {
log.error("GetNotifications failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
private List<Net_GetNotifications_Response.NotificationItem> mapRows(List<UserNotificationEntry> rows) {
List<Net_GetNotifications_Response.NotificationItem> out = new ArrayList<>();
for (UserNotificationEntry row : rows) {
Net_GetNotifications_Response.NotificationItem item = new Net_GetNotifications_Response.NotificationItem();
item.setKind(row.getNotificationKind());
item.setCreatedAtMs(row.getCreatedAtMs());
item.setSourceLogin(row.getSourceLogin());
item.setSourceBlockchainName(row.getSourceBchName());
item.setSourceBlockNumber(row.getSourceBlockNumber());
item.setSourceBlockHash(bytesToHex(row.getSourceBlockHash()));
item.setSourceMsgSubType(row.getSourceMsgSubType());
item.setConnectionTypeCode("connection".equals(row.getNotificationKind()) ? row.getSourceMsgSubType() : null);
item.setSourceText(row.getSourceText());
item.setTargetLogin(row.getTargetLogin());
item.setTargetBlockchainName(row.getTargetBchName());
item.setTargetBlockNumber(row.getTargetBlockNumber());
item.setTargetBlockHash(bytesToHex(row.getTargetBlockHash()));
out.add(item);
}
return out;
}
private static String bytesToHex(byte[] bytes) {
if (bytes == null) return null;
char[] HEX = "0123456789abcdef".toCharArray();
char[] out = new char[bytes.length * 2];
for (int i = 0; i < bytes.length; i++) {
int v = bytes[i] & 0xff;
out[i * 2] = HEX[v >>> 4];
out[i * 2 + 1] = HEX[v & 0x0f];
}
return new String(out);
}
}
@@ -0,0 +1,10 @@
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetNotifications_Request extends Net_Request {
private Integer limit;
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
}
@@ -0,0 +1,62 @@
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public class Net_GetNotifications_Response extends Net_Response {
private String login;
private List<NotificationItem> replies = new ArrayList<>();
private List<NotificationItem> events = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<NotificationItem> getReplies() { return replies; }
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
public List<NotificationItem> getEvents() { return events; }
public void setEvents(List<NotificationItem> events) { this.events = events; }
public static class NotificationItem {
private String kind;
private long createdAtMs;
private String sourceLogin;
private String sourceBlockchainName;
private int sourceBlockNumber;
private String sourceBlockHash;
private Integer sourceMsgSubType;
private Integer connectionTypeCode;
private String sourceText;
private String targetLogin;
private String targetBlockchainName;
private Integer targetBlockNumber;
private String targetBlockHash;
public String getKind() { return kind; }
public void setKind(String kind) { this.kind = kind; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
public String getSourceLogin() { return sourceLogin; }
public void setSourceLogin(String sourceLogin) { this.sourceLogin = sourceLogin; }
public String getSourceBlockchainName() { return sourceBlockchainName; }
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
public int getSourceBlockNumber() { return sourceBlockNumber; }
public void setSourceBlockNumber(int sourceBlockNumber) { this.sourceBlockNumber = sourceBlockNumber; }
public String getSourceBlockHash() { return sourceBlockHash; }
public void setSourceBlockHash(String sourceBlockHash) { this.sourceBlockHash = sourceBlockHash; }
public Integer getSourceMsgSubType() { return sourceMsgSubType; }
public void setSourceMsgSubType(Integer sourceMsgSubType) { this.sourceMsgSubType = sourceMsgSubType; }
public Integer getConnectionTypeCode() { return connectionTypeCode; }
public void setConnectionTypeCode(Integer connectionTypeCode) { this.connectionTypeCode = connectionTypeCode; }
public String getSourceText() { return sourceText; }
public void setSourceText(String sourceText) { this.sourceText = sourceText; }
public String getTargetLogin() { return targetLogin; }
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
public String getTargetBlockchainName() { return targetBlockchainName; }
public void setTargetBlockchainName(String targetBlockchainName) { this.targetBlockchainName = targetBlockchainName; }
public Integer getTargetBlockNumber() { return targetBlockNumber; }
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
public String getTargetBlockHash() { return targetBlockHash; }
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; }
}
}
@@ -0,0 +1,64 @@
package server.logic.ws_protocol.JSON.handlers.userSettings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Request;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_GetUserSetting_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
import shine.db.dao.CurrentUsersDAO;
import shine.db.dao.UserSettingsDAO;
import shine.db.entities.CurrentUserEntry;
import shine.db.entities.UserSettingEntry;
import java.sql.Connection;
public class Net_GetUserSetting_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetUserSetting_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetUserSetting_Request req = (Net_GetUserSetting_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()
|| req.getSetting_type() == null
|| req.getSetting_key() == null || req.getSetting_key().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Некорректные поля: login/setting_type/setting_key");
}
try (Connection c = DbController.getInstance().getConnection()) {
String login = CurrentUsersDAO.getInstance().getByLogin(c, req.getLogin().trim()) != null
? req.getLogin().trim()
: null;
if (login == null) {
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
}
UserSettingEntry entry = UserSettingsDAO.getInstance().getByLoginTypeKey(c, login, req.getSetting_type(), req.getSetting_key().trim());
if (entry == null) {
return NetExceptionResponseFactory.error(req, 404, "SETTING_NOT_FOUND", "Настройка не найдена");
}
Net_GetUserSetting_Response resp = new Net_GetUserSetting_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(entry.getLogin());
resp.setSetting_type(entry.getSettingType());
resp.setSetting_key(entry.getSettingKey());
resp.setTime_ms(entry.getTimeMs());
resp.setValue_text(entry.getValueText());
resp.setValue_num(entry.getValueNum());
resp.setClient_key(entry.getClientKey());
resp.setSignature(entry.getSignature());
resp.setSynced(entry.isSynced());
return resp;
} catch (Exception e) {
log.error("GetUserSetting failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
}
}
}
@@ -0,0 +1,68 @@
package server.logic.ws_protocol.JSON.handlers.userSettings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Request;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_ListUserSettings_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
import shine.db.dao.CurrentUsersDAO;
import shine.db.dao.UserSettingsDAO;
import shine.db.entities.CurrentUserEntry;
import shine.db.entities.UserSettingEntry;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
public class Net_ListUserSettings_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_ListUserSettings_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_ListUserSettings_Request req = (Net_ListUserSettings_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Некорректные поля: login");
}
try (Connection c = DbController.getInstance().getConnection()) {
String login = req.getLogin().trim();
CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(c, login);
if (user == null) {
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
}
List<UserSettingEntry> entries = UserSettingsDAO.getInstance().getByLogin(c, login);
List<Net_ListUserSettings_Response.Item> items = new ArrayList<>();
for (UserSettingEntry e : entries) {
Net_ListUserSettings_Response.Item item = new Net_ListUserSettings_Response.Item();
item.setLogin(e.getLogin());
item.setSetting_type(e.getSettingType());
item.setSetting_key(e.getSettingKey());
item.setTime_ms(e.getTimeMs());
item.setValue_text(e.getValueText());
item.setValue_num(e.getValueNum());
item.setClient_key(e.getClientKey());
item.setSignature(e.getSignature());
item.setSynced(e.isSynced());
items.add(item);
}
Net_ListUserSettings_Response resp = new Net_ListUserSettings_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(user.getLogin());
resp.setSettings(items);
return resp;
} catch (Exception e) {
log.error("ListUserSettings failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
}
}
}
@@ -0,0 +1,157 @@
package server.logic.ws_protocol.JSON.handlers.userSettings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.Base64Ws;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import server.sync.RemoteUserSettingsSyncClient;
import shine.db.DbController;
import shine.db.dao.CurrentUsersDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.dao.UserSettingsDAO;
import shine.db.entities.CurrentUserEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import shine.db.entities.UserSettingEntry;
import utils.config.ShineSignatureConstants;
import utils.config.AppConfig;
import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.List;
public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_UpsertUserSetting_Handler.class);
private static final RemoteUserSettingsSyncClient REMOTE = new RemoteUserSettingsSyncClient();
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_UpsertUserSetting_Request req = (Net_UpsertUserSetting_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()
|| req.getSetting_type() == null
|| req.getSetting_key() == null || req.getSetting_key().isBlank()
|| req.getTime_ms() == null || req.getTime_ms() <= 0
|| req.getClient_key() == null || req.getClient_key().isBlank()
|| req.getSignature() == null || req.getSignature().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS",
"Некорректные поля: login/setting_type/setting_key/time_ms/client_key/signature");
}
String login = req.getLogin().trim();
int settingType = req.getSetting_type();
String settingKey = req.getSetting_key().trim();
long timeMs = req.getTime_ms();
String valueText = req.getValue_text() == null ? "" : req.getValue_text();
long valueNum = req.getValue_num() == null ? 0L : req.getValue_num();
String clientKeyB64 = req.getClient_key().trim();
String signatureB64 = req.getSignature().trim();
boolean syncDelivery = Boolean.TRUE.equals(req.getSync_delivery());
try {
byte[] pubKey32;
byte[] sig64;
try {
pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key");
sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature");
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_BASE64", "client_key/signature должны быть Base64");
}
String signText = ShineSignatureConstants.USER_SETTINGS_PREFIX
+ escapePart(login) + '|'
+ settingType + '|'
+ escapePart(settingKey) + '|'
+ timeMs + '|'
+ escapePart(valueText) + '|'
+ valueNum;
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
return NetExceptionResponseFactory.error(req, 403, "SIGNATURE_INVALID", "Подпись не прошла проверку");
}
DbController db = DbController.getInstance();
CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance();
UserSettingsDAO settingsDAO = UserSettingsDAO.getInstance();
try (Connection c = db.getConnection()) {
CurrentUserEntry user = usersDAO.getByLogin(c, login);
if (user == null) {
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
}
String userClientKey = user.getClientKey();
if (userClientKey == null || userClientKey.isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "USER_DEVICE_KEY_EMPTY", "У пользователя не задан clientKey в БД");
}
if (!userClientKey.trim().equals(clientKeyB64)) {
return NetExceptionResponseFactory.error(req, 403, "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю");
}
UserSettingEntry entry = new UserSettingEntry(
login,
settingType,
settingKey,
timeMs,
valueText,
valueNum,
clientKeyB64,
signatureB64,
syncDelivery
);
int changed = settingsDAO.upsertIfNewer(c, entry);
if (!syncDelivery && changed > 0) {
int delivered = 0;
String ownServerLogin = String.valueOf(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG) == null ? "" : AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)).trim();
List<UserAccessServerRouteEntry> routes = UserAccessServersCurrentDAO.getInstance().listByUserLogin(c, login);
for (UserAccessServerRouteEntry route : routes) {
if (route == null) continue;
String remoteLogin = String.valueOf(route.getServerLogin() == null ? "" : route.getServerLogin()).trim();
String remoteUrl = String.valueOf(route.getServerUrl() == null ? "" : route.getServerUrl()).trim();
if (remoteLogin.isBlank() || remoteUrl.isBlank()) continue;
if (!ownServerLogin.isBlank() && remoteLogin.equalsIgnoreCase(ownServerLogin)) continue;
try {
REMOTE.upsertUserSetting(remoteUrl, entry, true);
delivered++;
} catch (Exception e) {
log.warn("user_settings immediate sync failed: login={} remoteServer={} reason={}", login, remoteLogin, String.valueOf(e));
}
}
if (delivered > 0 || routes.isEmpty()) {
settingsDAO.markSynced(c, login, settingType, settingKey);
}
}
Net_UpsertUserSetting_Response resp = new Net_UpsertUserSetting_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(login);
resp.setSetting_type(settingType);
resp.setSetting_key(settingKey);
resp.setTime_ms(timeMs);
resp.setSynced(syncDelivery || changed == 0);
return resp;
}
} catch (SQLException e) {
log.error("UpsertUserSetting DB error", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "DB_ERROR", "Ошибка БД");
} catch (Exception e) {
log.error("UpsertUserSetting failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при UpsertUserSetting", e));
}
}
private static String escapePart(String value) {
String s = String.valueOf(value == null ? "" : value);
return s.replace("\\", "\\\\").replace("|", "\\|");
}
}
@@ -0,0 +1,18 @@
package server.logic.ws_protocol.JSON.handlers.userSettings.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetUserSetting_Request extends Net_Request {
private String login;
private Integer setting_type;
private String setting_key;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public Integer getSetting_type() { return setting_type; }
public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; }
public String getSetting_key() { return setting_key; }
public void setSetting_key(String setting_key) { this.setting_key = setting_key; }
}
@@ -0,0 +1,42 @@
package server.logic.ws_protocol.JSON.handlers.userSettings.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_GetUserSetting_Response extends Net_Response {
private String login;
private Integer setting_type;
private String setting_key;
private Long time_ms;
private String value_text;
private Long value_num;
private String client_key;
private String signature;
private Boolean synced;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public Integer getSetting_type() { return setting_type; }
public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; }
public String getSetting_key() { return setting_key; }
public void setSetting_key(String setting_key) { this.setting_key = setting_key; }
public Long getTime_ms() { return time_ms; }
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
public String getValue_text() { return value_text; }
public void setValue_text(String value_text) { this.value_text = value_text; }
public Long getValue_num() { return value_num; }
public void setValue_num(Long value_num) { this.value_num = value_num; }
public String getClient_key() { return client_key; }
public void setClient_key(String client_key) { this.client_key = client_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
public Boolean getSynced() { return synced; }
public void setSynced(Boolean synced) { this.synced = synced; }
}
@@ -0,0 +1,10 @@
package server.logic.ws_protocol.JSON.handlers.userSettings.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_ListUserSettings_Request extends Net_Request {
private String login;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
}
@@ -0,0 +1,48 @@
package server.logic.ws_protocol.JSON.handlers.userSettings.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public class Net_ListUserSettings_Response extends Net_Response {
private String login;
private List<Item> settings = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<Item> getSettings() { return settings; }
public void setSettings(List<Item> settings) { this.settings = settings; }
public static class Item {
private String login;
private Integer setting_type;
private String setting_key;
private Long time_ms;
private String value_text;
private Long value_num;
private String client_key;
private String signature;
private Boolean synced;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public Integer getSetting_type() { return setting_type; }
public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; }
public String getSetting_key() { return setting_key; }
public void setSetting_key(String setting_key) { this.setting_key = setting_key; }
public Long getTime_ms() { return time_ms; }
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
public String getValue_text() { return value_text; }
public void setValue_text(String value_text) { this.value_text = value_text; }
public Long getValue_num() { return value_num; }
public void setValue_num(Long value_num) { this.value_num = value_num; }
public String getClient_key() { return client_key; }
public void setClient_key(String client_key) { this.client_key = client_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
public Boolean getSynced() { return synced; }
public void setSynced(Boolean synced) { this.synced = synced; }
}
}
@@ -0,0 +1,42 @@
package server.logic.ws_protocol.JSON.handlers.userSettings.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_UpsertUserSetting_Request extends Net_Request {
private String login;
private Integer setting_type;
private String setting_key;
private Long time_ms;
private String value_text;
private Long value_num;
private String client_key;
private String signature;
private Boolean sync_delivery;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public Integer getSetting_type() { return setting_type; }
public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; }
public String getSetting_key() { return setting_key; }
public void setSetting_key(String setting_key) { this.setting_key = setting_key; }
public Long getTime_ms() { return time_ms; }
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
public String getValue_text() { return value_text; }
public void setValue_text(String value_text) { this.value_text = value_text; }
public Long getValue_num() { return value_num; }
public void setValue_num(Long value_num) { this.value_num = value_num; }
public String getClient_key() { return client_key; }
public void setClient_key(String client_key) { this.client_key = client_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
public Boolean getSync_delivery() { return sync_delivery; }
public void setSync_delivery(Boolean sync_delivery) { this.sync_delivery = sync_delivery; }
}
@@ -0,0 +1,26 @@
package server.logic.ws_protocol.JSON.handlers.userSettings.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_UpsertUserSetting_Response extends Net_Response {
private String login;
private Integer setting_type;
private String setting_key;
private Long time_ms;
private Boolean synced;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public Integer getSetting_type() { return setting_type; }
public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; }
public String getSetting_key() { return setting_key; }
public void setSetting_key(String setting_key) { this.setting_key = setting_key; }
public Long getTime_ms() { return time_ms; }
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
public Boolean getSynced() { return synced; }
public void setSynced(Boolean synced) { this.synced = synced; }
}
@@ -0,0 +1,42 @@
package server.logic.ws_protocol.JSON.messages;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_MarkAllUserSettingsUnsynced_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_MarkAllUserSettingsUnsynced_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
import shine.db.dao.UserSettingsDAO;
import java.sql.Connection;
public class Net_MarkAllUserSettingsUnsynced_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_MarkAllUserSettingsUnsynced_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_MarkAllUserSettingsUnsynced_Request req = (Net_MarkAllUserSettingsUnsynced_Request) baseRequest;
try (Connection c = DbController.getInstance().getConnection()) {
int updated;
if (req.getLogin() == null || req.getLogin().isBlank()) {
updated = UserSettingsDAO.getInstance().markAllUnsynced(c);
} else {
updated = UserSettingsDAO.getInstance().markAllUnsynced(c, req.getLogin().trim());
}
Net_MarkAllUserSettingsUnsynced_Response resp = new Net_MarkAllUserSettingsUnsynced_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setUpdated(updated);
return resp;
} catch (Exception e) {
log.error("MarkAllUserSettingsUnsynced failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
}
}
}
@@ -1,208 +0,0 @@
package server.logic.ws_protocol.JSON.messages;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Response;
import server.logic.ws_protocol.JSON.push.WebPushSender;
import server.logic.ws_protocol.JSON.push.WsEventSender;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.ActiveSessionsDAO;
import shine.db.dao.DirectMessagesDAO;
import shine.db.dao.SignedDirectMessagesHistoryDAO;
import shine.db.dao.SignedDmReplayDAO;
import shine.db.dao.CurrentUsersDAO;
import shine.db.entities.ActiveSessionEntry;
import shine.db.entities.DirectMessageEntry;
import shine.db.entities.SignedDirectMessageHistoryEntry;
import shine.db.entities.CurrentUserEntry;
import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public class Net_SendDirectMessage_Handler implements JsonMessageHandler {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final long REPLAY_TTL_MS = 15L * 60L * 1000L;
private static final int MAX_MESSAGE_BYTES = 3000;
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_SendDirectMessage_Request req = (Net_SendDirectMessage_Request) baseRequest;
if (req.getBlobB64() == null || req.getBlobB64().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "blobB64 обязателен");
}
final byte[] raw;
final SignedDirectMessagePacket packet;
try {
raw = Base64.getDecoder().decode(req.getBlobB64().trim());
packet = SignedDirectMessagePacket.parse(raw, MAX_MESSAGE_BYTES);
} catch (IllegalArgumentException ex) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат пакета");
}
CurrentUserEntry fromUser = CurrentUsersDAO.getInstance().getByLogin(packet.fromLogin);
CurrentUserEntry toUser = CurrentUsersDAO.getInstance().getByLogin(packet.toLogin);
if (fromUser == null || toUser == null) {
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "from/to пользователь не найден");
}
byte[] publicKey32;
try {
publicKey32 = Ed25519Util.keyFromBase64(fromUser.getClientKey());
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_DEVICE_KEY", "Некорректный clientKey отправителя");
}
if (!Ed25519Util.verify(packet.signedBody, packet.signature64, publicKey32)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_SIGNATURE", "Подпись не прошла проверку");
}
long now = System.currentTimeMillis();
if (Math.abs(now - packet.timeMs) > REPLAY_TTL_MS) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_TIME_WINDOW", "Время сообщения вышло за окно 15 минут");
}
boolean replayOk = SignedDmReplayDAO.getInstance().registerUnique(packet.fromLogin, packet.timeMs, packet.nonce, now);
if (!replayOk) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "REPLAY", "Повторное сообщение заблокировано");
}
String messageId = NetIdGenerator.eventId("msg");
String textForUi = new String(packet.messageBytes, StandardCharsets.UTF_8);
DirectMessageEntry entry = new DirectMessageEntry();
entry.setMessageId(messageId);
entry.setFromLogin(packet.fromLogin);
entry.setToLogin(packet.toLogin);
entry.setText(textForUi);
entry.setCreatedAtMs(now);
DirectMessagesDAO.getInstance().insert(entry);
SignedDirectMessageHistoryEntry history = new SignedDirectMessageHistoryEntry();
history.setMessageId(messageId);
history.setFromLogin(packet.fromLogin);
history.setToLogin(packet.toLogin);
history.setTargetMode(packet.targetMode);
history.setTargetSessionId(packet.targetSessionId);
history.setMessageType(packet.messageType);
history.setTimeMs(packet.timeMs);
history.setNonce(packet.nonce);
history.setRawPacket(packet.rawPacket);
history.setCreatedAtMs(now);
SignedDirectMessagesHistoryDAO.getInstance().insert(history);
DeliveryResult delivery = deliver(packet, req.getBlobB64().trim(), messageId, now);
Net_SendDirectMessage_Response resp = new Net_SendDirectMessage_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setMessageId(messageId);
resp.setDeliveredWsSessions(delivery.wsDelivered);
resp.setDeliveredWebPushSessions(delivery.webPushDelivered);
resp.setSessionNotFound(delivery.sessionNotFound);
return resp;
}
private DeliveryResult deliver(SignedDirectMessagePacket packet, String blobB64, String messageId, long createdAtMs) throws Exception {
DeliveryResult result = new DeliveryResult();
Set<String> selectedSessionIds = new HashSet<>();
if (packet.targetMode == SignedDirectMessagePacket.TARGET_ONE_SESSION) {
ActiveSessionEntry byId = ActiveSessionsDAO.getInstance().getBySessionId(packet.targetSessionId);
if (byId == null || !packet.toLogin.equalsIgnoreCase(byId.getLogin())) {
result.sessionNotFound = true;
return result;
}
selectedSessionIds.add(byId.getSessionId());
deliverToSession(packet, blobB64, messageId, createdAtMs, byId.getSessionId(), result);
return result;
}
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(packet.toLogin);
for (ActiveSessionEntry s : sessions) {
selectedSessionIds.add(s.getSessionId());
deliverToSession(packet, blobB64, messageId, createdAtMs, s.getSessionId(), result);
}
return result;
}
private void deliverToSession(
SignedDirectMessagePacket packet,
String blobB64,
String messageId,
long createdAtMs,
String sessionId,
DeliveryResult result
) {
ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId);
boolean wsDelivered = false;
if (targetCtx != null) {
String eventId = NetIdGenerator.eventId("evt");
CompletableFuture<Boolean> waiter = DeliveryTracker.getInstance().register(eventId);
ObjectNode payload = MAPPER.createObjectNode();
payload.put("eventId", eventId);
payload.put("messageId", messageId);
payload.put("fromLogin", packet.fromLogin);
payload.put("toLogin", packet.toLogin);
payload.put("blobB64", blobB64);
payload.put("text", new String(packet.messageBytes, StandardCharsets.UTF_8));
payload.put("timeMs", createdAtMs);
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingDirectMessage", eventId, payload);
if (sent) {
try {
wsDelivered = waiter.get(1200, TimeUnit.MILLISECONDS);
} catch (Exception ignored) {
wsDelivered = false;
}
}
DeliveryTracker.getInstance().remove(eventId);
}
if (wsDelivered) {
result.wsDelivered++;
return;
}
try {
ActiveSessionEntry targetSession = ActiveSessionsDAO.getInstance().getBySessionId(sessionId);
if (targetSession == null) return;
if (isBlank(targetSession.getPushEndpoint()) || isBlank(targetSession.getPushP256dhKey()) || isBlank(targetSession.getPushAuthKey())) {
return;
}
boolean pushed = WebPushSender.sendBase64Payload(
targetSession.getPushEndpoint(),
targetSession.getPushP256dhKey(),
targetSession.getPushAuthKey(),
blobB64
);
if (pushed) result.webPushDelivered++;
} catch (Exception ignored) {
// ignore per-session push errors
}
}
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
private static final class DeliveryResult {
int wsDelivered;
int webPushDelivered;
boolean sessionNotFound;
}
}
@@ -0,0 +1,129 @@
package server.logic.ws_protocol.JSON.messages;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_UserSettingsSyncBatch_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_UserSettingsSyncBatch_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.dao.UserSettingsDAO;
import shine.db.entities.UserSettingEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
public class Net_UserSettingsSyncBatch_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_UserSettingsSyncBatch_Handler.class);
private static final int DEFAULT_LIMIT = 500;
private static final int MAX_LIMIT = 1000;
private static final int DEFAULT_MAX_BYTES = 3_000_000;
private static final int MAX_BYTES_CAP = 5_000_000;
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_UserSettingsSyncBatch_Request req = (Net_UserSettingsSyncBatch_Request) baseRequest;
String ownerLogin = normalizeOriginal(req.getOwnerLogin());
if (ownerLogin == null) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_OWNER_LOGIN", "ownerLogin обязателен");
}
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
if (ownServerLogin == null) {
return NetExceptionResponseFactory.error(req, 500, "LOCAL_SERVER_NOT_CONFIGURED", "server.SHiNE.login не настроен");
}
if (!isLocalAccessServer(ownerLogin, ownServerLogin)) {
return NetExceptionResponseFactory.error(req, 403, "LOCAL_SERVER_NOT_ACCESS_SERVER", "Локальный сервер не является access-сервером пользователя");
}
int limit = clamp(req.getLimit() == null ? DEFAULT_LIMIT : req.getLimit(), 1, MAX_LIMIT);
int maxBytes = clamp(req.getMaxBytes() == null ? DEFAULT_MAX_BYTES : req.getMaxBytes(), 64_000, MAX_BYTES_CAP);
long afterTimeMs = Math.max(0L, req.getAfterTimeMs() == null ? 0L : req.getAfterTimeMs());
String afterSettingKey = req.getAfterSettingKey() == null ? "" : req.getAfterSettingKey().trim();
List<UserSettingEntry> batch;
try (Connection c = DbController.getInstance().getConnection()) {
batch = UserSettingsDAO.getInstance().listNewerThan(
c,
ownerLogin,
afterTimeMs,
afterSettingKey,
limit
);
}
Net_UserSettingsSyncBatch_Response resp = new Net_UserSettingsSyncBatch_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setOwnerLogin(ownerLogin);
resp.setLimit(limit);
int rawBytes = 0;
List<Net_UserSettingsSyncBatch_Response.Item> items = new ArrayList<>();
for (UserSettingEntry entry : batch) {
Net_UserSettingsSyncBatch_Response.Item item = new Net_UserSettingsSyncBatch_Response.Item();
item.setLogin(entry.getLogin());
item.setSetting_type(entry.getSettingType());
item.setSetting_key(entry.getSettingKey());
item.setTime_ms(entry.getTimeMs());
item.setValue_text(entry.getValueText());
item.setValue_num(entry.getValueNum());
item.setClient_key(entry.getClientKey());
item.setSignature(entry.getSignature());
item.setSynced(entry.isSynced());
items.add(item);
rawBytes += String.valueOf(entry.getLogin()).length()
+ String.valueOf(entry.getSettingKey()).length()
+ String.valueOf(entry.getValueText()).length()
+ String.valueOf(entry.getClientKey() == null ? "" : entry.getClientKey()).length()
+ String.valueOf(entry.getSignature() == null ? "" : entry.getSignature()).length()
+ 64;
if (rawBytes > maxBytes) break;
resp.setNextTimeMs(entry.getTimeMs());
resp.setNextSettingKey(entry.getSettingKey());
}
resp.setRawBytes(rawBytes);
resp.setHasMore(batch.size() > items.size());
resp.setItems(items);
return resp;
}
private boolean isLocalAccessServer(String ownerLogin, String ownServerLogin) throws Exception {
try (Connection c = DbController.getInstance().getConnection()) {
for (UserAccessServerRouteEntry route : UserAccessServersCurrentDAO.getInstance().listByUserLogin(c, ownerLogin)) {
if (route == null || route.getServerLogin() == null) continue;
if (ownServerLogin.equals(normalize(route.getServerLogin()))) {
return true;
}
}
}
return false;
}
private static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
private static String normalize(String value) {
if (value == null) return null;
String s = value.trim().toLowerCase();
return s.isEmpty() ? null : s;
}
private static String normalizeOriginal(String value) {
if (value == null) return null;
String s = value.trim();
return s.isEmpty() ? null : s;
}
}
@@ -0,0 +1,61 @@
package server.logic.ws_protocol.JSON.messages;
import server.logic.ws_protocol.JSON.messages.entyties.Net_UserSettingsSyncBatch_Response;
import shine.db.dao.UserSettingsDAO;
import shine.db.entities.UserSettingEntry;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
public final class UserSettingsSyncApplySupport {
private UserSettingsSyncApplySupport() {}
public static ApplyResult applySyncedItem(Connection c, String ownerLogin, Net_UserSettingsSyncBatch_Response.Item item) throws Exception {
if (item == null) return new ApplyResult(false, "empty_item");
String login = normalize(item.getLogin());
String key = normalize(item.getSetting_key());
if (login == null || key == null) return new ApplyResult(false, "bad_item");
UserSettingEntry entry = new UserSettingEntry(
login,
item.getSetting_type() == null ? 0 : item.getSetting_type(),
key,
item.getTime_ms() == null ? 0L : item.getTime_ms(),
item.getValue_text() == null ? "" : item.getValue_text(),
item.getValue_num() == null ? 0L : item.getValue_num(),
item.getClient_key(),
item.getSignature(),
true
);
int changed = UserSettingsDAO.getInstance().upsertIfNewer(c, entry);
return new ApplyResult(changed > 0, changed > 0 ? "applied" : "ignored");
}
public static List<UserSettingEntry> toEntries(List<Net_UserSettingsSyncBatch_Response.Item> items) {
List<UserSettingEntry> out = new ArrayList<>();
if (items == null) return out;
for (Net_UserSettingsSyncBatch_Response.Item item : items) {
if (item == null) continue;
out.add(new UserSettingEntry(
normalize(item.getLogin()),
item.getSetting_type() == null ? 0 : item.getSetting_type(),
normalize(item.getSetting_key()),
item.getTime_ms() == null ? 0L : item.getTime_ms(),
item.getValue_text() == null ? "" : item.getValue_text(),
item.getValue_num() == null ? 0L : item.getValue_num(),
item.getClient_key(),
item.getSignature(),
true
));
}
return out;
}
private static String normalize(String value) {
String s = String.valueOf(value == null ? "" : value).trim();
return s.isEmpty() ? null : s;
}
public record ApplyResult(boolean applied, String status) {}
}
@@ -0,0 +1,10 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_MarkAllUserSettingsUnsynced_Request extends Net_Request {
private String login;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
}
@@ -0,0 +1,10 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_MarkAllUserSettingsUnsynced_Response extends Net_Response {
private Integer updated;
public Integer getUpdated() { return updated; }
public void setUpdated(Integer updated) { this.updated = updated; }
}
@@ -1,10 +0,0 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_SendDirectMessage_Request extends Net_Request {
private String blobB64;
public String getBlobB64() { return blobB64; }
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
}
@@ -1,19 +0,0 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_SendDirectMessage_Response extends Net_Response {
private String messageId;
private int deliveredWsSessions;
private int deliveredWebPushSessions;
private boolean sessionNotFound;
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public int getDeliveredWsSessions() { return deliveredWsSessions; }
public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
public boolean isSessionNotFound() { return sessionNotFound; }
public void setSessionNotFound(boolean sessionNotFound) { this.sessionNotFound = sessionNotFound; }
}
@@ -0,0 +1,26 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_UserSettingsSyncBatch_Request extends Net_Request {
private String ownerLogin;
private Long afterTimeMs;
private String afterSettingKey;
private Integer limit;
private Integer maxBytes;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public Long getAfterTimeMs() { return afterTimeMs; }
public void setAfterTimeMs(Long afterTimeMs) { this.afterTimeMs = afterTimeMs; }
public String getAfterSettingKey() { return afterSettingKey; }
public void setAfterSettingKey(String afterSettingKey) { this.afterSettingKey = afterSettingKey; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public Integer getMaxBytes() { return maxBytes; }
public void setMaxBytes(Integer maxBytes) { this.maxBytes = maxBytes; }
}
@@ -0,0 +1,62 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public class Net_UserSettingsSyncBatch_Response extends Net_Response {
private String ownerLogin;
private Integer limit;
private Integer rawBytes;
private Boolean hasMore;
private Long nextTimeMs;
private String nextSettingKey;
private List<Item> items = new ArrayList<>();
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public Integer getRawBytes() { return rawBytes; }
public void setRawBytes(Integer rawBytes) { this.rawBytes = rawBytes; }
public Boolean getHasMore() { return hasMore; }
public void setHasMore(Boolean hasMore) { this.hasMore = hasMore; }
public Long getNextTimeMs() { return nextTimeMs; }
public void setNextTimeMs(Long nextTimeMs) { this.nextTimeMs = nextTimeMs; }
public String getNextSettingKey() { return nextSettingKey; }
public void setNextSettingKey(String nextSettingKey) { this.nextSettingKey = nextSettingKey; }
public List<Item> getItems() { return items; }
public void setItems(List<Item> items) { this.items = items; }
public static class Item {
private String login;
private Integer setting_type;
private String setting_key;
private Long time_ms;
private String value_text;
private Long value_num;
private String client_key;
private String signature;
private Boolean synced;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public Integer getSetting_type() { return setting_type; }
public void setSetting_type(Integer setting_type) { this.setting_type = setting_type; }
public String getSetting_key() { return setting_key; }
public void setSetting_key(String setting_key) { this.setting_key = setting_key; }
public Long getTime_ms() { return time_ms; }
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
public String getValue_text() { return value_text; }
public void setValue_text(String value_text) { this.value_text = value_text; }
public Long getValue_num() { return value_num; }
public void setValue_num(Long value_num) { this.value_num = value_num; }
public String getClient_key() { return client_key; }
public void setClient_key(String client_key) { this.client_key = client_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
public Boolean getSynced() { return synced; }
public void setSynced(Boolean synced) { this.synced = synced; }
}
}
@@ -0,0 +1,209 @@
package server.sync;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import shine.db.entities.UserSettingEntry;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class RemoteUserSettingsSyncClient {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
public void upsertUserSetting(String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery) throws Exception {
JsonNode response = send(serverAddressRaw, """
{
"op":"UpsertUserSetting",
"requestId":%s,
"payload":{
"login":%s,
"setting_type":%d,
"setting_key":%s,
"time_ms":%d,
"value_text":%s,
"value_num":%d,
"client_key":%s,
"signature":%s,
"sync_delivery":%s
}
}
""".formatted(
"%s",
MAPPER.writeValueAsString(entry.getLogin()),
entry.getSettingType(),
MAPPER.writeValueAsString(entry.getSettingKey()),
entry.getTimeMs(),
MAPPER.writeValueAsString(entry.getValueText() == null ? "" : entry.getValueText()),
entry.getValueNum(),
MAPPER.writeValueAsString(entry.getClientKey() == null ? "" : entry.getClientKey()),
MAPPER.writeValueAsString(entry.getSignature() == null ? "" : entry.getSignature()),
syncDelivery ? "true" : "false"
));
ensureOk("UpsertUserSetting", response);
}
public RemoteUserSettingsBatch userSettingsSyncBatch(
String serverAddressRaw,
String ownerLogin,
long afterTimeMs,
String afterSettingKey,
int limit,
int maxBytes
) throws Exception {
JsonNode response = send(serverAddressRaw, """
{
"op":"UserSettingsSyncBatch",
"requestId":%s,
"payload":{
"ownerLogin":%s,
"afterTimeMs":%d,
"afterSettingKey":%s,
"limit":%d,
"maxBytes":%d
}
}
""".formatted(
"%s",
MAPPER.writeValueAsString(ownerLogin),
Math.max(0L, afterTimeMs),
MAPPER.writeValueAsString(afterSettingKey == null ? "" : afterSettingKey),
limit,
maxBytes
));
ensureOk("UserSettingsSyncBatch", response);
JsonNode payload = response.path("payload");
List<RemoteUserSettingsItem> items = new ArrayList<>();
JsonNode arr = payload.path("items");
if (arr.isArray()) {
for (JsonNode item : arr) {
items.add(new RemoteUserSettingsItem(
item.path("login").asText(""),
item.path("setting_type").asInt(0),
item.path("setting_key").asText(""),
item.path("time_ms").asLong(0L),
item.path("value_text").asText(""),
item.path("value_num").asLong(0L),
item.path("client_key").asText(""),
item.path("signature").asText("")
));
}
}
return new RemoteUserSettingsBatch(
payload.path("nextTimeMs").asLong(afterTimeMs),
payload.path("nextSettingKey").asText(afterSettingKey == null ? "" : afterSettingKey),
payload.path("hasMore").asBoolean(false),
items
);
}
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception {
String requestId = MAPPER.writeValueAsString("user-settings-sync-" + UUID.randomUUID());
String json = jsonTemplate.formatted(requestId);
String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw);
if (wsUrl == null) {
throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
}
CompletableFuture<String> responseFuture = new CompletableFuture<>();
CountDownLatch openLatch = new CountDownLatch(1);
SyncWsListener listener = new SyncWsListener(responseFuture, openLatch);
WebSocket webSocket = HTTP.newWebSocketBuilder()
.connectTimeout(Duration.ofSeconds(6))
.buildAsync(URI.create(wsUrl), listener)
.get(8, TimeUnit.SECONDS);
if (!openLatch.await(8, TimeUnit.SECONDS)) {
tryAbort(webSocket);
throw new TimeoutException("WS open timeout");
}
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
tryAbort(webSocket);
return MAPPER.readTree(responseJson);
}
private void ensureOk(String op, JsonNode response) {
int status = response.path("status").asInt(500);
if (status >= 200 && status < 300) return;
String code = response.path("code").asText("");
if (code.isBlank()) code = response.path("error").asText("");
throw new IllegalStateException(op + " failed: status=" + status + " code=" + code);
}
private static void tryAbort(WebSocket webSocket) {
try {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
} catch (Exception ignored) {
}
try {
webSocket.abort();
} catch (Exception ignored) {
}
}
public record RemoteUserSettingsBatch(
long nextTimeMs,
String nextSettingKey,
boolean hasMore,
List<RemoteUserSettingsItem> items
) {}
public record RemoteUserSettingsItem(
String login,
int settingType,
String settingKey,
long timeMs,
String valueText,
long valueNum,
String clientKey,
String signature
) {}
private static final class SyncWsListener implements WebSocket.Listener {
private final CompletableFuture<String> responseFuture;
private final CountDownLatch openLatch;
private final StringBuilder textBuffer = new StringBuilder();
private SyncWsListener(CompletableFuture<String> responseFuture, CountDownLatch openLatch) {
this.responseFuture = responseFuture;
this.openLatch = openLatch;
}
@Override
public void onOpen(WebSocket webSocket) {
openLatch.countDown();
webSocket.request(1);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
textBuffer.append(data);
if (last && !responseFuture.isDone()) {
responseFuture.complete(textBuffer.toString());
}
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
responseFuture.completeExceptionally(error);
}
}
}
@@ -8,6 +8,7 @@ import sync.codec.ShineUsersCodec;
import java.sql.*;
import java.util.*;
import java.util.Locale;
public final class PostgresStorageRepository
implements AutoCloseable {
@@ -465,7 +466,7 @@ public final class PostgresStorageRepository
String sql =
"INSERT INTO solana_user_pda_current (" +
"pda_address, login, record_number, slot, last_tx_signature, " +
"pda_address, login, normalized_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, " +
@@ -475,9 +476,10 @@ public final class PostgresStorageRepository
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT (pda_address) DO UPDATE SET " +
"login = EXCLUDED.login, " +
"normalized_login = EXCLUDED.normalized_login, " +
"record_number = EXCLUDED.record_number, " +
"slot = EXCLUDED.slot, " +
"last_tx_signature = EXCLUDED.last_tx_signature, " +
@@ -679,36 +681,41 @@ public final class PostgresStorageRepository
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.setString(3, normalizeLogin(snapshot.login()));
statement.setInt(4, snapshot.recordNumber());
statement.setLong(5, snapshot.slot());
statement.setString(6, snapshot.lastTxSignature());
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);
statement.setLong(33, nowMs);
}
private String normalizeLogin(String login) {
return login == null ? "" : login.trim().toLowerCase(Locale.ROOT);
}
private ShineUsersCodec.UserPdaSnapshot mapSnapshot(
@@ -911,6 +918,7 @@ public final class PostgresStorageRepository
"CREATE TABLE IF NOT EXISTS solana_user_pda_current (" +
"pda_address TEXT PRIMARY KEY, " +
"login TEXT NOT NULL UNIQUE, " +
"normalized_login TEXT NOT NULL, " +
"record_number INTEGER NOT NULL, " +
"slot BIGINT NOT NULL, " +
"last_tx_signature TEXT NOT NULL, " +
@@ -943,11 +951,29 @@ public final class PostgresStorageRepository
"last_synced_at_ms BIGINT NOT NULL" +
")"
);
statement.executeUpdate(
"ALTER TABLE solana_user_pda_current " +
"ADD COLUMN IF NOT EXISTS normalized_login TEXT"
);
statement.executeUpdate(
"UPDATE solana_user_pda_current " +
"SET normalized_login = LOWER(BTRIM(login)) " +
"WHERE normalized_login IS NULL " +
" OR normalized_login <> LOWER(BTRIM(login))"
);
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot " +
"ON solana_user_pda_current(slot)"
);
statement.executeUpdate(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_user_pda_current_normalized_login " +
"ON solana_user_pda_current(normalized_login)"
);
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_user_pda_current_normalized_login " +
"ON solana_user_pda_current(normalized_login)"
);
statement.executeUpdate(
"CREATE TABLE IF NOT EXISTS solana_user_pda_history (" +
@@ -1057,12 +1083,16 @@ public final class PostgresStorageRepository
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(
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) AS access_server(login_value)
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
JOIN solana_user_pda_current s
ON LOWER(s.login) = LOWER(btrim(access_server.login_value))
AND s.is_server = TRUE
@@ -1100,12 +1130,16 @@ public final class PostgresStorageRepository
FOR affected_user IN
SELECT u.login
FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements_text(
CROSS JOIN LATERAL (
SELECT login_value
FROM jsonb_array_elements_text(
CASE
WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb
ELSE u.access_servers_json::jsonb
END
) AS access_server(login_value)
) WITH ORDINALITY AS access_server(login_value, ord)
WHERE ord <= 2
) AS access_server
WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login)
LOOP
PERFORM shine_refresh_user_access_servers_for_user(affected_user.login);
@@ -0,0 +1,206 @@
package server.sync;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.dao.UserSettingsDAO;
import shine.db.dao.UserSettingsSyncPeerStateDAO;
import shine.db.entities.UserAccessServerRouteEntry;
import shine.db.entities.UserSettingEntry;
import shine.db.entities.UserSettingsSyncPeerStateEntry;
import utils.config.AppConfig;
import java.sql.Connection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public final class PeriodicUserSettingsSyncService {
private static final Logger log = LoggerFactory.getLogger(PeriodicUserSettingsSyncService.class);
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static final RemoteUserSettingsSyncClient REMOTE = new RemoteUserSettingsSyncClient();
private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance();
private static final UserSettingsDAO SETTINGS_DAO = UserSettingsDAO.getInstance();
private static final UserSettingsSyncPeerStateDAO STATE_DAO = UserSettingsSyncPeerStateDAO.getInstance();
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "periodic-user-settings-sync");
t.setDaemon(true);
return t;
}
});
private PeriodicUserSettingsSyncService() {}
public static void startOrLog() {
if (!isEnabled()) {
log.info("Periodic user settings sync disabled by user.settings.sync.enabled=false");
return;
}
if (!STARTED.compareAndSet(false, true)) return;
long initialDelaySec = configLong("user.settings.sync.initialDelaySeconds", 90L, 0L, 3600L);
long periodHours = configLong("user.settings.sync.periodHours", 6L, 1L, 168L);
EXECUTOR.scheduleWithFixedDelay(
PeriodicUserSettingsSyncService::runCycleSafe,
initialDelaySec,
TimeUnit.HOURS.toSeconds(periodHours),
TimeUnit.SECONDS
);
log.info("Periodic user settings sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours);
}
private static void runCycleSafe() {
try {
runCycle();
} catch (Exception e) {
log.error("Periodic user settings sync failed unexpectedly", e);
}
}
private static void runCycle() throws Exception {
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
if (ownServerLogin == null) {
log.warn("Periodic user settings sync skipped: {} is empty", SERVER_LOGIN_CONFIG);
return;
}
List<String> ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin);
Set<String> owners = new LinkedHashSet<>(ownersRaw);
if (owners.isEmpty()) {
log.info("Periodic user settings sync skipped: no local access-server users for {}", ownServerLogin);
return;
}
int syncedPeers = 0;
int appliedItems = 0;
int pushedItems = 0;
for (String ownerLogin : owners) {
List<UserAccessServerRouteEntry> routes = ACCESS_DAO.listByUserLogin(ownerLogin);
for (UserAccessServerRouteEntry route : routes) {
if (route == null) continue;
String remoteLogin = normalize(route.getServerLogin());
String remoteUrl = route.getServerUrl();
if (remoteLogin == null || remoteUrl == null || remoteUrl.isBlank()) continue;
if (remoteLogin.equals(ownServerLogin)) continue;
try {
SyncStats stats = syncOwnerWithRemote(ownerLogin, route);
appliedItems += stats.applied();
pushedItems += stats.pushed();
syncedPeers++;
} catch (Exception e) {
STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e));
log.warn("Periodic user settings sync peer failed: owner={} remoteServer={} reason={}",
ownerLogin, route.getServerLogin(), String.valueOf(e));
}
}
}
log.info("Periodic user settings sync cycle finished: owners={} syncedPeers={} appliedItems={} pushedItems={}",
owners.size(), syncedPeers, appliedItems, pushedItems);
}
private static SyncStats syncOwnerWithRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception {
int limit = (int) configLong("user.settings.sync.batchLimit", 500L, 1L, 1000L);
int maxBytes = (int) configLong("user.settings.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L);
int maxPages = (int) configLong("user.settings.sync.maxPagesPerPeer", 50L, 1L, 500L);
UserSettingsSyncPeerStateEntry state;
try (Connection c = getDbConnection()) {
state = STATE_DAO.getOrCreate(c, ownerLogin, route.getServerLogin(), route.getServerUrl());
}
long cursorTimeMs = state.getCursorTimeMs();
String cursorSettingKey = state.getCursorSettingKey() == null ? "" : state.getCursorSettingKey();
int applied = 0;
int pushed = 0;
boolean bootstrapCompleted = false;
for (int page = 0; page < maxPages; page++) {
RemoteUserSettingsSyncClient.RemoteUserSettingsBatch batch = REMOTE.userSettingsSyncBatch(
route.getServerUrl(),
ownerLogin,
cursorTimeMs,
cursorSettingKey,
limit,
maxBytes
);
try (Connection c = getDbConnection()) {
for (RemoteUserSettingsSyncClient.RemoteUserSettingsItem item : batch.items()) {
UserSettingEntry entry = new UserSettingEntry(
item.login(),
item.settingType(),
item.settingKey(),
item.timeMs(),
item.valueText(),
item.valueNum(),
item.clientKey(),
item.signature(),
true
);
int changed = SETTINGS_DAO.upsertIfNewer(c, entry);
if (changed > 0) applied++;
}
}
cursorTimeMs = Math.max(cursorTimeMs, batch.nextTimeMs());
cursorSettingKey = batch.nextSettingKey() == null ? "" : batch.nextSettingKey();
bootstrapCompleted = !batch.hasMore();
STATE_DAO.updateSuccess(ownerLogin, route.getServerLogin(), route.getServerUrl(), cursorTimeMs, cursorSettingKey, bootstrapCompleted);
if (!batch.hasMore() || batch.items().isEmpty()) break;
}
try (Connection c = getDbConnection()) {
List<UserSettingEntry> unsynced = SETTINGS_DAO.listUnsyncedByLogin(c, ownerLogin, limit);
for (UserSettingEntry entry : unsynced) {
REMOTE.upsertUserSetting(route.getServerUrl(), entry, true);
SETTINGS_DAO.markSynced(c, entry.getLogin(), entry.getSettingType(), entry.getSettingKey());
pushed++;
}
}
if (!bootstrapCompleted) {
log.info("Periodic user settings sync peer paused by page limit: owner={} remoteServer={} maxPages={}",
ownerLogin, route.getServerLogin(), maxPages);
}
return new SyncStats(applied, pushed);
}
private static java.sql.Connection getDbConnection() throws Exception {
return shine.db.DbController.getInstance().getConnection();
}
private static boolean isEnabled() {
String raw = AppConfig.getInstance().getParam("user.settings.sync.enabled");
return raw == null || raw.isBlank() || Boolean.parseBoolean(raw.trim());
}
private static long configLong(String key, long defaultValue, long min, long max) {
String raw = AppConfig.getInstance().getParam(key);
if (raw == null || raw.isBlank()) return defaultValue;
try {
long parsed = Long.parseLong(raw.trim());
return Math.max(min, Math.min(max, parsed));
} catch (Exception ignored) {
return defaultValue;
}
}
private static String normalize(String value) {
if (value == null) return null;
String s = value.trim().toLowerCase(Locale.ROOT);
return s.isEmpty() ? null : s;
}
private record SyncStats(int applied, int pushed) {}
}
@@ -9,6 +9,7 @@ import server.debug.DebugApiConfigurator;
import server.sync.BlockchainResyncRecoveryOnStartup;
import server.sync.PeriodicBlockchainSyncService;
import server.sync.PeriodicDmSyncService;
import server.sync.PeriodicUserSettingsSyncService;
import server.sync.SolanaUsersSyncStartupService;
import server.sync.SyncServersBootstrapService;
import utils.config.AppConfig;
@@ -104,6 +105,7 @@ public final class WsServer {
server.start();
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
PeriodicDmSyncService.startOrLog();
PeriodicUserSettingsSyncService.startOrLog();
server.join();
}
}
@@ -189,11 +189,11 @@ public class IT_03_AddBlock_NoAuth {
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
// 3) FRIEND взаимно (на HEADER)
sendConnection(sender1, st1, MsgSubType.CONNECTION_FRIEND,
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
bch2, u2HeaderBlock, u2HeaderHash,
"U1 -> U2: FRIEND", t);
sendConnection(sender2, st2, MsgSubType.CONNECTION_FRIEND,
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
bch1, u1HeaderBlock, u1HeaderHash,
"U2 -> U1: FRIEND", t);
@@ -1,29 +0,0 @@
# Подключение других устройств по QR и типизированные сессии
## Зачем
QR-подключение других устройств сейчас есть как заготовка, но сценарий нужно довести до устойчивого состояния. Параллельно надо аккуратно оформить типизированные сессии homeserver-ов в PDA.
## Что сделать
1. Довести QR-сценарий до стабильного подключения нового устройства.
2. Нормально описать и хранить устройство как отдельную типизированную сессию.
3. Согласовать это с серверной и UI-логикой.
4. Проверить, что подключение работает одинаково на новом и повторном устройстве.
## Что уже есть
- в планах есть `сессионные homeserver-ы в PDA`;
- в планах есть `подключение других устройств через QR`;
- базовая заготовка уже существует, но сценарий считается нестабильным.
## Откуда продолжать
- от текущих документов в `TODO/medium/`;
- отдельно проверить, какие поля уже есть в PDA и UI.
## Какие документы потом обновить
- `docs/Solana_Architecture/README.md`;
- `TODO/medium/2026-06-03_подключение_других_устройств_через_qr.md`;
- `TODO/medium/2026-06-02_сессионные_homeserver_в_pda.md`.
@@ -1,28 +0,0 @@
# ESP32 как личное файловое хранилище
## Зачем
Планируется использовать ESP32 как личное файловое хранилище SHiNE для переписок и вложений.
## Что сделать
1. Продумать формат хранения файлов на устройстве.
2. Согласовать загрузку и чтение файлов между UI, сервером и устройством.
3. Проверить, как устройство показывает статусы и ошибки.
4. Свести это с существующим homeserver/UI-прототипом.
## Что уже есть
- в списке будущих фич уже есть отдельная задача по ESP32S3 file storage;
- для UI homeserver уже есть отдельная документация и скетч должны держаться синхронно.
## Откуда продолжать
- от `TODO/medium/2026-05-26_0029_esp32s3_file_storage.md`;
- от документации по ESP32 UI homeserver.
## Какие документы потом обновить
- `TODO/medium/2026-05-26_0029_esp32s3_file_storage.md`;
- `TODO/README.md`;
- документацию по ESP32 UI homeserver, если добавятся экраны или статусы.
@@ -0,0 +1,21 @@
# TODO: уведомления и full-resync после фиксации блоков в Arweave
## Контекст
`user_notifications_state` является производным индексом blockchain-блоков. Сейчас full-resync цепочки очищает и перестраивает основные derived-state таблицы, но уведомления специально не включены в этот cleanup.
На текущем этапе это **не исправляем**, потому что логика синхронизации ещё будет дорабатываться, а надёжность/финальность блоков планируется усилить записью в Arweave.
## Что проверить после внедрения Arweave
Когда схема Arweave и правила восстановления цепочки стабилизируются:
- определить окончательный source of truth для accepted/finalized blocks;
- проверить поведение `user_notifications_state` при rollback/full-resync;
- если цепочка может реально заменить ранее принятый блок, удалять/перестраивать уведомления по `source_bch_name` и фактическому набору финальных блоков;
- не допускать stale-уведомлений от блоков, которые больше не входят в подтверждённую цепочку;
- добавить интеграционный тест на divergence + full-resync + notification projection.
## Важно
До появления финальной Arweave/recovery-модели не добавлять временную сложную cleanup-логику только ради этого редкого сценария.
-61
View File
@@ -1,61 +0,0 @@
# TODO
Папка для короткого списка ближайших и среднесрочных задач, которые уже обсуждались и пока отложены.
## Как использовать
- Один markdown-файл = одна задача.
- В файле коротко фиксируем:
- зачем это нужно;
- что именно сделать;
- что уже есть в коде;
- откуда продолжать;
- какие документы потом надо обновить.
- Это не активная разработка. Тут только план и контекст.
- Старую папку `docs/Future_Features/` считать архивной и больше не использовать как источник новых задач.
## Текущие задачи
- `2026-06-26_1800_корректное_завершение_за_30с.md` - дать сервису до 30 секунд на корректное завершение опасных операций перед рестартом.
- `2026-06-26_1810_подключение_устройств_по_qr.md` - довести подключение других устройств по QR и перевести это в нормальные типизированные сессии.
- `2026-06-26_1815_esp32_файловое_хранилище.md` - использовать ESP32 как личное файловое хранилище для переписок и вложений.
## Децентрализация
Текущий production-режим SHiNE считается односерверным. Задачи по нескольким серверам, Arweave и realtime PDA/Solana sync вынесены в `Децентрализация/` и не блокируют выкладку текущей версии на GitHub.
- `Децентрализация/односерверный_production_режим.md` - границы текущей production-версии с одним сервером.
- `Децентрализация/запись_блокчейнов_в_arweave.md` - будущая запись/архивация блокчейнов в Arweave.
- `Децентрализация/realtime_pda_solana_sync.md` - будущая онлайн-синхронизация PDA и Solana.
- `Децентрализация/межсерверная_передача_сообщений.md` - будущая доставка сообщений между серверами.
- `Децентрализация/межсерверные_звонки.md` - будущая маршрутизация звонков между серверами.
- `Децентрализация/2026-06-26_1805_межсерверный_ws_и_dm_sync.md` - перенесённый старый план постоянного server-to-server WS и DM sync.
## Новые фишки которые надо доделать
- `Новые фишки которые надо доделать/Новая_контентная_модель_блокчейна/` - отложенная новая контентная модель блокчейна, не входящая в текущий односерверный production-релиз.
## Перенесённые планы из `docs/Future_Features/`
### near
- `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал.
- `near/2026-05-25_1106_wallet_topup_solana_arweave.md` - пополнение Solana и Arweave через внешний сервис покупки с подсказкой и копированием адреса.
### medium
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
- `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-08-09_esp32_переход_на_sendsignal_и_client_key.md` - перевести ESP32 и wallet-extension на единый `SendSignal`, обязательный `client key` и подготовить почву для будущего E2E-шифрования payload.
- `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - завершить зачистку хвостов после перевода серверной БД с `SQLite` на `PostgreSQL`.
### dao_запуск
- `dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md` - ESP32 как аппаратный кошелёк: постоянная device-сессия на сервере, подтверждение операций на экране, делегированные сессии для браузера/телефона.
### far
- `far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через SHiNE/WebRTC DataChannel и обмен файлами по чанкам с адресацией по `SHA-256`.
@@ -1,114 +0,0 @@
# Homeserver: технические команды и передача файлов через SHiNE/WebRTC
## Зачем нужна фича
Идея на дальнее будущее: дать возможность обращаться к homeserver не только как к участнику сети SHiNE, но и как к удалённой технической точке управления.
Цели:
- отправлять на homeserver технические команды в текстовом виде;
- получать текстовый ответ на команду;
- при наличии WebRTC DataChannel передавать части файлов в обе стороны;
- хранить полученные файлы на SD-карте homeserver;
- использовать единый механизм доставки как через сервер SHiNE, так и напрямую через DataChannel.
## Горизонт
`far` - идея без ближайшего срока реализации. Сейчас приоритет ниже, чем запуск и стабилизация основного проекта.
## Что именно имеется в виду
### 1. Единая модель технической команды
Техническая команда должна иметь единый смысл независимо от транспорта доставки:
- через любой доступный сервер SHiNE;
- через уже установленный WebRTC DataChannel.
Если конкретный транспорт недоступен, ответ по нему может не прийти. Это считается нормальным поведением протокола.
### 2. Команда как короткоживущий подписанный сигнал
У команды должны быть:
- `commandId`;
- временная метка;
- TTL около 10 секунд;
- криптографическая подпись.
Смысл такой:
- если команда быстро дошла, homeserver подтверждает принятие;
- если не дошла вовремя, команда считается протухшей;
- отправитель может безопасно послать повтор;
- при повторе homeserver отвечает либо `команда принята`, либо `уже выполнено ранее`.
Это даёт дедупликацию и безопасный resend без повторного выполнения действия.
### 3. Текстовые технические команды
Базовый сценарий похож на короткий удалённый shell-протокол, но на уровне строго ограниченных команд:
- отправил строку-команду;
- получил строку-ответ.
Команды не обязаны исполнять произвольный shell. Предпочтительная модель - белый список операций с контролируемым форматом аргументов и ответа.
### 4. Передача файлов только при наличии DataChannel
Если между устройствами есть WebRTC DataChannel, через него можно передавать технические сообщения для файлового обмена.
Предварительная модель:
- имя файла = `SHA-256` содержимого;
- можно запросить диапазон байт `from..to`;
- можно отправить диапазон байт `from..to`;
- homeserver хранит полученные данные на SD-карте;
- если DataChannel нет, на запрос файловой передачи возвращается ответ в духе `не могу передать, нет data channel`.
Фактически файл-обмен должен быть частным случаем общего протокола технических команд.
### 5. Установка data-соединения по явной команде
Нужна техническая команда уровня:
- `установить data-соединение`.
Ответ:
- либо `да`, после чего запускается обычная процедура `offer/answer/ICE`;
- либо `нет` и причина отказа.
### 6. Доставка на пользовательские сессии
Логика должна быть совместима с общей моделью SHiNE, где технические сигналы можно отправлять на конкретные активные сессии пользователя.
Идея:
- на любую активную сессию пользователя можно посылать техническую команду;
- контакт пользователя может инициировать такую техническую коммуникацию так же, как он уже инициирует звонок или другой служебный сигнал.
## Что нужно будет сделать при возврате к задаче
- Спроектировать отдельный формат технических команд и ack-ответов.
- Решить, будет ли это новый тип служебных сообщений в существующем протоколе блокчейн/сигналинга или отдельная ветка поверх уже имеющихся transport-операций.
- Отдельно продумать авторизацию: кто именно из контактов и какие команды имеет право слать.
- Ограничить набор допустимых команд, чтобы не превратить механизм в небезопасный удалённый shell.
- Спроектировать протокол чанков файлов: размер чанка, нумерация, повторная отправка, контроль целостности, дозагрузка, завершение файла.
- Продумать хранение на SD-карте: временные файлы, сборка чанков, проверка итогового `SHA-256`, очистка мусора.
- Продумать поведение при отсутствии DataChannel, таймаутах и дублирующихся командах.
- Проверить, как это лучше встраивать в текущие клиентские сессии, звонки и homeserver-логику.
## Вопросы для будущего уточнения
- Это должен быть строго служебный протокол или пользователь сможет вызывать его и вручную из UI.
- Нужен ли доступ только к заранее разрешённым каталогам/файлам.
- Нужна ли двусторонняя синхронизация файлов или достаточно ручных команд `запросить кусок` / `отправить кусок`.
- Нужно ли разрешать передачу файлов через сервер SHiNE как fallback, или файл-обмен должен идти только через DataChannel.
- Какой максимальный размер файлов и допустимый объём хранения на SD-карте.
## Что уже сделано
Пока только зафиксирована идея и базовая концепция. Реализация не начиналась.
## Какие документы нужно будет обновить при реализации
- `docs/Blockchain/README.md` и связанные файлы, если изменятся типы служебных сообщений или форматы блокчейн-команд.
- `docs/API/` если изменится публичный серверный API или появятся новые операции.
- `docs/Personal_Messages/Протокол_DM_v1.md` если часть маршрутизации или подтверждений будет встроена в существующую логику доставки/сессий.
- Документацию по homeserver/ESP32, если появится пользовательская или сервисная файловая логика на устройстве.
## С какого места продолжать позже
Возвращаться к задаче только после стабилизации запуска проекта и базовых текущих функций. Начинать с проектирования протокола команд и матрицы прав доступа, а уже потом переходить к DataChannel-файлообмену.
@@ -1,62 +0,0 @@
# Кошелёк и пополнение баланса сияния
- Горизонт:
`medium`
- Ориентир:
среднесрочно
- Статус:
`proposal`
## Кратко
Нужно добавить кошелёк для внутреннего баланса сияния и пополнение этого баланса через блокчейн-логику проекта. Задача связана с регистрацией пользователя и будущим учётом баланса.
## Предполагаемый сценарий
1. Пользователь регистрируется и получает/подключает нужные кошельки.
2. В интерфейсе появляется баланс сияния.
3. Пользователь открывает пополнение баланса сияния.
4. Система создаёт или принимает блокчейн-операцию пополнения.
5. После подтверждения баланса UI обновляет значение.
## Что нужно продумать
1. Что именно является единицей баланса сияния.
2. Где хранится состояние баланса: в существующем блокчейне SHiNE, Solana-модуле или комбинированно.
3. Какая операция отвечает за пополнение.
4. Нужно ли делать отдельную регистрацию кошелька сияния или использовать существующую регистрацию пользователя.
5. Как баланс восстанавливается после перезагрузки клиента.
6. Какие права нужны для пополнения и списания.
7. Нужна ли история операций баланса.
## Вопросы перед реализацией
1. Пополнение баланса сияния должно идти через основной блокчейн SHiNE или через Solana-программу.
2. Нужна ли конвертация из SOL/AR в сияние.
3. Кто может выпускать или начислять сияние.
4. Нужно ли поддерживать перевод сияния между пользователями.
5. Нужны ли лимиты, комиссии или статусы подтверждения.
6. Какой экран должен показывать баланс: регистрация, профиль, кошелёк или отдельная страница.
7. Нужно ли отображать неподтверждённый баланс отдельно от подтверждённого.
## Важное ограничение
Если для баланса сияния потребуется новый формат блокчейн-блока или изменение существующего формата, перед реализацией нужно отдельно предупредить пользователя и получить явное подтверждение на изменение формата блокчейна.
Если потребуется новый серверный API или изменение существующих `op`, перед реализацией нужно отдельно предупредить пользователя и получить явное подтверждение на изменение API.
## Документы, которые обновить при реализации
- `docs/Blockchain/`, если появятся или изменятся блоки баланса.
- `docs/Blockchain/CHANGELOG.md`, если меняется блокчейн-формат.
- `docs/API/`, если меняется серверный API.
- после реализации отдельно согласовать ручную проверку.
- Документацию Solana-регистрации, если баланс будет связан с Solana-модулем.
## Минимальная проверка в будущем
1. Новый пользователь видит корректный начальный баланс.
2. Пополнение создаёт правильную операцию.
3. Баланс обновляется после подтверждения.
4. После перезагрузки UI баланс остаётся корректным.
5. Ошибочные или повторные операции не начисляют баланс дважды.
@@ -1,44 +0,0 @@
# ESP32S3 как личное файловое хранилище SHiNE
## Горизонт
Среднесрочный: ближайшие недели или 1-2 месяца.
## Зачем нужна фича
Нужно проработать маленький физический сервер на ESP32S3 как персональное или доверенное файловое хранилище SHiNE.
Идея: при обмене сообщениями пользователи смогут использовать такой сервер для хранения своих файлов, вложений, файлов общих переписок и связанных данных.
## Что нужно сделать
- Описать роль ESP32S3-сервера в общей архитектуре ключей и сессий.
- Определить, какие ключи может хранить такое устройство.
- Решить, хранит ли устройство только файлы или также подписывает пользовательские операции.
- Описать протокол загрузки, скачивания и удаления файлов.
- Определить правила шифрования файлов до отправки на устройство.
- Продумать индексацию файлов для личных и общих переписок.
- Решить, как устройство авторизуется на основном сервере SHiNE.
## Вопросы перед реализацией
- ESP32S3 должен работать как полностью локальное устройство или как публично доступный мини-сервер?
- Нужен ли внешний relay, если устройство находится за NAT?
- Какие ограничения по размеру файла считаем допустимыми?
- Хранит ли устройство метаданные переписок или только зашифрованные blob-файлы?
- Как восстанавливать доступ, если устройство потеряно или заменено?
## Что уже сделано
Код не реализован. Идея зафиксирована как будущая задача после описания модели ключей.
## Документы, которые нужно обновить при возврате
- `docs/Keys/README.md`
- `docs/Personal_Messages/Протокол_DM_v1.md`
- `docs/API/`
- `docs/Blockchain/`, если появятся новые блоки или команды для файлов.
## С какого места продолжать
Начать с короткого протокольного документа: роли устройства, авторизация, шифрование файлов, минимальные API-операции и сценарии восстановления.
@@ -1,105 +0,0 @@
# Сессионные homeserver-ы в PDA пользователя
- Статус:
`future`
- Горизонт:
`medium`
- Ориентир:
после завершения первого этапа по пользовательским сессиям
- Основание:
Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних homeserver-ов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли.
## Зачем нужна фича
У одного пользователя может быть несколько доверенных внутренних homeserver-ов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели.
Это нужно, чтобы:
- хранить несколько homeserver-ов у одного пользователя одновременно;
- различать обычные клиентские сессии и серверные сессии по явному типу;
- дать расширяемый формат записи с версией;
- использовать единый подход для DM, звонков и внутренних команд между сессиями.
## Целевая идея
В пользовательском PDA должен появиться список записей сессий, где каждая запись содержит как минимум:
- `sessionType` (`u8`);
- `sessionVersion` (`u8`);
- `sessionName`;
- `sessionPubKey`.
Предварительные значения:
- тип `1` - обычная пользовательская сессия;
- тип `100` - homeserver пользователя;
- версия `1` - первая рабочая версия формата записи сессии.
На текущем этапе под это уже зарезервирован отдельный блок `SessionsBlock` с `block_type = 55`, а `TrustedStateBlock` остаётся на `50`.
Важно: homeserver-ов у одного пользователя может быть несколько.
## Архитектурный принцип
Внутренний протокол взаимодействия должен оставаться транспортным.
То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки homeserver-а, а должен:
- доставлять сообщения между сессиями;
- доставлять сигналы звонков между сессиями;
- хранить и маршрутизировать адресацию;
- не принимать на себя бизнес-логику содержимого внутренних команд.
## Что уже подтверждается текущим кодом
- Личные сообщения уже доставляются по всем сессиям целевого пользователя с отдельным учётом доставки на каждую сессию.
- Подтверждение доставки DM уже идёт отдельно по каждой сессии.
- Вызов звонка уже рассылается по нескольким активным сессиям пользователя.
- Сигналы звонка уже адресуются конкретной сессии, а stop-сигналы дублируются на остальные сессии того же пользователя.
Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол homeserver-а".
## Что нужно сделать при возврате к задаче
1. Согласовать финальный бинарный формат записи сессии в PDA пользователя.
2. Проверить, не меняет ли это уже опубликованный формат пользовательской PDA-записи.
3. Если формат PDA меняется, заранее предупредить пользователя и получить отдельное подтверждение.
4. Решить, где именно хранится массив сессий:
- в основной записи пользователя;
- в отдельной PDA-структуре расширения;
- или в смешанной схеме с базовой записью и внешними индексами.
5. Зафиксировать ограничения:
- максимальное число сессий;
- максимальную длину `sessionName`;
- правила удаления и обновления записи;
- правила ротации `sessionPubKey`.
6. Продумать, как UI и сервер будут отличать тип `1` и тип `100`.
7. Определить, какие внутренние сообщения homeserver-а останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации.
8. Добавить API/операции чтения и обновления списка сессий, если для этого не хватит существующих механизмов.
9. После реализации обязательно обновить документацию.
## Что нужно обновить при реализации
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
- `docs/Solana_Architecture/README.md`
- `docs/Инициализация_Solana_регистрации/README.md`
- `docs/Keys/README.md`
- `docs/Personal_Messages/Протокол_DM_v1.md`, если изменится адресация DM по типам сессий
- `docs/API/`, если появятся новые серверные операции или изменятся ответы
## Что пока не делать
- Не включать это автоматически в основной deploy сервера.
- Не менять сейчас Solana PDA-формат без отдельного подтверждения.
- Не добавлять временные поля в публичный API "на всякий случай".
## С какого места продолжать
Продолжать после завершения первой части:
1. описать минимальный формат записи пользовательской сессии;
2. отдельно решить, живут ли homeserver-ы в том же списке, что и обычные сессии;
3. затем уже проектировать операции регистрации, обновления и отключения таких сессий.
@@ -1,44 +0,0 @@
# Подключение других устройств через QR
- Горизонт:
`medium`
- Ориентир:
позже, не сейчас
- Статус:
`future`
## Зачем нужна фича
Нужно нормально довести подключение другого устройства через QR-код. Сейчас есть полуготовая заготовка, но сценарий работает нестабильно и требует отдельной доработки.
## Что уже есть
- В UI уже есть экраны:
- `shine-UI/js/pages/connect-device-view.js`
- `shine-UI/js/pages/device-qr-view.js`
- Есть сервис переноса ключей через QR:
- `shine-UI/js/services/qr-key-transfer-service.js`
- Логика частично собрана, но её нельзя считать завершённой или надёжной.
## Что нужно будет сделать потом
1. Проверить и довести формат QR-передачи.
2. Проверить сканирование и ручной ввод QR-текста.
3. Проверить перенос `device`, `blockchain`, `root` ключей только по реальному наличию на исходном устройстве.
4. Проверить, что после переноса очищается старая история нужного логина и не ломается вход.
5. Отдельно проверить сценарий без `BarcodeDetector`.
6. Довести экран подтверждения на втором устройстве.
## Что сейчас важно
- Не считать эту часть готовой.
- Не возвращать её в активную разработку без отдельной команды пользователя.
- Если вернёмся к задаче, сначала нужно понять, что именно уже работает, а что нет, и потом починить целиком.
## Что обновить при возврате
- после реализации отдельно согласовать ручную проверку
- `shine-UI/js/pages/connect-device-view.js`
- `shine-UI/js/pages/device-qr-view.js`
- `shine-UI/js/services/qr-key-transfer-service.js`
- документацию по ключам, если формат переноса меняется
@@ -1,29 +0,0 @@
# Перенести старые сессионные сигналы на `SendSignal`
## Контекст
В проект добавлен новый общий межсессионный transport `SendSignal`.
Первое текущее применение:
- `remote AddBlock via homeserver session`
Старые сценарии пока оставлены на прежнем транспорте, чтобы не ломать уже работающий код.
## Что перенести позже
1. Звонковые сигналы, которые сейчас идут через `CallSignalToSession`.
2. Старый wallet/ESP32 обмен, где технические команды всё ещё привязаны к call-like транспорту.
3. Остальные доверенные межсессионные команды одного пользователя.
## Что важно учесть при переносе
- не ломать обратную совместимость работающих звонков;
- сохранить текущую маршрутизацию по `sessionId`;
- договориться о едином `signalType`;
- отдельно описать миграцию клиентских обработчиков событий:
- `IncomingCallSignal` -> `IncomingSignal`
## С какого сценария продолжать
Начинать перенос со звонков, но только после отдельной ручной проверки того, что `SendSignal` стабильно отработал на `remote AddBlock`.
@@ -1,57 +0,0 @@
# Переход с SQLite на PostgreSQL
## Зачем
Переход runtime-сервера на `PostgreSQL` уже выполнен, но после него остались хвосты в документации, именах, комментариях и части прямых SQL-запросов.
Этот TODO теперь нужен не для самого перехода, а для доведения проекта до полностью консистентного состояния после ухода от `SQLite`.
## Что сделать
- Дочистить документацию, где ещё описан `SQLite` как текущий runtime.
- Убрать или переименовать legacy-названия и комментарии, которые уже не соответствуют PostgreSQL runtime.
- Постепенно перенести оставшиеся прямые SQL-запросы из хэндлеров в DAO/service.
- Проверить case-insensitive сравнения, уникальные ограничения и индексы уже в чисто PostgreSQL модели.
- Отдельно пройтись по TODO/служебным документам и убрать ссылки на удалённые SQLite-классы как на актуальный код.
## Что уже есть в коде
- Доступ к БД в основном проходит через DAO-слой, а не полностью размазан по проекту.
- Основная серверная логика уже разделена по модулям.
- Runtime-сервер уже работает только с `PostgreSQL`.
- Пустая БД инициализируется автоматически через `schema_v1`.
## Откуда продолжать
- Продолжать с зачистки 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`, коммит будет создан после этой записи.
@@ -1,71 +0,0 @@
# Пополнение Solana и Arweave через внешний сервис покупки
- Горизонт:
`near`
- Ориентир:
сегодня/завтра
- Статус:
`proposal`
## Кратко
Нужно добавить удобное пополнение кошельков на экране регистрации/кошелька: для Solana и Arweave дать отдельные действия `Пополнить`, которые ведут на международный сервис покупки криптовалюты с карты и помогают пользователю скопировать адрес кошелька.
## Пользовательский сценарий
1. Пользователь видит адрес кошелька Solana или Arweave.
2. Нажимает `Пополнить`.
3. Открывается промежуточное окно с инструкцией:
- сейчас пользователь перейдёт на страницу покупки/пополнения;
- нужно указать или проверить адрес кошелька;
- после оплаты нужно закрыть внешнюю страницу и вернуться назад;
- Solana обычно приходит быстро, ориентир 10-15 секунд после подтверждения сети;
- Arweave может идти дольше, точное время нужно уточнить по выбранному сервису.
4. В окне есть кнопки:
- `Скопировать адрес и перейти`;
- `Перейти без копирования`.
5. Для Solana и Arweave используются разные окна/инструкции и, возможно, разные внешние ссылки.
## Что нужно сделать
1. Найти текущий экран, где показываются кошельки при регистрации и пополнении.
2. Найти текущую ссылку покупки Arweave, если она уже есть в UI.
3. Выбрать международный сервис покупки Solana с карты, не российский.
4. Проверить, поддерживает ли сервис deep link с предзаполненным адресом кошелька.
5. Если deep link невозможен, реализовать промежуточное окно с копированием адреса.
6. Добавить отдельные действия для Solana и Arweave.
7. Сделать текст инструкции коротким и понятным.
8. Проверить, что адрес копируется в буфер обмена в браузере.
9. Проверить мобильный сценарий и desktop-сценарий.
## Вопросы перед реализацией
1. Какой сервис покупки Solana использовать: тот же провайдер, что для Arweave, или другой международный on-ramp.
2. Нужно ли разрешать покупку только SOL или также USDC/SPL-токены на Solana.
3. Где именно показывать кнопку `Пополнить`: только регистрация, настройки кошелька или оба места.
4. Нужно ли показывать предупреждение о комиссиях и стороннем сервисе.
5. Нужно ли открывать внешнюю страницу в новой вкладке или в текущем окне.
6. Нужно ли логировать факт нажатия `Пополнить` на сервере.
7. Какой точный текст использовать для времени прихода Arweave.
## Риски и ограничения
- On-ramp-сервисы меняют ссылки и параметры, поэтому deep link нужно проверять перед реализацией.
- Clipboard API может требовать HTTPS и пользовательский жест.
- Нельзя обещать точное время поступления средств: лучше писать ориентир и зависимость от сети/провайдера.
- Внешний сервис может быть недоступен в отдельных странах или для отдельных карт.
## Документы, которые обновить при реализации
- Документацию UI/кошельков, если такая есть.
- после реализации отдельно согласовать ручную проверку.
- `docs/API/`, только если появится новый серверный API или логирование.
## Минимальная проверка
1. На Solana-кошельке открывается правильное окно пополнения.
2. Кнопка `Скопировать адрес и перейти` копирует Solana-адрес и открывает внешний сервис.
3. Кнопка `Перейти без копирования` открывает внешний сервис без копирования.
4. Аналогичный сценарий работает для Arweave.
5. На мобильном экране текст и кнопки не перекрываются.
6. Возврат назад в приложение не ломает состояние регистрации/кошелька.
@@ -1,33 +0,0 @@
# Убрать временную очистку `signed_messages_v2` в миграции БД v11
## Зачем это нужно
При переходе на новый DM-протокол `SHiNE_DM v1` была добавлена временная миграция БД `v11`, которая при первом старте на старой базе полностью очищает:
- `signed_messages_v2`
- `signed_message_session_delivery`
Это сделано как защитный reset, потому что старые DM-строки и backlog могли быть несовместимы с новым форматом, новой логикой tombstone и новым клиентским E2EE-разбором.
## Что именно потом сделать
- найти историческое место, где была добавлена временная миграция `migrateToV11()`, и убрать её остатки из runtime-логики/документации;
- удалить helper `clearLegacySignedMessagesForDmV11(...)`;
- поднять версию схемы дальше обычным образом уже без destructive-cleanup;
- при необходимости заменить это на нормальную точечную миграцию старых DM-записей или совсем убрать поддержку старой истории.
## Что уже есть в коде
- `LATEST_SCHEMA_VERSION = 11`;
- при миграции в `v11` выполняется полная очистка DM-таблиц;
- в коде прямо оставлен комментарий, что это временная мера.
## Откуда продолжать
Продолжать от коммита, в котором была добавлена миграция `v11` для очистки DM-таблиц после перехода на `SHiNE_DM v1`.
## Какие документы потом обновить
- `docs/Personal_Messages/Протокол_DM_v1.md`, если изменится стратегия миграции старой истории;
- `docs/Personal_Messages/Формат_DM_v1.md`, если появится отдельное правило совместимости/конвертации;
- при необходимости `docs/API/12_Direct_Messages_Push_Calls_API.md`, если затронется поведение backlog/доставки.
@@ -1,5 +1,13 @@
# Восстановить полную логику `shine_login_guard`
Тоесть сделать что бы нормально проверялись логины пользователей
Статус: отложено.
## Зачем это нужно
@@ -0,0 +1 @@
Передачу билетов владельцами со счёта на счёт
@@ -0,0 +1,5 @@
Баланс в салане
баланс в Арвив / и турбо
Балан лимит МБ / оно же сияния токены SHN
@@ -0,0 +1,3 @@
Сделать поддержку нескольких залогиненных аккаунтов враз тоесть что бы можно было менять акаунт под которым заходить
- подумать о деталях, а так хорошая тема - и тестировать удобнее станет
-
@@ -27,13 +27,3 @@
- `BlockchainTmpRecoveryOnStartup` и `BlockchainResyncRecoveryOnStartup` уже умеют добирать незавершённые хвосты после старта.
- `AddBlock` уже стал crash-safe через `tmp_bch` / `write_check` / `write_pending`.
## Откуда продолжать
- начать с `systemd`-юнита и базового shutdown-hook в сервере;
- затем проверить, что текущие операции реально завершаются в отведённые 30 секунд.
## Какие документы потом обновить
- `deploy/`;
- `docs/Blockchain/sync-between-servers.md`, если изменится поведение остановки/восстановления.
@@ -0,0 +1,11 @@
Там были какието разные апи для ошибок звонка
и для старта тестовых соединений
убрать короче лишнее
и сделать номальное апи что бы клиент мог
высылать уведомления об ошибках на сервер
предлогал отправить уведомление
@@ -0,0 +1,5 @@
Как_вариант_можно_сделать_hameserver_как_хранилище_файлов_пользователя
И всё это можно сделать внутри ESP32
Хотя не понятно надо ли так делать - потому что вроде удобно,
но тем не менее и сложно как то объяснить такой функционал людям
@@ -0,0 +1,5 @@
Сделать что бы если сервера с исходящими сообщениями не могут доставить на входящие то
- делать повторные попытки через время
- если так и не получилось и никужа не доставлено уведомлять пользователя
- Так же как вариант собирать подписи что полученно входящее сообщение с серверов пользователя полчателя
@@ -0,0 +1,7 @@
# Межсерверные звонки
Доделать Звонки что бы работало как сигнал о том что вызов идёт.
и
Пользователи на разных серверах должны иметь возможность устанавливать звонки так же, как пользователи одного сервера.
@@ -1,43 +0,0 @@
# Постоянный server-to-server WS и DM sync
## Зачем
Текущий production-режим SHiNE рассчитан на один основной сервер. Межсерверная синхронизация относится к будущей децентрализации и не должна блокировать выкладку односерверной production-версии.
Сейчас синхронизация между серверами работает в основном как periodic sync и one-shot push. Для нормальной репликации в будущем ещё нужен постоянный межсерверный канал:
- живое подключение к партнёру;
- push новых блоков;
- push DM;
- ACK на доставку;
- backoff/reconnect;
- стартовый backfill.
## Что сделать
1. Поднять постоянное WebSocket-соединение между партнёрскими серверами.
2. Сделать push новых блоков сразу после `AddBlock`.
3. Сделать push DM-блоков между серверами.
4. Добавить ACK и повторную отправку при сбое.
5. Ввести стартовый обмен курсорами и добор хвоста.
## Что уже есть
- `ListBlockchainHeads`;
- `GetBlockchainBlock`;
- `GetSyncUserProfile`;
- базовый periodic sync;
- базовый backfill хвоста;
- базовый full resync при divergence.
## Откуда продолжать
- от текущего `sync_servers` bootstrap и `PeriodicBlockchainSyncService`;
- дальше выделить отдельный межсерверный transport layer.
## Какие документы потом обновить
- `docs/Blockchain/sync-between-servers.md`;
- `docs/Personal_Messages/Протокол_DM_v1.md`;
- `docs/Personal_Messages/Формат_DM_v1.md`;
- `docs/API/`.
@@ -1,16 +0,0 @@
# Децентрализация
Папка для задач, которые нужны для будущего режима с несколькими серверами, Solana/PDA-синхронизацией и внешним хранением данных.
## Текущий статус
Сейчас production-режим SHiNE считается односерверным: один сервер обслуживает пользователей, сообщения, звонки и запись данных. Задачи из этой папки не являются блокерами для выкладки текущего репозитория на GitHub и запуска одного production-сервера.
## Задачи
- `односерверный_production_режим.md` - зафиксировать границы текущей production-версии.
- `запись_блокчейнов_в_arweave.md` - вынести долговременную запись блокчейнов в Arweave.
- `realtime_pda_solana_sync.md` - сделать онлайн-синхронизацию PDA/Solana в реальном времени.
- `межсерверная_передача_сообщений.md` - реализовать доставку сообщений между серверами.
- `межсерверные_звонки.md` - реализовать маршрутизацию звонков между серверами.
- `2026-06-26_1805_межсерверный_ws_и_dm_sync.md` - старый план постоянного server-to-server WS и DM sync, перенесённый в контекст децентрализации.
@@ -1,30 +0,0 @@
# Realtime-синхронизация PDA и Solana
## Зачем
В будущем PDA-записи и Solana-состояние должны автоматически и быстро синхронизироваться с серверным состоянием, чтобы данные пользователей, homeserver-сессии и связанные записи не расходились.
## Что сделать
1. Определить, какие серверные события должны обновлять PDA.
2. Добавить очередь/воркер для надёжной отправки изменений в Solana.
3. Добавить периодическую сверку серверного состояния с PDA.
4. Добавить обработку ошибок, повторов и конфликтов версий.
5. Добавить мониторинг задержек и неуспешных Solana-транзакций.
## Что учесть
- Solana/Anchor-модуль находится в `shine-solana/shine/` и ведётся отдельно от основного server/UI deploy.
- Перед изменениями внутри Solana-модуля нужно читать `shine-solana/shine/AGENTS.md`.
- Основная инструкция по Solana-регистрации находится в `docs/Инициализация_Solana_регистрации/README.md`.
- Формат пользовательской PDA-записи описан в `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`.
## Документы, которые потом нужно обновить
- `docs/Инициализация_Solana_регистрации/README.md`;
- `docs/Solana_Architecture/README.md`;
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`, если меняется формат PDA.
## Статус
Отложено до этапа децентрализации.
@@ -1,29 +0,0 @@
# Запись блокчейнов в Arweave
## Зачем
Для будущей децентрализации нужно долговременное внешнее хранение блокчейнов, чтобы данные не зависели только от одного серверного диска.
## Что сделать
1. Определить, какие блокчейны и какие диапазоны блоков записываются в Arweave.
2. Зафиксировать формат пачки блоков, метаданных, ссылок и контрольных хэшей.
3. Добавить безопасный механизм публикации без хранения приватного JWK в git.
4. Добавить проверку уже загруженных диапазонов, чтобы не плодить дубли.
5. Описать восстановление блокчейна из Arweave при потере локальных данных.
## Важные ограничения
- Любое изменение формата блокчейна требует отдельного предупреждения и явного подтверждения пользователя.
- Добавление данных в блокчейн должно выполняться только через `AddBlock`.
- Секреты Arweave нельзя хранить в репозитории.
## Документы, которые потом нужно обновить
- `docs/Blockchain/README.md`;
- `docs/Blockchain/CHANGELOG.md`;
- документы deploy/секретов в `deploy/`, если появятся новые параметры.
## Статус
Отложено до этапа децентрализации.
@@ -1,30 +0,0 @@
# Межсерверная передача сообщений
## Зачем
Когда у SHiNE появится несколько серверов, пользователи на разных серверах должны получать личные сообщения без ручной синхронизации и без привязки к одному центральному узлу.
## Что сделать
1. Определить протокол server-to-server доставки DM.
2. Добавить маршрутизацию получателя по серверу, user id, публичному ключу или PDA.
3. Добавить ACK, повторы, дедупликацию и backfill пропущенных сообщений.
4. Разделить realtime-доставку и восстановление истории.
5. Описать поведение при недоступности удалённого сервера.
## Что учесть
- Логика DM должна соответствовать документам в `docs/Personal_Messages/`.
- При изменении формата signed DM-блока или правил доставки нужно обновлять протокол и байтовый формат DM.
- Если появятся новые server API/WebSocket операции, нужно обновить `docs/API/`.
## Документы, которые потом нужно обновить
- `docs/Personal_Messages/Протокол_DM_v1.md`;
- `docs/Personal_Messages/Формат_DM_v1.md`;
- `docs/API/`;
- `docs/API/09_Operations_Index.md`, если добавляются новые `op`.
## Статус
Отложено до этапа децентрализации.
@@ -1,29 +0,0 @@
# Межсерверные звонки
## Зачем
В будущем пользователи на разных серверах должны иметь возможность устанавливать звонки так же, как пользователи одного сервера.
## Что сделать
1. Определить протокол межсерверной сигнализации звонков.
2. Добавить маршрутизацию offer/answer/ICE-кандидатов между серверами.
3. Добавить обработку статусов занятости, отказа, таймаута и ошибок маршрута.
4. Добавить диагностику доставки сигналов между серверами.
5. Проверить совместимость с текущими логами `CallDeliveryReport`.
## Что учесть
- Специальная диагностика установки звонков идёт через `CallDeliveryReport`.
- На production важно сохранять поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`.
- Межсерверные звонки не должны ломать текущий односерверный сценарий.
## Документы, которые потом нужно обновить
- `docs/API/`, если добавляются или меняются операции сигнализации;
- документы по звонкам/диагностике, если они будут выделены отдельно;
- deploy-документы, если появятся новые параметры TURN/server-to-server маршрутизации.
## Статус
Отложено до этапа децентрализации.
@@ -1,23 +0,0 @@
# Односерверный production-режим
## Зачем
Перед выкладкой репозитория на GitHub и запуском production нужно явно зафиксировать, что текущая стабильная версия работает как один основной сервер.
## Что считаем текущей нормой
- Один production-сервер обслуживает пользователей, сообщения, звонки и серверные данные.
- Децентрализованные сценарии не считаются обязательными для первого production-релиза.
- Межсерверная доставка сообщений, межсерверные звонки, realtime PDA/Solana sync и запись блокчейнов в Arweave вынесены в отдельные будущие задачи.
- Код и документация текущего production не должны создавать ожидание, что несколько серверов уже работают как единая realtime-сеть.
## Что сделать перед возвратом к децентрализации
1. Проверить актуальные документы по API, blockchain, DM и deploy.
2. Выделить минимальный протокол server-to-server взаимодействия.
3. Решить, какие данные остаются локальными, какие реплицируются между серверами, а какие записываются во внешнее долговременное хранилище.
4. После изменения API, blockchain-форматов или DM-протокола обновить соответствующие документы по правилам проекта.
## Статус
Отложено. Текущий production работает как один сервер.
@@ -0,0 +1,6 @@
Сделать все эти: друг, близкий друг, и статусы типо сияет, точно сияет, реальный человек и т.д.
и как вариант не реальный человек тк
- украли акаунт
- сразу был нереальным мошенником
- умер
@@ -1,275 +0,0 @@
# Новая логика контента в блокчейне SHiNE
## Зачем это нужно
Сейчас блокчейн SHiNE хорошо умеет хранить обычные сообщения, ответы, лайки и связи между людьми.
Новая модель добавляет поверх этого более понятный смысл контента:
- обычный текст;
- упражнение;
- услуга / процедура;
- курс;
- стартовая страница канала (`entrypoint`).
Это нужно для того, чтобы канал стал не просто лентой постов, а полноценным пространством знаний, практик, услуг и сообществ.
## Что меняется для людей
### 1. В канале появятся понятные виды материалов
Сообщение можно будет создать не только как обычный текст, но и как:
- упражнение;
- услугу / процедуру;
- курс;
- стартовую страницу канала.
Смысл в том, что приложение и сервер будут понимать, что это за материал, а не просто показывать любой текст одинаково.
### 2. У канала будет стартовая страница
У канала появится отдельное стартовое сообщение `entrypoint`.
Это не курс и не оглавление, а именно главная точка входа в канал:
- короткое объяснение, о чём канал;
- описание структуры;
- ссылки на нужные материалы;
- удобное начало для новых людей.
У канала в каждый момент времени будет только одна актуальная стартовая страница.
Если её исправляют, то сохраняется история версий.
Если её удаляют, для интерфейса считается, что стартовой страницы у канала сейчас нет.
### 3. Курс, упражнение и услуга / процедура будут отличаться по смыслу
Это важно для логики и статистики.
- `Упражнение` — то, что человек может делать много раз.
- `Услуга / процедура` — то, что тоже можно проходить много раз, но обычно с участием другого человека.
- `Курс` — то, что можно начать, закончить или бросить.
За счёт этого сервер сможет честно считать активность, а интерфейс сможет показывать человеку именно те действия, которые подходят к данному типу материала.
### 4. Появятся статусные действия
На контент можно будет не только ответить или поставить лайк, но и отметить свой путь:
- сделал один раз;
- заинтересовался и рассматривает;
- начал;
- закончил / освоил / знаю;
- бросил.
При этом:
- для упражнений и услуг / процедур будет отдельно считаться, сколько раз человек сделал / прошёл;
- для упражнений и курсов будет храниться текущий статус.
Текущий статус определяется просто:
- последнее статусное действие и считается актуальным.
Например:
- если последнее действие “заинтересовался и рассматривает”, значит человек присматривается, но ещё не начал;
- если последнее действие `started`, значит материал сейчас в процессе;
- если последнее действие `abandoned`, значит человек бросил;
- если последнее действие `completed`, значит для системы он завершил / освоил материал.
### 5. К действиям можно добавлять живой текст
Практически любое статусное действие можно будет сопровождать коротким комментарием.
Например:
- “Начал изучать, потому что давно хотел разобраться”;
- “Бросил, пока нет времени”;
- “Прошёл процедуру, стало заметно легче”.
Это важно, потому что сам блокчейн будет хранить не только формальный статус, но и живую человеческую причину или заметку.
### 6. Появится подтверждение статуса другими людьми
Отдельный человек сможет подтвердить чей-то статус.
Примеры:
- подтвердить, что человек действительно занимался;
- подтвердить, что он реально прошёл услугу;
- подтвердить, что он освоил материал.
Подтверждение — это не замена статуса, а отдельное мнение / свидетельство со стороны.
### 7. Появится отдельный тип «мнение»
На любое сообщение можно будет ответить не только обычным ответом, но и специальным типом ответа: `мнение`.
Это по сути тоже текстовый ответ, но с отдельным смыслом:
- это отзыв;
- это оценка;
- это мнение о материале;
- это явная метка для будущего анализа нейронками.
То есть:
- обычный ответ нужен для разговора;
- `мнение` нужно для отзыва, оценки и анализа реакции людей.
## Что остаётся как раньше
### Комментарии
Обычные ответы на сообщения остаются.
То есть обсуждение материалов не ломается и не меняется концептуально.
### Лайки контента
Лайк на сообщение, курс, упражнение или услугу остаётся обычной реакцией на конкретный блок.
### Лайк пользователю
Лайк пользователю не будет считаться реакцией на сообщение.
Он относится к графу связей между людьми.
Это удобно, потому что:
- лайк человека — это отношение к человеку;
- лайк материала — это отношение к контенту.
## Сообщество вокруг канала
Канал сможет работать не только как лента, но и как сообщество.
Для этого появятся простые действия:
- заявка на вступление;
- самостоятельный выход;
- принятие;
- исключение.
Сервер сможет понимать:
- кто только подал заявку;
- кто уже принят;
- кто вышел;
- кто был исключён.
## Личный канал и лента достижений
У каждого человека по смыслу появляется два важных пространства:
- канал его обычных постов;
- отдельная лента его тренировок и достижений.
В обычном канале человек сможет:
- писать посты;
- делиться мыслями;
- публиковать материалы;
- обсуждать темы как раньше.
А в ленте достижений будут видны его реальные действия:
- какие упражнения он делал;
- какие услуги / процедуры проходил;
- какие курсы его заинтересовали;
- какие курсы он начал;
- какие курсы он закончил;
- что он бросил.
То есть блокчейн SHiNE сможет хранить не только слова человека, но и его путь, активность и историю практики.
## Что смогут делать авторы контента
Создатели контента в своих каналах смогут публиковать не только обычные посты, но и:
- упражнения;
- курсы;
- стартовую страницу канала;
- услуги / процедуры, которые они оказывают.
Это превращает канал в сочетание:
- блога;
- базы знаний;
- пространства обучения;
- каталога услуг и практик.
## Что увидит человек в интерфейсе
На специальных сообщениях в UI можно будет показывать отдельные кнопки действий.
Например:
- `Выполнил упражнение`
- `Прошёл процедуру`
- `Заинтересовало`
- `Начал курс`
- `Закончил курс`
То есть материал можно будет не просто прочитать, а сразу отметить реальное действие.
Также при ответе на любое сообщение можно будет выбрать:
- обычный ответ;
- `мнение / отзыв`.
## Как будет работать лента достижений
Если кто-то зайдёт в твою ленту достижений, он сможет:
- прочитать, что ты делал;
- оставить мнение / отзыв;
- подтвердить, что это действительно было.
Это даёт основу для мягкой “сертификации” внутри SHiNE.
Например:
- человек прошёл курс и получил подтверждения;
- человек прошёл процедуру и получил отзыв;
- человек регулярно делает упражнения, и это видно в его истории.
Так постепенно у пользователя появляется не только лента постов, но и лента достижений, подтверждений и репутации.
## Ссылки внутри SHiNE
Для переходов между материалами вводятся простые внутренние адреса:
- обычная ссылка: `SHiNE/alice-001/157`
- особополная ссылка: `SHiNE/alice-001/157/ХЭШ`
Первая форма — основная и каноническая.
Вторая нужна там, где хочется добавить ещё и точную проверку по хэшу.
## Что это даёт в итоге
После внедрения новая блокчейн-логика позволит:
- строить каналы как структурированные пространства, а не просто как поток постов;
- выделять упражнения, услуги и курсы как отдельные сущности;
- показывать стартовую страницу канала;
- хранить путь человека по материалу;
- хранить отдельную ленту его действий и достижений;
- считать активность и статусы;
- подтверждать результаты другими людьми;
- развивать сообщество вокруг канала.
И самое важное: всё это можно добавить как расширение уже существующего блокчейна SHiNE, не разрушая старую модель сообщений.
## Отдельный вопрос для будущего
Отзывы о людях как о людях — полезная идея, но её стоит дополнительно обдумать.
Например, на вкладке связей в будущем можно:
- писать человеку отзыв;
- смотреть все отзывы о человеке;
- выводить сначала отзывы близких друзей, родственников, друзей и контактов, а уже потом остальные.
Но этот слой нужно делать осторожно, чтобы он не стал слишком жёстким или неприятным для людей.
Поэтому отзывы о людях как отдельная социальная механика требуют дополнительного обсуждения и проектирования.
@@ -1,670 +0,0 @@
# ТЗ: новая контентная модель блокчейна SHiNE
## Статус документа
Этот документ описывает предлагаемые новые типы блоков и правила их обработки.
Цель:
- добавить новую семантику контента;
- не ломать существующие блоки `type=0..4`;
- внедрить всё как расширение блокчейна за счёт новых форматов.
Документ является проектным ТЗ на реализацию в сервере, БД, API чтения и UI.
## 1. Базовые принципы
### 1.1. Совместимость
Старые типы не меняются:
- `type=0` — TECH
- `type=1` — TEXT
- `type=2` — REACTION
- `type=3` — CONNECTION
- `type=4` — USER_PARAM
Новые сущности и действия добавляются только как новые `type` и новые `body`.
Это означает:
- старые блоки продолжают читаться как раньше;
- старые `TEXT_POST`, `TEXT_REPLY`, `REACTION_LIKE` и остальные форматы не ломаются;
- существующий блокчейн остаётся валидным;
- новый функционал появляется только там, где клиент и сервер умеют его понимать.
### 1.2. Общая стратегия
Новая модель делится на четыре слоя:
1. контентные сущности;
2. текстовые отзывы и мнения;
3. статусные действия пользователей;
4. community-события вокруг канала.
### 1.3. Редактирование и удаление
Для новых контентных сущностей сохраняется действующий принцип SHiNE:
- редактирование всегда ссылается на оригинальный блок;
- тип сущности edit не меняет;
- удаление выполняется через `edit` с пустым текстом;
- отдельный `DELETE`-подтип не вводится.
Это правило особенно важно для:
- `plain_text`
- `exercise`
- `service`
- `course`
- `entrypoint`
В пользовательских текстах и UI желательно использовать русские названия:
- обычный текст;
- упражнение;
- услуга / процедура;
- курс;
- стартовое сообщение канала.
## 2. Канонические внутренние ссылки
В новой модели поддерживаются только две формы внутренней ссылки:
- каноническая: `SHiNE/<blockchainName>/<blockNumber>`
- особополная: `SHiNE/<blockchainName>/<blockNumber>/<blockHash>`
Примеры:
- `SHiNE/alice-001/157`
- `SHiNE/alice-001/157/abcd1234...`
Правила:
- канонической считается именно короткая форма без хэша;
- форма с хэшем используется как усиленный вариант для точной проверки;
- внутри UI и серверной логики ссылка должна приводиться как минимум к паре:
- `blockchainName`
- `blockNumber`
- если хэш присутствует, он участвует в дополнительной валидации ссылки.
## 3. Новые контентные сущности
## 3.1. Новый `type=5``CONTENT`
Назначение:
- хранение новых смысловых материалов канала;
- сохранение линии канала;
- поддержка edit-версий и логического удаления.
### 3.1.1. Подтипы `CONTENT`
- `subType=10``CONTENT_PLAIN`
- `subType=11``CONTENT_EDIT_PLAIN`
- `subType=20``CONTENT_EXERCISE`
- `subType=21``CONTENT_EDIT_EXERCISE`
- `subType=30``CONTENT_SERVICE`
- `subType=31``CONTENT_EDIT_SERVICE`
- `subType=40``CONTENT_COURSE`
- `subType=41``CONTENT_EDIT_COURSE`
- `subType=50``CONTENT_ENTRYPOINT`
- `subType=51``CONTENT_EDIT_ENTRYPOINT`
### 3.1.2. Семантика подтипов
- `CONTENT_PLAIN` — обычный текст нового поколения.
- `CONTENT_EXERCISE` — упражнение, которое можно выполнять многократно.
- `CONTENT_SERVICE` — услуга / процедура, которую можно проходить многократно.
- `CONTENT_COURSE` — курс / оглавление.
- `CONTENT_ENTRYPOINT` — стартовое сообщение канала.
### 3.1.3. Почему `entrypoint` отдельный тип
`entrypoint` не считается курсом.
Это отдельная сущность, потому что:
- она описывает вход в канал;
- по ней нельзя делать `started / completed / abandoned`;
- у канала в каждый момент времени должна быть только одна актуальная стартовая страница.
### 3.1.4. Ограничение на `entrypoint`
Для одного канала допускается только один исходный блок `CONTENT_ENTRYPOINT`.
Правила:
- если entrypoint уже существует, создать второй нельзя;
- изменять можно только через `CONTENT_EDIT_ENTRYPOINT`;
- если entrypoint логически удалён, UI должен считать, что стартовой страницы больше нет;
- исторический блок при этом остаётся в цепочке.
### 3.1.5. Формат body для `CONTENT_*`
Для `version=1` рекомендуется использовать формат, максимально совместимый по логике с текущими `TEXT_POST` / `TEXT_EDIT_POST`.
#### Создающие блоки
Для:
- `CONTENT_PLAIN`
- `CONTENT_EXERCISE`
- `CONTENT_SERVICE`
- `CONTENT_COURSE`
- `CONTENT_ENTRYPOINT`
body:
```text
ContentLineBody_v1
- lineCode: int32
- prevLineNumber: int32
- prevLineHash32: [32]
- thisLineNumber: int32
- textLenBytes: uint16
- text UTF-8
```
#### Edit-блоки
Для:
- `CONTENT_EDIT_PLAIN`
- `CONTENT_EDIT_EXERCISE`
- `CONTENT_EDIT_SERVICE`
- `CONTENT_EDIT_COURSE`
- `CONTENT_EDIT_ENTRYPOINT`
body:
```text
ContentEditBody_v1
- lineCode: int32
- prevLineNumber: int32
- prevLineHash32: [32]
- thisLineNumber: int32
- toBlockGlobalNumber: int32
- toBlockHash32: [32]
- textLenBytes: uint16
- text UTF-8
```
Правила:
- edit всегда ссылается на оригинальный блок соответствующего типа;
- `toBlockchainName` в edit не хранится;
- `textLen=0` означает логическое удаление содержимого;
- тип исходной сущности edit не меняет.
### 3.1.6. Что считается комментарием
Комментарии не требуют нового формата.
Для обсуждения новых контентных сущностей продолжают использоваться уже существующие:
- `TEXT_REPLY`
- `TEXT_EDIT_REPLY`
Это позволяет не ломать старую reply-механику и reuse текущую модель тредов.
## 4. Текстовые отзывы
## 4.1. Новый `type=6``TEXT_RATING`
Назначение:
- текстовая оценка / отзыв на объект;
- без числовой шкалы;
- с возможностью редактирования и логического удаления.
Смысл `TEXT_RATING`:
- это текст;
- это специальный отзыв / мнение / оценка;
- это явный сигнал, что перед нами не просто комментарий, а осмысленный отзыв;
- в будущем это поле можно отдельно анализировать нейронками.
### 4.1.1. Подтипы
- `subType=10``TEXT_RATING_POST`
- `subType=11``TEXT_RATING_EDIT`
### 4.1.2. Где разрешён `TEXT_RATING_POST`
Разрешён на target:
- `HEADER` пользователя;
- контентный блок `type=5`;
- при необходимости в будущем — на другие target-блоки по отдельному решению.
Сейчас в данном ТЗ:
- отзыв / оценка на пользователя — да;
- отзыв / оценка на контент — да;
- отзыв / лайк на канал целиком — не вводится, только оставляется как будущая возможность.
### 4.1.3. Где и как используется `TEXT_RATING_POST`
`TEXT_RATING_POST` можно создавать:
- как отзыв на контентный блок;
- как отзыв на пользователя через target на `HEADER`;
- как специальный ответ вместо обычного комментария.
Практическое правило для UI:
- при ответе на любое сообщение пользователь может выбрать:
- обычный ответ;
- `мнение / отзыв`.
### 4.1.4. Формат body
#### Создание
```text
TextRatingBody_v1
- toBlockchainNameLen: uint8
- toBlockchainName UTF-8
- toBlockGlobalNumber: int32
- toBlockHash32: [32]
- textLenBytes: uint16
- text UTF-8
```
#### Редактирование
```text
TextRatingEditBody_v1
- toBlockGlobalNumber: int32
- toBlockHash32: [32]
- textLenBytes: uint16
- text UTF-8
```
Правила:
- edit ссылается на оригинальный `TEXT_RATING_POST`;
- пустой текст в edit означает логическое удаление отзыва.
## 5. Статусные действия и накопительные события
## 5.1. Новый `type=7``STATUS_ACTION`
Назначение:
- хранение действий пользователя по отношению к контенту;
- вычисление текущего статуса;
- накопительный учёт повторных прохождений;
- подтверждение статусов другими людьми.
### 5.1.1. Подтипы
- `subType=10``STATUS_DONE_ONCE`
- `subType=20``STATUS_INTERESTED`
- `subType=30``STATUS_STARTED`
- `subType=40``STATUS_COMPLETED`
- `subType=50``STATUS_ABANDONED`
- `subType=60``STATUS_CONFIRMED`
### 5.1.2. Матрица допустимости по контенту
`STATUS_DONE_ONCE` разрешён только для:
- `CONTENT_EXERCISE`
- `CONTENT_SERVICE`
`STATUS_INTERESTED`, `STATUS_STARTED`, `STATUS_COMPLETED`, `STATUS_ABANDONED` разрешены только для:
- `CONTENT_EXERCISE`
- `CONTENT_COURSE`
`CONTENT_ENTRYPOINT` не поддерживает:
- `interested`
- `started`
- `completed`
- `abandoned`
### 5.1.3. Как считать текущее состояние
Для пары:
- `actorLogin`
- `targetBlock`
актуальным статусом считается последнее по времени статусное событие из набора:
- `STATUS_INTERESTED`
- `STATUS_STARTED`
- `STATUS_COMPLETED`
- `STATUS_ABANDONED`
Следствия:
- у одного пользователя по одному объекту в каждый момент времени только один актуальный статус;
- если последним пришёл `interested`, статус считается “заинтересовался / рассматривает, но ещё не начал”;
- если последним пришёл `started`, статус считается “в процессе”;
- если последним пришёл `completed`, статус считается “завершён / освоен / знаю”;
- если последним пришёл `abandoned`, статус считается “брошен”.
### 5.1.4. Как считать количество прохождений
`STATUS_DONE_ONCE` не меняет текущий статус.
Он считается отдельно как накопительное событие.
Сервер должен уметь считать:
- сколько раз пользователь сделал упражнение;
- сколько раз пользователь прошёл услугу / процедуру.
### 5.1.5. Дополнительный текст действия
Каждое действие `STATUS_*` может содержать дополнительный текст-комментарий.
Примеры:
- как именно делал упражнение;
- чем заинтересовал курс;
- с какими мыслями начал курс;
- почему бросил;
- что именно подтверждает подтверждающий человек.
### 5.1.6. Подтверждение статуса
`STATUS_CONFIRMED` разрешён только на target-статусы:
- `STATUS_DONE_ONCE`
- `STATUS_INTERESTED`
- `STATUS_STARTED`
- `STATUS_COMPLETED`
- `STATUS_ABANDONED`
Это значит:
- подтверждение не ставится прямо на курс или упражнение;
- подтверждение ставится на конкретный статусный блок другого человека.
Подтверждение:
- не меняет основной статус автора;
- не меняет счётчик `done_once`;
- хранится как отдельное мнение / свидетельство.
### 5.1.7. Формат body
Для `STATUS_DONE_ONCE`, `STATUS_INTERESTED`, `STATUS_STARTED`, `STATUS_COMPLETED`, `STATUS_ABANDONED`:
```text
StatusActionBody_v1
- toBlockchainNameLen: uint8
- toBlockchainName UTF-8
- toBlockGlobalNumber: int32
- toBlockHash32: [32]
- noteLenBytes: uint16
- note UTF-8
```
Для `STATUS_CONFIRMED`:
```text
StatusConfirmBody_v1
- toBlockchainNameLen: uint8
- toBlockchainName UTF-8
- toBlockGlobalNumber: int32
- toBlockHash32: [32]
- noteLenBytes: uint16
- note UTF-8
```
На уровне бинарного формата тело можно оставить одинаковым.
Различие задаётся `subType` и правилами валидации target.
## 6. Community-события
## 6.1. Новый `type=8``COMMUNITY_EVENT`
Назначение:
- заявки в сообщество;
- выход из сообщества;
- принятие;
- исключение.
### 6.1.1. Подтипы
- `subType=10``COMMUNITY_JOIN_REQUEST`
- `subType=20``COMMUNITY_LEAVE`
- `subType=30``COMMUNITY_ACCEPT`
- `subType=40``COMMUNITY_REMOVE`
### 6.1.2. Базовая логика
`COMMUNITY_JOIN_REQUEST`
- создаёт пользователь;
- target — `CONTENT_ENTRYPOINT` канала;
- может содержать текст заявки.
`COMMUNITY_LEAVE`
- создаёт сам участник;
- target — `CONTENT_ENTRYPOINT` канала;
- подтверждение не требуется;
- может содержать текст.
`COMMUNITY_ACCEPT`
- создаёт владелец канала;
- target — конкретный блок `COMMUNITY_JOIN_REQUEST`;
- может содержать текст.
`COMMUNITY_REMOVE`
- создаёт владелец канала;
- target — `CONTENT_ENTRYPOINT` канала;
- body дополнительно хранит `subjectLogin`, кого исключили;
- может содержать текст.
### 6.1.3. Текущее членство
Пользователь считается текущим участником сообщества, если:
- у него есть хотя бы одно принятие в это сообщество;
- после этого принятия нет более позднего:
- `COMMUNITY_LEAVE`
- `COMMUNITY_REMOVE`
Заявка сама по себе членство не создаёт.
### 6.1.4. Формат body
Для `JOIN_REQUEST` и `LEAVE`:
```text
CommunityActionBody_v1
- toBlockchainNameLen: uint8
- toBlockchainName UTF-8
- toBlockGlobalNumber: int32
- toBlockHash32: [32]
- noteLenBytes: uint16
- note UTF-8
```
Для `ACCEPT`:
```text
CommunityAcceptBody_v1
- toBlockchainNameLen: uint8
- toBlockchainName UTF-8
- toBlockGlobalNumber: int32
- toBlockHash32: [32]
- noteLenBytes: uint16
- note UTF-8
```
Для `REMOVE`:
```text
CommunityRemoveBody_v1
- toBlockchainNameLen: uint8
- toBlockchainName UTF-8
- toBlockGlobalNumber: int32
- toBlockHash32: [32]
- subjectLoginLen: uint8
- subjectLogin ASCII
- noteLenBytes: uint16
- note UTF-8
```
## 7. Что остаётся на старых типах
### 7.0. Обычный канал и лента достижений
На уровне продукта рекомендуется различать:
- обычный канал постов пользователя;
- отдельную ленту его действий и достижений.
В обычном канале пользователь:
- пишет посты;
- публикует материалы;
- общается и обсуждает.
В ленте достижений видны события:
- какие упражнения он делал;
- какие услуги / процедуры проходил;
- какие курсы его заинтересовали;
- какие курсы он начал;
- какие курсы он завершил;
- что он бросил.
В данном ТЗ эта модель фиксируется как продуктовая логика.
Конкретный способ хранения можно реализовать:
- либо отдельным специальным каналом;
- либо отдельным режимом чтения по статусным блокам.
### 7.1. Лайк пользователю
Лайк пользователю не вводится как `REACTION`.
Он остаётся в слое социальных связей:
- через `CONNECTION`
- как будущий отдельный подтип связи
В этом ТЗ сам новый подтип связи не описывается детально.
Нужно только зафиксировать правило:
- лайк человека относится к графу связей, а не к реакции на блок.
### 7.2. Лайк контента
Лайк на:
- `CONTENT_PLAIN`
- `CONTENT_EXERCISE`
- `CONTENT_SERVICE`
- `CONTENT_COURSE`
- `CONTENT_ENTRYPOINT`
может использовать уже существующий:
- `REACTION_LIKE`
- `REACTION_UNLIKE`
Отдельный новый формат для лайка контента не нужен.
### 7.3. Канал целиком
В текущем ТЗ не вводятся:
- отзыв на канал целиком;
- лайк канала целиком.
Это оставляется как будущая возможность.
### 7.4. Отзывы о людях
Отзывы о человеке как о человеке в текущем ТЗ допустимы через `TEXT_RATING` на `HEADER`.
Но продуктовую модель их показа нужно отдельно продумать.
Направление для будущего:
- просмотр отзывов о человеке на вкладке связей;
- приоритетный вывод отзывов от близких друзей, родственников, друзей и контактов;
- затем вывод остальных отзывов.
Эта тема полезна, но требует дополнительной осторожной проработки с точки зрения UX и социальных рисков.
## 8. Требования к серверу
Сервер после внедрения должен уметь:
1. Валидировать новые `type=5..8`.
2. Хранить новые блоки без ломки старого чтения.
3. Определять текущий статус пользователя по объекту:
- `interested`
- `started`
- `completed`
- `abandoned`
4. Считать накопительные события `done_once` для:
- `exercise`
- `service`
5. Считать подтверждения статусов.
6. Определять единственный актуальный `entrypoint` канала.
7. Определять текущее членство в сообществе канала.
8. Поддерживать внутренние ссылки вида:
- `SHiNE/<blockchainName>/<blockNumber>`
- `SHiNE/<blockchainName>/<blockNumber>/<blockHash>`
## 9. Требования к UI
UI после внедрения должен уметь:
1. Показывать разные карточки для:
- текста
- упражнения
- услуги
- курса
- entrypoint
2. Показывать стартовую страницу канала, если `entrypoint` существует.
3. Не показывать entrypoint, если он логически удалён.
4. Давать человеку только допустимые действия по типу материала.
5. Показывать:
- текущий статус;
- количество `done_once`;
- подтверждения статуса.
6. Показывать отдельные действия-кнопки на специальных блоках, например:
- `Выполнил упражнение`
- `Прошёл процедуру`
- `Заинтересовало`
- `Начал курс`
- `Закончил курс`
7. При ответе на сообщение давать выбор:
- обычный ответ;
- `мнение / отзыв`.
8. Открывать внутренние ссылки SHiNE.
## 10. Вывод по совместимости
Предлагаемая модель реализуема без слома старого блокчейна.
Причина:
- старые `type=0..4` не меняются;
- новые сущности вводятся только как новые `type=5..8`;
- существующие `reply`, `like`, `edit`, `HEADER`, `CREATE_CHANNEL` и `CONNECTION` продолжают работать как раньше;
- старые клиенты смогут игнорировать новые типы как неизвестные;
- новые клиенты смогут постепенно включать поддержку нового функционала.
Итог:
- это расширение формата блокчейна;
- это не миграция со сломом старых блоков;
- это можно внедрять поэтапно.

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