SHA256
Compare commits
40
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
b686b12c6a | ||
|
|
e4b903b13c | ||
|
|
72f576a70e | ||
|
|
c4f2c7be63 | ||
|
|
75b75fd223 | ||
|
|
391b18a5ac | ||
|
|
8de0f8e87c | ||
|
|
adb0b76559 | ||
|
|
b6df25c02c | ||
|
|
620186db1c | ||
|
|
21fac8046e | ||
|
|
de7c35f587 | ||
|
|
a23c979045 | ||
|
|
13da7287b1 | ||
|
|
e8398df9fc | ||
|
|
8303682bd7 | ||
|
|
8125d1a124 | ||
|
|
110ff5e12e | ||
|
|
24afcba20a | ||
|
|
d5f41d12e8 | ||
|
|
d640dd66e2 | ||
|
|
05bd9f57d6 | ||
|
|
60ab476ec7 | ||
|
|
bcae285ab3 | ||
|
|
cd67176b41 | ||
|
|
98eba54621 | ||
|
|
c530627078 | ||
|
|
f961947e1e | ||
|
|
e9e6628b21 | ||
|
|
c7684d6fe1 | ||
|
|
7d475ab403 | ||
|
|
143adcbbe4 | ||
|
|
ca8b6a33ba | ||
|
|
2f73cdb0f9 | ||
|
|
d1e06d9ef1 | ||
|
|
0ed78e2202 | ||
|
|
64818c586b | ||
|
|
809c216a5c | ||
|
|
415a40e436 | ||
|
|
d233f1d73f |
@@ -36,7 +36,8 @@ public final class BodyRecordParser {
|
||||
case TextBody.KEY -> {
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||
yield new TextLineBody(subType, version, bodyBytes);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,12 @@ public final class MsgSubType {
|
||||
*/
|
||||
public static final short TEXT_REPOST = 30;
|
||||
|
||||
/**
|
||||
* CHANNEL_META — скрытый технический снимок профиля канала.
|
||||
* Имеет hasLine, использует body как POST: line-поля + UTF-8 текст.
|
||||
*/
|
||||
public static final short TEXT_CHANNEL_META = 70;
|
||||
|
||||
/* ===================== REACTION (msg_type=2) ===================== */
|
||||
|
||||
/** Лайк (LIKE). */
|
||||
|
||||
+6
-7
@@ -19,7 +19,7 @@ import java.util.Objects;
|
||||
* [1] channelNameLen
|
||||
* [N] channelName UTF-8
|
||||
* [2] channelDescriptionLen
|
||||
* [M] channelDescription UTF-8 (0..200 bytes)
|
||||
* [M] channelDescription UTF-8 (0..2048 bytes)
|
||||
* [2] channelTypeCode (uint16)
|
||||
* [2] channelTypeVersion (uint16)
|
||||
*/
|
||||
@@ -38,7 +38,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
|
||||
private static final byte[] ZERO32 = new byte[32];
|
||||
private static final int MAX_NAME_LENGTH = 32;
|
||||
private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
|
||||
public static final int MAX_DESCRIPTION_UTF8_LEN = 2048;
|
||||
|
||||
public final short subType;
|
||||
public final short version;
|
||||
@@ -88,7 +88,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
|
||||
int descriptionLen = Short.toUnsignedInt(bb.getShort());
|
||||
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
|
||||
}
|
||||
if (bb.remaining() != descriptionLen + 4) {
|
||||
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
|
||||
@@ -145,7 +145,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
String normalizedDescription = normalizeDescription(channelDescription);
|
||||
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
|
||||
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
|
||||
}
|
||||
|
||||
int typeCode = Short.toUnsignedInt(channelTypeCode);
|
||||
@@ -166,7 +166,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
|
||||
private static String normalizeDescription(String value) {
|
||||
if (value == null) return "";
|
||||
return value.trim().replaceAll("\\s+", " ");
|
||||
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -177,7 +177,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
}
|
||||
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
|
||||
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||
throw new IllegalArgumentException("channelDescription utf8 len must be <=2048");
|
||||
}
|
||||
|
||||
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + 2 + descriptionUtf8.length + 4;
|
||||
@@ -204,4 +204,3 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||
@Override
|
||||
public int lineSeq() { return thisLineNumber; }
|
||||
}
|
||||
|
||||
|
||||
+18
-10
@@ -17,10 +17,11 @@ import java.util.Objects;
|
||||
* - POST (10)
|
||||
* - EDIT_POST (11)
|
||||
* - REPOST (30)
|
||||
* - CHANNEL_META (70)
|
||||
*
|
||||
* Формат bodyBytes (BigEndian):
|
||||
*
|
||||
* POST:
|
||||
* POST / CHANNEL_META:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
@@ -89,8 +90,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
int st = this.subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st);
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
@@ -133,7 +135,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
this.toBlockHash32 = null;
|
||||
}
|
||||
|
||||
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF));
|
||||
this.message = readStrictUtf8Len16(bb, "TextLineBody text",
|
||||
st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF));
|
||||
|
||||
ensureNoTail(bb, "TextLineBody");
|
||||
}
|
||||
@@ -155,8 +158,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST");
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META");
|
||||
}
|
||||
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
@@ -206,7 +210,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF))
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF))
|
||||
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
|
||||
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
@@ -231,10 +236,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
|
||||
} else {
|
||||
if (message == null || message.isBlank())
|
||||
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
|
||||
} else if (message == null || message.isBlank()) {
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
}
|
||||
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||
throw new IllegalArgumentException("POST must not contain target fields");
|
||||
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
|
||||
}
|
||||
|
||||
return this;
|
||||
@@ -251,7 +259,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
}
|
||||
|
||||
int cap;
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
|
||||
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
// EDIT_POST
|
||||
|
||||
@@ -19,8 +19,12 @@ public final class DatabaseInitializer {
|
||||
public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version";
|
||||
public static final int SCHEMA_VERSION_1 = 1;
|
||||
public static final int SCHEMA_VERSION_2 = 2;
|
||||
public static final int SCHEMA_VERSION_3 = 3;
|
||||
public static final int SCHEMA_VERSION_4 = 4;
|
||||
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";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -29,6 +33,7 @@ public final class DatabaseInitializer {
|
||||
public static final short TEXT_REPLY = 20;
|
||||
public static final short TEXT_EDIT_REPLY = 21;
|
||||
public static final short TEXT_REPOST = 30;
|
||||
public static final short TEXT_CHANNEL_META = 70;
|
||||
|
||||
public static final short REACTION_LIKE = 1;
|
||||
public static final short REACTION_UNLIKE = 2;
|
||||
@@ -82,6 +87,14 @@ public final class DatabaseInitializer {
|
||||
int currentVersion = readCurrentSchemaVersion(conn);
|
||||
if (currentVersion < SCHEMA_VERSION_2) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V2_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_2;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_3) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_3;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_4) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ public final class MsgSubType {
|
||||
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */
|
||||
public static final short TEXT_REPOST = 30;
|
||||
|
||||
/** CHANNEL_META — скрытый технический снимок профиля канала. */
|
||||
public static final short TEXT_CHANNEL_META = 70;
|
||||
|
||||
/* ===================== REACTION (msg_type=2) ===================== */
|
||||
|
||||
/** Лайк (LIKE). */
|
||||
|
||||
@@ -65,8 +65,12 @@ public final class ChannelNameStateDAO {
|
||||
channel_type_version,
|
||||
channel_root_block_number,
|
||||
channel_root_block_hash,
|
||||
created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
created_at_ms,
|
||||
ava_ar,
|
||||
ava_sha256,
|
||||
ava_size,
|
||||
meta_updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getSlug());
|
||||
@@ -79,6 +83,34 @@ public final class ChannelNameStateDAO {
|
||||
ps.setInt(8, entry.getChannelRootBlockNumber());
|
||||
ps.setBytes(9, entry.getChannelRootBlockHash());
|
||||
ps.setLong(10, entry.getCreatedAtMs());
|
||||
ps.setString(11, entry.getAvaAr() == null ? "" : entry.getAvaAr());
|
||||
ps.setString(12, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
|
||||
ps.setLong(13, Math.max(0L, entry.getAvaSize()));
|
||||
ps.setLong(14, Math.max(0L, entry.getMetaUpdatedAtMs()));
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateMeta(Connection c, ChannelNameStateEntry entry) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE channel_names_state
|
||||
SET display_name = ?,
|
||||
channel_description = ?,
|
||||
ava_ar = ?,
|
||||
ava_sha256 = ?,
|
||||
ava_size = ?,
|
||||
meta_updated_at_ms = ?
|
||||
WHERE owner_bch_name = ? AND channel_root_block_number = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getDisplayName() == null ? "" : entry.getDisplayName());
|
||||
ps.setString(2, entry.getChannelDescription() == null ? "" : entry.getChannelDescription());
|
||||
ps.setString(3, entry.getAvaAr() == null ? "" : entry.getAvaAr());
|
||||
ps.setString(4, entry.getAvaSha256() == null ? "" : entry.getAvaSha256());
|
||||
ps.setLong(5, Math.max(0L, entry.getAvaSize()));
|
||||
ps.setLong(6, Math.max(0L, entry.getMetaUpdatedAtMs()));
|
||||
ps.setString(7, entry.getOwnerBlockchainName());
|
||||
ps.setInt(8, entry.getChannelRootBlockNumber());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,11 @@ public final class CurrentUsersDAO {
|
||||
|
||||
/** Поиск по префиксу с внешним соединением. Соединение НЕ закрывает. */
|
||||
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix) throws SQLException {
|
||||
return searchByLoginPrefix(c, prefix, null);
|
||||
}
|
||||
|
||||
/** Поиск по префиксу с optional-фильтром server PDA. Соединение НЕ закрывает. */
|
||||
public List<CurrentUserEntry> searchByLoginPrefix(Connection c, String prefix, Boolean isServer) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
@@ -163,6 +168,7 @@ public final class CurrentUsersDAO {
|
||||
client_key
|
||||
FROM %s
|
||||
WHERE LOWER(login) LIKE ?
|
||||
AND (? IS NULL OR is_server = ?)
|
||||
ORDER BY login
|
||||
LIMIT 5
|
||||
""".formatted(CurrentUsersSql.usersSubquery("su"));
|
||||
@@ -171,6 +177,13 @@ public final class CurrentUsersDAO {
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, prefix.toLowerCase() + "%");
|
||||
if (isServer == null) {
|
||||
ps.setNull(2, Types.BOOLEAN);
|
||||
ps.setNull(3, Types.BOOLEAN);
|
||||
} else {
|
||||
ps.setBoolean(2, isServer);
|
||||
ps.setBoolean(3, isServer);
|
||||
}
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) result.add(mapRow(rs));
|
||||
}
|
||||
@@ -181,8 +194,13 @@ public final class CurrentUsersDAO {
|
||||
|
||||
/** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */
|
||||
public List<CurrentUserEntry> searchByLoginPrefix(String prefix) throws SQLException {
|
||||
return searchByLoginPrefix(prefix, null);
|
||||
}
|
||||
|
||||
/** Поиск по префиксу без внешнего соединения с optional-фильтром server PDA. */
|
||||
public List<CurrentUserEntry> searchByLoginPrefix(String prefix, Boolean isServer) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return searchByLoginPrefix(c, prefix);
|
||||
return searchByLoginPrefix(c, prefix, isServer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.entities.DmSyncPeerStateEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
public final class DmSyncPeerStateDAO {
|
||||
private static volatile DmSyncPeerStateDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private DmSyncPeerStateDAO() {}
|
||||
|
||||
public static DmSyncPeerStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (DmSyncPeerStateDAO.class) {
|
||||
if (instance == null) instance = new DmSyncPeerStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public DmSyncPeerStateEntry getOrCreate(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||
DmSyncPeerStateEntry existing = get(ownerLogin, remoteServerLogin);
|
||||
if (existing != null) {
|
||||
if (remoteServerUrl != null && !remoteServerUrl.isBlank()
|
||||
&& !remoteServerUrl.equals(existing.getRemoteServerUrl())) {
|
||||
touchRemoteUrl(ownerLogin, remoteServerLogin, remoteServerUrl);
|
||||
existing.setRemoteServerUrl(remoteServerUrl);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO dm_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
|
||||
ps.setLong(4, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
existing = get(ownerLogin, remoteServerLogin);
|
||||
if (existing == null) {
|
||||
throw new SQLException("Failed to create dm_sync_peer_state row");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
public void updateSuccess(
|
||||
String ownerLogin,
|
||||
String remoteServerLogin,
|
||||
String remoteServerUrl,
|
||||
long cursorStoredAtMs,
|
||||
String cursorMessageKey,
|
||||
boolean bootstrapCompleted
|
||||
) throws SQLException {
|
||||
long nowMs = System.currentTimeMillis();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO dm_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
|
||||
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||
remote_server_url = EXCLUDED.remote_server_url,
|
||||
cursor_stored_at_ms = EXCLUDED.cursor_stored_at_ms,
|
||||
cursor_message_key = EXCLUDED.cursor_message_key,
|
||||
bootstrap_completed = EXCLUDED.bootstrap_completed,
|
||||
last_sync_at_ms = EXCLUDED.last_sync_at_ms,
|
||||
last_error = NULL,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
|
||||
ps.setLong(4, Math.max(0L, cursorStoredAtMs));
|
||||
ps.setString(5, cursorMessageKey == null ? "" : cursorMessageKey);
|
||||
ps.setBoolean(6, bootstrapCompleted);
|
||||
ps.setLong(7, nowMs);
|
||||
ps.setLong(8, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException {
|
||||
long nowMs = System.currentTimeMillis();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO dm_sync_peer_state (
|
||||
owner_login, remote_server_login, remote_server_url,
|
||||
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?)
|
||||
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
|
||||
remote_server_url = EXCLUDED.remote_server_url,
|
||||
last_error = EXCLUDED.last_error,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
""")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
|
||||
ps.setString(4, truncate(error));
|
||||
ps.setLong(5, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private DmSyncPeerStateEntry get(String ownerLogin, String remoteServerLogin) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT owner_login, remote_server_login, remote_server_url,
|
||||
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
|
||||
last_sync_at_ms, last_error, updated_at_ms
|
||||
FROM dm_sync_peer_state
|
||||
WHERE LOWER(owner_login) = LOWER(?)
|
||||
AND LOWER(remote_server_login) = LOWER(?)
|
||||
LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, remoteServerLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
DmSyncPeerStateEntry e = new DmSyncPeerStateEntry();
|
||||
e.setOwnerLogin(rs.getString("owner_login"));
|
||||
e.setRemoteServerLogin(rs.getString("remote_server_login"));
|
||||
e.setRemoteServerUrl(rs.getString("remote_server_url"));
|
||||
e.setCursorStoredAtMs(rs.getLong("cursor_stored_at_ms"));
|
||||
e.setCursorMessageKey(rs.getString("cursor_message_key"));
|
||||
e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed"));
|
||||
long lastSyncAt = rs.getLong("last_sync_at_ms");
|
||||
e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAt);
|
||||
e.setLastError(rs.getString("last_error"));
|
||||
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||
return e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void touchRemoteUrl(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE dm_sync_peer_state
|
||||
SET remote_server_url = ?, updated_at_ms = ?
|
||||
WHERE LOWER(owner_login) = LOWER(?)
|
||||
AND LOWER(remote_server_login) = LOWER(?)
|
||||
""")) {
|
||||
ps.setString(1, remoteServerUrl);
|
||||
ps.setLong(2, System.currentTimeMillis());
|
||||
ps.setString(3, ownerLogin);
|
||||
ps.setString(4, remoteServerLogin);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static String truncate(String value) {
|
||||
if (value == null) return null;
|
||||
String clean = value.trim();
|
||||
if (clean.length() <= 500) return clean;
|
||||
return clean.substring(0, 500);
|
||||
}
|
||||
}
|
||||
@@ -416,6 +416,72 @@ public final class SignedMessagesDAO {
|
||||
}
|
||||
}
|
||||
|
||||
public SyncBatch listSyncBatch(
|
||||
String ownerLogin,
|
||||
long afterStoredAtMs,
|
||||
String afterMessageKey,
|
||||
int limit,
|
||||
int maxBytes
|
||||
) throws Exception {
|
||||
int safeLimit = Math.max(1, Math.min(limit, 500));
|
||||
int sqlLimit = safeLimit + 1;
|
||||
String safeAfterMessageKey = afterMessageKey == null ? "" : afterMessageKey;
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
SELECT
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
FROM %s
|
||||
WHERE (
|
||||
LOWER(target_login) = LOWER(?)
|
||||
OR (
|
||||
message_type IN (5, 6, 7, 8)
|
||||
AND (LOWER(from_login) = LOWER(?) OR LOWER(to_login) = LOWER(?))
|
||||
)
|
||||
)
|
||||
AND (
|
||||
created_at_ms > ?
|
||||
OR (created_at_ms = ? AND (? = '' OR message_key > ?))
|
||||
)
|
||||
ORDER BY created_at_ms ASC, message_key ASC
|
||||
LIMIT ?
|
||||
""".formatted(messagesTable());
|
||||
List<SignedMessageEntry> out = new ArrayList<>();
|
||||
boolean hasMore = false;
|
||||
int usedBytes = 0;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerLogin);
|
||||
ps.setString(2, ownerLogin);
|
||||
ps.setString(3, ownerLogin);
|
||||
ps.setLong(4, Math.max(0L, afterStoredAtMs));
|
||||
ps.setLong(5, Math.max(0L, afterStoredAtMs));
|
||||
ps.setString(6, safeAfterMessageKey);
|
||||
ps.setString(7, safeAfterMessageKey);
|
||||
ps.setInt(8, sqlLimit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
SignedMessageEntry row = mapRow(rs);
|
||||
byte[] raw = row.getRawBlock();
|
||||
int rowBytes = raw == null ? 0 : raw.length;
|
||||
if (out.size() >= safeLimit) {
|
||||
hasMore = true;
|
||||
break;
|
||||
}
|
||||
if (!out.isEmpty() && maxBytes > 0 && usedBytes + rowBytes > maxBytes) {
|
||||
hasMore = true;
|
||||
break;
|
||||
}
|
||||
out.add(row);
|
||||
usedBytes += rowBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new SyncBatch(out, hasMore, usedBytes);
|
||||
}
|
||||
}
|
||||
|
||||
private void upsertMessage(Connection c, SignedMessageEntry e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO %s (
|
||||
@@ -748,6 +814,8 @@ public final class SignedMessagesDAO {
|
||||
}
|
||||
}
|
||||
|
||||
public record SyncBatch(List<SignedMessageEntry> items, boolean hasMore, int rawBytes) {}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface SqlWork<T> {
|
||||
T run() throws Exception;
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.KeyEncodingUtil;
|
||||
import shine.db.entities.UserAccessServerRouteEntry;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DAO локальной routing-проекции access servers пользователей.
|
||||
*/
|
||||
public final class UserAccessServersCurrentDAO {
|
||||
|
||||
private static volatile UserAccessServersCurrentDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private UserAccessServersCurrentDAO() {}
|
||||
|
||||
public static UserAccessServersCurrentDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (UserAccessServersCurrentDAO.class) {
|
||||
if (instance == null) instance = new UserAccessServersCurrentDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public List<UserAccessServerRouteEntry> listByUserLogin(String userLogin) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return listByUserLogin(c, userLogin);
|
||||
}
|
||||
}
|
||||
|
||||
public List<UserAccessServerRouteEntry> listByUserLogin(Connection c, String userLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT user_login, server_login, server_url, server_client_key
|
||||
FROM user_access_servers_current
|
||||
WHERE LOWER(user_login) = LOWER(?)
|
||||
ORDER BY server_login
|
||||
""";
|
||||
List<UserAccessServerRouteEntry> result = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, userLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<String> listUserLoginsByServerLogin(String serverLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT user_login
|
||||
FROM user_access_servers_current
|
||||
WHERE LOWER(server_login) = LOWER(?)
|
||||
ORDER BY user_login
|
||||
""";
|
||||
List<String> result = new ArrayList<>();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, serverLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(rs.getString("user_login"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException {
|
||||
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
|
||||
entry.setUserLogin(rs.getString("user_login"));
|
||||
entry.setServerLogin(rs.getString("server_login"));
|
||||
entry.setServerUrl(rs.getString("server_url"));
|
||||
entry.setServerClientKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("server_client_key")));
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
+36
@@ -13,6 +13,10 @@ public class ChannelNameStateEntry {
|
||||
private int channelRootBlockNumber;
|
||||
private byte[] channelRootBlockHash;
|
||||
private long createdAtMs;
|
||||
private String avaAr;
|
||||
private String avaSha256;
|
||||
private long avaSize;
|
||||
private long metaUpdatedAtMs;
|
||||
|
||||
public String getSlug() {
|
||||
return slug;
|
||||
@@ -93,4 +97,36 @@ public class ChannelNameStateEntry {
|
||||
public void setCreatedAtMs(long createdAtMs) {
|
||||
this.createdAtMs = createdAtMs;
|
||||
}
|
||||
|
||||
public String getAvaAr() {
|
||||
return avaAr;
|
||||
}
|
||||
|
||||
public void setAvaAr(String avaAr) {
|
||||
this.avaAr = avaAr;
|
||||
}
|
||||
|
||||
public String getAvaSha256() {
|
||||
return avaSha256;
|
||||
}
|
||||
|
||||
public void setAvaSha256(String avaSha256) {
|
||||
this.avaSha256 = avaSha256;
|
||||
}
|
||||
|
||||
public long getAvaSize() {
|
||||
return avaSize;
|
||||
}
|
||||
|
||||
public void setAvaSize(long avaSize) {
|
||||
this.avaSize = avaSize;
|
||||
}
|
||||
|
||||
public long getMetaUpdatedAtMs() {
|
||||
return metaUpdatedAtMs;
|
||||
}
|
||||
|
||||
public void setMetaUpdatedAtMs(long metaUpdatedAtMs) {
|
||||
this.metaUpdatedAtMs = metaUpdatedAtMs;
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package shine.db.entities;
|
||||
|
||||
public class DmSyncPeerStateEntry {
|
||||
private String ownerLogin;
|
||||
private String remoteServerLogin;
|
||||
private String remoteServerUrl;
|
||||
private long cursorStoredAtMs;
|
||||
private String cursorMessageKey;
|
||||
private boolean bootstrapCompleted;
|
||||
private Long lastSyncAtMs;
|
||||
private String lastError;
|
||||
private long updatedAtMs;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
public String getRemoteServerLogin() { return remoteServerLogin; }
|
||||
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
|
||||
public String getRemoteServerUrl() { return remoteServerUrl; }
|
||||
public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; }
|
||||
public long getCursorStoredAtMs() { return cursorStoredAtMs; }
|
||||
public void setCursorStoredAtMs(long cursorStoredAtMs) { this.cursorStoredAtMs = cursorStoredAtMs; }
|
||||
public String getCursorMessageKey() { return cursorMessageKey; }
|
||||
public void setCursorMessageKey(String cursorMessageKey) { this.cursorMessageKey = cursorMessageKey; }
|
||||
public boolean isBootstrapCompleted() { return bootstrapCompleted; }
|
||||
public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; }
|
||||
public Long getLastSyncAtMs() { return lastSyncAtMs; }
|
||||
public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; }
|
||||
public String getLastError() { return lastError; }
|
||||
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package shine.db.entities;
|
||||
|
||||
/**
|
||||
* Локальная routing-проекция access server пользователя.
|
||||
*
|
||||
* Источник:
|
||||
* - user_access_servers_current
|
||||
*/
|
||||
public class UserAccessServerRouteEntry {
|
||||
|
||||
private String userLogin;
|
||||
private String serverLogin;
|
||||
private String serverUrl;
|
||||
private String serverClientKey;
|
||||
|
||||
public String getUserLogin() { return userLogin; }
|
||||
public void setUserLogin(String userLogin) { this.userLogin = userLogin; }
|
||||
|
||||
public String getServerLogin() { return serverLogin; }
|
||||
public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; }
|
||||
|
||||
public String getServerUrl() { return serverUrl; }
|
||||
public void setServerUrl(String serverUrl) { this.serverUrl = serverUrl; }
|
||||
|
||||
public String getServerClientKey() { return serverClientKey; }
|
||||
public void setServerClientKey(String serverClientKey) { this.serverClientKey = serverClientKey; }
|
||||
}
|
||||
@@ -15,7 +15,8 @@ public final class CurrentUsersSql {
|
||||
current_users.blockchain_name AS blockchain_name,
|
||||
current_users.client_key AS solana_key,
|
||||
current_users.blockchain_key AS blockchain_key,
|
||||
current_users.client_key AS client_key
|
||||
current_users.client_key AS client_key,
|
||||
current_users.is_server AS is_server
|
||||
FROM solana_user_pda_current current_users
|
||||
) %s
|
||||
""".formatted(alias);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor
|
||||
ON signed_messages(target_login, created_at_ms, message_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower
|
||||
ON signed_messages(LOWER(target_login), created_at_ms, message_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor
|
||||
ON signed_messages(from_login, created_at_ms, message_key)
|
||||
WHERE message_type IN (5, 6, 7, 8);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor
|
||||
ON signed_messages(to_login, created_at_ms, message_key)
|
||||
WHERE message_type IN (5, 6, 7, 8);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower
|
||||
ON signed_messages(LOWER(from_login), created_at_ms, message_key)
|
||||
WHERE message_type IN (5, 6, 7, 8);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower
|
||||
ON signed_messages(LOWER(to_login), created_at_ms, message_key)
|
||||
WHERE message_type IN (5, 6, 7, 8);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
remote_server_login TEXT NOT NULL,
|
||||
remote_server_url TEXT NOT NULL,
|
||||
cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
cursor_message_key TEXT NOT NULL DEFAULT '',
|
||||
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_sync_at_ms BIGINT,
|
||||
last_error TEXT,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, remote_server_login)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner
|
||||
ON dm_sync_peer_state(owner_login);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,15 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE channel_names_state
|
||||
ADD COLUMN IF NOT EXISTS ava_ar TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS ava_sha256 TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS ava_size BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS meta_updated_at_ms BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 4, 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, 2, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
@@ -536,7 +536,11 @@ CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||
channel_type_version INTEGER NOT NULL DEFAULT 1,
|
||||
channel_root_block_number INTEGER NOT NULL,
|
||||
channel_root_block_hash BYTEA NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
ava_ar TEXT NOT NULL DEFAULT '',
|
||||
ava_sha256 TEXT NOT NULL DEFAULT '',
|
||||
ava_size BIGINT NOT NULL DEFAULT 0,
|
||||
meta_updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
|
||||
@@ -652,6 +656,28 @@ CREATE INDEX IF NOT EXISTS idx_signed_messages_target
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_base
|
||||
ON signed_messages(base_key, message_type);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor
|
||||
ON signed_messages(target_login, created_at_ms, message_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower
|
||||
ON signed_messages(LOWER(target_login), created_at_ms, message_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor
|
||||
ON signed_messages(from_login, created_at_ms, message_key)
|
||||
WHERE message_type IN (5, 6, 7, 8);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor
|
||||
ON signed_messages(to_login, created_at_ms, message_key)
|
||||
WHERE message_type IN (5, 6, 7, 8);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower
|
||||
ON signed_messages(LOWER(from_login), created_at_ms, message_key)
|
||||
WHERE message_type IN (5, 6, 7, 8);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower
|
||||
ON signed_messages(LOWER(to_login), created_at_ms, message_key)
|
||||
WHERE message_type IN (5, 6, 7, 8);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_receipt_incoming
|
||||
ON signed_messages(target_login, receipt_ref_base_key)
|
||||
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
|
||||
@@ -672,6 +698,22 @@ CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
|
||||
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
||||
ON signed_message_session_delivery(session_id, delivered);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
|
||||
owner_login TEXT NOT NULL,
|
||||
remote_server_login TEXT NOT NULL,
|
||||
remote_server_url TEXT NOT NULL,
|
||||
cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
cursor_message_key TEXT NOT NULL DEFAULT '',
|
||||
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_sync_at_ms BIGINT,
|
||||
last_error TEXT,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (owner_login, remote_server_login)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner
|
||||
ON dm_sync_peer_state(owner_login);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_views_state (
|
||||
viewer_login TEXT NOT NULL,
|
||||
to_bch_name TEXT NOT NULL,
|
||||
|
||||
+4
@@ -88,6 +88,7 @@ import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DmSyncBatch_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||
@@ -100,6 +101,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||
@@ -198,6 +200,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
||||
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
||||
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
||||
Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()),
|
||||
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
|
||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||
@@ -278,6 +281,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
||||
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
||||
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
|
||||
Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class),
|
||||
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
|
||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||
|
||||
+87
-7
@@ -21,6 +21,7 @@ import server.sync.BlockchainResyncGuard;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import server.sync.AddBlockSyncService;
|
||||
@@ -277,6 +278,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
ChannelNameStateEntry channelNameStateEntry = null;
|
||||
ChannelNameStateEntry channelMetaUpdateEntry = null;
|
||||
Chat200CreateSeed chat200CreateSeed = null;
|
||||
if (block.body instanceof CreateChannelBody createChannelBody) {
|
||||
int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
|
||||
@@ -302,14 +304,36 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
String displayName = normalizedName;
|
||||
String channelDescription = createChannelBody.channelDescription == null
|
||||
? ""
|
||||
: ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
|
||||
String avaAr = "";
|
||||
String avaSha256 = "";
|
||||
long avaSize = 0L;
|
||||
long metaUpdatedAtMs = 0L;
|
||||
if (channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
|
||||
try {
|
||||
ChannelMetaTextParser.Parsed createMeta = ChannelMetaTextParser.parseStrict(channelDescription, normalizedName);
|
||||
displayName = createMeta.title();
|
||||
channelDescription = createMeta.description();
|
||||
avaAr = createMeta.avaAr();
|
||||
avaSha256 = createMeta.avaSha256();
|
||||
avaSize = createMeta.avaSize();
|
||||
metaUpdatedAtMs = block.timestamp * 1000L;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, e.getMessage(), serverLastNum, serverLastHashHex);
|
||||
}
|
||||
}
|
||||
|
||||
channelNameStateEntry = new ChannelNameStateEntry();
|
||||
channelNameStateEntry.setSlug(slug);
|
||||
channelNameStateEntry.setDisplayName(normalizedName);
|
||||
channelNameStateEntry.setChannelDescription(
|
||||
createChannelBody.channelDescription == null
|
||||
? ""
|
||||
: ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription)
|
||||
);
|
||||
channelNameStateEntry.setDisplayName(displayName);
|
||||
channelNameStateEntry.setChannelDescription(channelDescription);
|
||||
channelNameStateEntry.setAvaAr(avaAr);
|
||||
channelNameStateEntry.setAvaSha256(avaSha256);
|
||||
channelNameStateEntry.setAvaSize(avaSize);
|
||||
channelNameStateEntry.setMetaUpdatedAtMs(metaUpdatedAtMs);
|
||||
channelNameStateEntry.setOwnerLogin(login);
|
||||
channelNameStateEntry.setOwnerBlockchainName(blockchainName);
|
||||
channelNameStateEntry.setChannelTypeCode(channelTypeCode);
|
||||
@@ -329,6 +353,39 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
chat200CreateSeed.chatTitle = channelNameStateEntry.getChannelDescription();
|
||||
chat200CreateSeed.updatedAtMs = block.timestamp * 1000L;
|
||||
}
|
||||
} else if ((block.type & 0xFFFF) == 1
|
||||
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|
||||
&& block.body instanceof TextLineBody metaBody) {
|
||||
int lineCode = metaBody.lineCode();
|
||||
if (lineCode <= 0) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_meta_line", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
ExistingChannelState existingChannel;
|
||||
try {
|
||||
existingChannel = loadExistingChannelState(blockchainName, lineCode);
|
||||
} catch (Exception e) {
|
||||
log.error("AddBlock: channel_meta_lookup_failed (login={}, blockchainName={}, lineCode={})",
|
||||
login, blockchainName, lineCode, e);
|
||||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
if (existingChannel == null) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "channel_not_found", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
ChannelMetaTextParser.Parsed parsedMeta;
|
||||
try {
|
||||
parsedMeta = ChannelMetaTextParser.parseStrict(metaBody.message, existingChannel.slug);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, e.getMessage(), serverLastNum, serverLastHashHex);
|
||||
}
|
||||
channelMetaUpdateEntry = new ChannelNameStateEntry();
|
||||
channelMetaUpdateEntry.setOwnerBlockchainName(blockchainName);
|
||||
channelMetaUpdateEntry.setChannelRootBlockNumber(lineCode);
|
||||
channelMetaUpdateEntry.setDisplayName(parsedMeta.title());
|
||||
channelMetaUpdateEntry.setChannelDescription(parsedMeta.description());
|
||||
channelMetaUpdateEntry.setAvaAr(parsedMeta.avaAr());
|
||||
channelMetaUpdateEntry.setAvaSha256(parsedMeta.avaSha256());
|
||||
channelMetaUpdateEntry.setAvaSize(parsedMeta.avaSize());
|
||||
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
|
||||
}
|
||||
|
||||
// 4.2) запрет дырок: blockNumber строго last+1
|
||||
@@ -485,7 +542,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
);
|
||||
}
|
||||
|
||||
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry);
|
||||
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry);
|
||||
|
||||
if (chat200CreateSeed != null) {
|
||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||
@@ -544,6 +601,29 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
long updatedAtMs;
|
||||
}
|
||||
|
||||
private static final class ExistingChannelState {
|
||||
String slug;
|
||||
}
|
||||
|
||||
private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) throws Exception {
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT slug
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name = ? AND channel_root_block_number = ?
|
||||
LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, rootBlockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
ExistingChannelState state = new ExistingChannelState();
|
||||
state.slug = rs.getString("slug");
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void upsertChat200StateFromCreate(Chat200CreateSeed seed) throws Exception {
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
|
||||
+6
-1
@@ -58,7 +58,8 @@ public final class BlockchainWriter {
|
||||
BlockchainStateEntry st,
|
||||
BlockEntry be,
|
||||
UserParamEntry userParamEntry,
|
||||
ChannelNameStateEntry channelNameStateEntry) throws SQLException {
|
||||
ChannelNameStateEntry channelNameStateEntry,
|
||||
ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException {
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
byte[] blockBytes = block.toBytes();
|
||||
@@ -91,6 +92,10 @@ public final class BlockchainWriter {
|
||||
channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||
}
|
||||
|
||||
if (channelMetaUpdateEntry != null) {
|
||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import java.util.HashMap;
|
||||
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 ChannelMetaTextParser() {}
|
||||
|
||||
public static Parsed parseStrict(String rawText, String fallbackTitle) {
|
||||
String text = String.valueOf(rawText == null ? "" : rawText);
|
||||
String title = "";
|
||||
String avaAr = "";
|
||||
String avaSha256 = "";
|
||||
long avaSize = 0L;
|
||||
boolean seenTitle = false;
|
||||
boolean seenAvatar = false;
|
||||
|
||||
int offset = 0;
|
||||
while (text.startsWith("<SHiNE:", offset)) {
|
||||
int end = text.indexOf('>', offset);
|
||||
if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag");
|
||||
String body = text.substring(offset + "<SHiNE:".length(), end);
|
||||
if (body.startsWith("title;")) {
|
||||
if (seenTitle) throw new IllegalArgumentException("duplicate_channel_meta_title");
|
||||
seenTitle = true;
|
||||
title = parseTitleTag(body);
|
||||
} else if (body.startsWith("avatar;")) {
|
||||
if (seenAvatar) throw new IllegalArgumentException("duplicate_channel_meta_avatar");
|
||||
seenAvatar = true;
|
||||
Avatar avatar = parseAvatarTag(body);
|
||||
avaAr = avatar.ar;
|
||||
avaSha256 = avatar.sha256;
|
||||
avaSize = avatar.size;
|
||||
} else {
|
||||
throw new IllegalArgumentException("unknown_channel_meta_tag");
|
||||
}
|
||||
offset = end + 1;
|
||||
if (offset < text.length() && text.charAt(offset) == '\n') offset += 1;
|
||||
}
|
||||
|
||||
String description = text.substring(offset).trim();
|
||||
if (countCodePoints(description) > MAX_DESCRIPTION_CHARS) {
|
||||
throw new IllegalArgumentException("channel_meta_description_too_long");
|
||||
}
|
||||
|
||||
String cleanTitle = title.isBlank() ? String.valueOf(fallbackTitle == null ? "" : fallbackTitle).trim() : title.trim();
|
||||
validateTitle(cleanTitle);
|
||||
|
||||
return new Parsed(cleanTitle, description, avaAr, avaSha256, avaSize);
|
||||
}
|
||||
|
||||
private static String parseTitleTag(String body) {
|
||||
String prefix = "title;v=1;";
|
||||
if (!body.startsWith(prefix)) throw new IllegalArgumentException("bad_channel_meta_title");
|
||||
String title = body.substring(prefix.length()).trim();
|
||||
validateTitle(title);
|
||||
return title;
|
||||
}
|
||||
|
||||
private static Avatar parseAvatarTag(String body) {
|
||||
Map<String, String> fields = parseFields(body.substring("avatar;".length()));
|
||||
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();
|
||||
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;
|
||||
try {
|
||||
size = Long.parseLong(sizeRaw);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("bad_channel_meta_avatar_size");
|
||||
}
|
||||
if (size <= 0L) throw new IllegalArgumentException("bad_channel_meta_avatar_size");
|
||||
return new Avatar(ar, sha256, size);
|
||||
}
|
||||
|
||||
private static Map<String, String> parseFields(String raw) {
|
||||
Map<String, String> out = new HashMap<>();
|
||||
for (String part : String.valueOf(raw == null ? "" : raw).split(";")) {
|
||||
int eq = part.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
String key = part.substring(0, eq).trim();
|
||||
String value = part.substring(eq + 1).trim();
|
||||
if (!key.isEmpty()) out.put(key, value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void validateTitle(String title) {
|
||||
String value = String.valueOf(title == null ? "" : title);
|
||||
if (value.indexOf('<') >= 0 || value.indexOf('>') >= 0 || value.indexOf(';') >= 0
|
||||
|| value.indexOf('\t') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0
|
||||
|| value.indexOf('\0') >= 0) {
|
||||
throw new IllegalArgumentException("bad_channel_meta_title_chars");
|
||||
}
|
||||
if (countCodePoints(value) > MAX_TITLE_CHARS) {
|
||||
throw new IllegalArgumentException("channel_meta_title_too_long");
|
||||
}
|
||||
}
|
||||
|
||||
private static int countCodePoints(String value) {
|
||||
return String.valueOf(value == null ? "" : value).codePointCount(0, String.valueOf(value == null ? "" : value).length());
|
||||
}
|
||||
|
||||
private record Avatar(String ar, String sha256, long size) {}
|
||||
|
||||
public record Parsed(String title, String description, String avaAr, String avaSha256, long avaSize) {}
|
||||
}
|
||||
+73
-2
@@ -1,7 +1,9 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import blockchain.BchBlockEntry;
|
||||
import blockchain.MsgSubType;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
import blockchain.body.TextLineBody;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.DbController;
|
||||
@@ -20,6 +22,7 @@ import java.util.Map;
|
||||
public final class ChannelNamesStateBootstrapper {
|
||||
private static final Logger log = LoggerFactory.getLogger(ChannelNamesStateBootstrapper.class);
|
||||
private static final int MSG_TYPE_TECH = 0;
|
||||
private static final int MSG_TYPE_TEXT = 1;
|
||||
private static final int MSG_SUB_TYPE_CREATE_CHANNEL = 1;
|
||||
private static volatile boolean bootstrapped;
|
||||
|
||||
@@ -74,17 +77,30 @@ public final class ChannelNamesStateBootstrapper {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String displayName;
|
||||
String displayName;
|
||||
final String slug;
|
||||
final String channelDescription;
|
||||
String channelDescription;
|
||||
final int channelTypeCode;
|
||||
final int channelTypeVersion;
|
||||
String avaAr = "";
|
||||
String avaSha256 = "";
|
||||
long avaSize = 0L;
|
||||
long metaUpdatedAtMs = 0L;
|
||||
try {
|
||||
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
|
||||
slug = ChannelNameRules.toCanonicalSlug(displayName);
|
||||
channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
|
||||
channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
|
||||
channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion);
|
||||
if (channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
|
||||
ChannelMetaTextParser.Parsed createMeta = ChannelMetaTextParser.parseStrict(channelDescription, displayName);
|
||||
displayName = createMeta.title();
|
||||
channelDescription = createMeta.description();
|
||||
avaAr = createMeta.avaAr();
|
||||
avaSha256 = createMeta.avaSha256();
|
||||
avaSize = createMeta.avaSize();
|
||||
metaUpdatedAtMs = parsed.timestamp * 1000L;
|
||||
}
|
||||
} catch (Exception badName) {
|
||||
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
|
||||
continue;
|
||||
@@ -102,6 +118,10 @@ public final class ChannelNamesStateBootstrapper {
|
||||
entry.setSlug(slug);
|
||||
entry.setDisplayName(displayName);
|
||||
entry.setChannelDescription(channelDescription == null ? "" : channelDescription);
|
||||
entry.setAvaAr(avaAr);
|
||||
entry.setAvaSha256(avaSha256);
|
||||
entry.setAvaSize(avaSize);
|
||||
entry.setMetaUpdatedAtMs(metaUpdatedAtMs);
|
||||
entry.setOwnerLogin(ownerLogin);
|
||||
entry.setOwnerBlockchainName(ownerBch);
|
||||
entry.setChannelTypeCode(channelTypeCode);
|
||||
@@ -116,6 +136,8 @@ public final class ChannelNamesStateBootstrapper {
|
||||
|
||||
dao.clearAll(c);
|
||||
dao.insertAll(c, entries);
|
||||
|
||||
applyMetaUpdates(c, dao, entries, skipped);
|
||||
c.commit();
|
||||
log.info("channel_names_state bootstrapped: {}", entries.size());
|
||||
if (!conflicts.isEmpty()) {
|
||||
@@ -148,4 +170,53 @@ public final class ChannelNamesStateBootstrapper {
|
||||
throw new RuntimeException("Failed to bootstrap channel_names_state", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyMetaUpdates(Connection c,
|
||||
ChannelNameStateDAO dao,
|
||||
List<ChannelNameStateEntry> entries,
|
||||
List<String> skipped) throws Exception {
|
||||
Map<String, String> fallbackTitles = new LinkedHashMap<>();
|
||||
for (ChannelNameStateEntry entry : entries) {
|
||||
fallbackTitles.put(entry.getOwnerBlockchainName() + "#" + entry.getChannelRootBlockNumber(), entry.getSlug());
|
||||
}
|
||||
|
||||
String sql = """
|
||||
SELECT bch_name, block_number, block_bytes
|
||||
FROM blocks
|
||||
WHERE msg_type = ? AND msg_sub_type = ?
|
||||
ORDER BY bch_name, block_number
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setInt(1, MSG_TYPE_TEXT);
|
||||
ps.setInt(2, MsgSubType.TEXT_CHANNEL_META);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String ownerBch = rs.getString("bch_name");
|
||||
int blockNumber = rs.getInt("block_number");
|
||||
byte[] blockBytes = rs.getBytes("block_bytes");
|
||||
try {
|
||||
BchBlockEntry parsed = new BchBlockEntry(blockBytes);
|
||||
if (!(parsed.body instanceof TextLineBody metaBody)) continue;
|
||||
int lineCode = metaBody.lineCode();
|
||||
String fallbackTitle = fallbackTitles.get(ownerBch + "#" + lineCode);
|
||||
if (fallbackTitle == null) continue;
|
||||
ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(metaBody.message, fallbackTitle);
|
||||
|
||||
ChannelNameStateEntry update = new ChannelNameStateEntry();
|
||||
update.setOwnerBlockchainName(ownerBch);
|
||||
update.setChannelRootBlockNumber(lineCode);
|
||||
update.setDisplayName(parsedMeta.title());
|
||||
update.setChannelDescription(parsedMeta.description());
|
||||
update.setAvaAr(parsedMeta.avaAr());
|
||||
update.setAvaSha256(parsedMeta.avaSha256());
|
||||
update.setAvaSize(parsedMeta.avaSize());
|
||||
update.setMetaUpdatedAtMs(parsed.timestamp * 1000L);
|
||||
dao.updateMeta(c, update);
|
||||
} catch (Exception e) {
|
||||
skipped.add(ownerBch + "#" + blockNumber + " (invalid_meta)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+144
-51
@@ -23,8 +23,6 @@ final class ChannelsReadSupport {
|
||||
static final int MSG_TYPE_REACTION = 2;
|
||||
static final int MSG_TYPE_TECH = 0;
|
||||
static final String STORIES_CHANNEL_NAME = "stories";
|
||||
static final String COMMAND_PREFIX = "/.";
|
||||
static final String COMMAND_DESC = "desc";
|
||||
static final String COMMAND_ADD = "add";
|
||||
static final String COMMAND_REMOVE = "remove";
|
||||
|
||||
@@ -47,11 +45,40 @@ final class ChannelsReadSupport {
|
||||
|
||||
if (rootNumber == 0) {
|
||||
meta.channelName = STORIES_CHANNEL_NAME;
|
||||
meta.channelDescription = detectLatestDescriptionCommand(c, ownerBch, 0);
|
||||
meta.displayName = STORIES_CHANNEL_NAME;
|
||||
meta.channelDescription = "";
|
||||
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_STORIES & 0xFFFF;
|
||||
return meta;
|
||||
}
|
||||
|
||||
String stateSql = """
|
||||
SELECT slug, display_name, channel_description, channel_type_code, channel_type_version,
|
||||
ava_ar, ava_sha256, ava_size, meta_updated_at_ms
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name = ? AND channel_root_block_number = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(stateSql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, rootNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
meta.channelName = rs.getString("slug");
|
||||
meta.displayName = rs.getString("display_name");
|
||||
meta.channelDescription = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
|
||||
meta.channelTypeCode = rs.getInt("channel_type_code");
|
||||
meta.channelTypeVersion = rs.getInt("channel_type_version");
|
||||
meta.avaAr = String.valueOf(rs.getString("ava_ar") == null ? "" : rs.getString("ava_ar"));
|
||||
meta.avaSha256 = String.valueOf(rs.getString("ava_sha256") == null ? "" : rs.getString("ava_sha256"));
|
||||
meta.avaSize = rs.getLong("ava_size");
|
||||
meta.metaUpdatedAtMs = rs.getLong("meta_updated_at_ms");
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
} catch (SQLException ignored) {
|
||||
// fallback ниже для окружений до миграции.
|
||||
}
|
||||
|
||||
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
@@ -68,21 +95,29 @@ final class ChannelsReadSupport {
|
||||
BodyRecord body = e.body;
|
||||
if (body instanceof CreateChannelBody ccb) {
|
||||
meta.channelName = ccb.channelName;
|
||||
meta.displayName = ccb.channelName;
|
||||
meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||
meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode);
|
||||
meta.channelTypeVersion = Short.toUnsignedInt(ccb.channelTypeVersion);
|
||||
if (meta.channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
|
||||
ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(meta.channelDescription, meta.channelName);
|
||||
meta.displayName = parsedMeta.title();
|
||||
meta.channelDescription = parsedMeta.description();
|
||||
meta.avaAr = parsedMeta.avaAr();
|
||||
meta.avaSha256 = parsedMeta.avaSha256();
|
||||
meta.avaSize = parsedMeta.avaSize();
|
||||
meta.metaUpdatedAtMs = e.timestamp * 1000L;
|
||||
}
|
||||
} else {
|
||||
meta.channelName = null;
|
||||
meta.displayName = null;
|
||||
meta.channelDescription = "";
|
||||
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
|
||||
}
|
||||
String updatedDescription = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
|
||||
if (updatedDescription != null) {
|
||||
meta.channelDescription = updatedDescription;
|
||||
}
|
||||
return meta;
|
||||
} catch (Exception ignored) {
|
||||
meta.channelName = null;
|
||||
meta.displayName = null;
|
||||
meta.channelDescription = "";
|
||||
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
|
||||
return meta;
|
||||
@@ -106,7 +141,7 @@ final class ChannelsReadSupport {
|
||||
|
||||
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
|
||||
ORDER BY block_number DESC
|
||||
@@ -133,7 +168,7 @@ final class ChannelsReadSupport {
|
||||
|
||||
static PostBlock loadLastVersion(Connection c, String ownerBch, int originalBlockNumber, byte[] originalHash) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||
@@ -270,8 +305,7 @@ final class ChannelsReadSupport {
|
||||
|
||||
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||
if (rootNumber == 0) {
|
||||
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, 0);
|
||||
return fromCommand == null ? "" : fromCommand;
|
||||
return "";
|
||||
}
|
||||
|
||||
// Preferred source: persisted state (fast path, works for CreateChannelBody v2).
|
||||
@@ -287,8 +321,7 @@ final class ChannelsReadSupport {
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
String saved = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
|
||||
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
|
||||
return fromCommand == null ? saved : fromCommand;
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
} catch (SQLException ignored) {
|
||||
@@ -306,8 +339,6 @@ final class ChannelsReadSupport {
|
||||
BchBlockEntry e = new BchBlockEntry(bytes);
|
||||
BodyRecord body = e.body;
|
||||
if (body instanceof CreateChannelBody ccb) {
|
||||
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
|
||||
if (fromCommand != null) return fromCommand;
|
||||
return ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||
}
|
||||
return "";
|
||||
@@ -363,45 +394,97 @@ final class ChannelsReadSupport {
|
||||
return meta.channelTypeVersion;
|
||||
}
|
||||
|
||||
static String detectLatestDescriptionCommand(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
String sql = """
|
||||
SELECT block_bytes
|
||||
static List<MetaEvent> channelMetaEvents(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
List<MetaEvent> out = new ArrayList<>();
|
||||
if (lineCode <= 0) return out;
|
||||
ChannelMeta base = detectChannelMeta(c, ownerBch, lineCode);
|
||||
String rootSql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
|
||||
ORDER BY block_number DESC
|
||||
LIMIT 300
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND block_number=?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(rootSql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TECH);
|
||||
ps.setInt(3, 1);
|
||||
ps.setInt(4, lineCode);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
MetaEvent event = new MetaEvent();
|
||||
event.kind = "created";
|
||||
event.login = rs.getString("login");
|
||||
event.bchName = rs.getString("bch_name");
|
||||
event.blockNumber = rs.getInt("block_number");
|
||||
event.blockHash = rs.getBytes("block_hash");
|
||||
event.lineStep = null;
|
||||
try {
|
||||
BchBlockEntry entry = new BchBlockEntry(rs.getBytes("block_bytes"));
|
||||
event.createdAtMs = entry.timestamp * 1000L;
|
||||
if (entry.body instanceof CreateChannelBody ccb) {
|
||||
event.title = ccb.channelName;
|
||||
event.description = ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||
if (Short.toUnsignedInt(ccb.channelTypeCode) == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) {
|
||||
ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(event.description, ccb.channelName);
|
||||
event.title = parsedMeta.title();
|
||||
event.description = parsedMeta.description();
|
||||
event.avaAr = parsedMeta.avaAr();
|
||||
event.avaSha256 = parsedMeta.avaSha256();
|
||||
event.avaSize = parsedMeta.avaSize();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
event.createdAtMs = 0L;
|
||||
}
|
||||
if (event.title == null || event.title.isBlank()) {
|
||||
event.title = base.displayName == null || base.displayName.isBlank() ? base.channelName : base.displayName;
|
||||
}
|
||||
if (event.description == null) event.description = "";
|
||||
if (event.avaAr == null) event.avaAr = "";
|
||||
if (event.avaSha256 == null) event.avaSha256 = "";
|
||||
out.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?
|
||||
ORDER BY block_number ASC
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
||||
ps.setInt(5, lineCode);
|
||||
ps.setInt(3, MsgSubType.TEXT_CHANNEL_META);
|
||||
ps.setInt(4, lineCode);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
TextInfo info = parseTextAndTime(rs.getBytes("block_bytes"));
|
||||
CommandInfo commandInfo = parseCommandText(info.text);
|
||||
if (commandInfo != null && COMMAND_DESC.equals(commandInfo.command)) {
|
||||
return commandInfo.arg;
|
||||
byte[] bytes = rs.getBytes("block_bytes");
|
||||
TextInfo info = parseTextAndTime(bytes);
|
||||
try {
|
||||
ChannelMetaTextParser.Parsed parsed = ChannelMetaTextParser.parseStrict(info.text, base.channelName);
|
||||
MetaEvent event = new MetaEvent();
|
||||
event.kind = "profile_updated";
|
||||
event.login = rs.getString("login");
|
||||
event.bchName = rs.getString("bch_name");
|
||||
event.blockNumber = rs.getInt("block_number");
|
||||
event.blockHash = rs.getBytes("block_hash");
|
||||
event.lineStep = (Integer) rs.getObject("this_line_number");
|
||||
event.createdAtMs = info.createdAtMs;
|
||||
event.title = parsed.title();
|
||||
event.description = parsed.description();
|
||||
event.avaAr = parsed.avaAr();
|
||||
event.avaSha256 = parsed.avaSha256();
|
||||
event.avaSize = parsed.avaSize();
|
||||
out.add(event);
|
||||
} catch (Exception ignored) {
|
||||
// Невалидный meta-блок не участвует в системной истории UI.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static CommandInfo parseCommandText(String text) {
|
||||
String value = String.valueOf(text == null ? "" : text).trim();
|
||||
if (!value.startsWith(COMMAND_PREFIX)) return null;
|
||||
|
||||
String raw = value.substring(COMMAND_PREFIX.length()).trim();
|
||||
if (raw.isEmpty()) return null;
|
||||
|
||||
int sp = raw.indexOf(' ');
|
||||
String cmd = (sp < 0 ? raw : raw.substring(0, sp)).trim().toLowerCase();
|
||||
String arg = sp < 0 ? "" : raw.substring(sp + 1).trim();
|
||||
if (cmd.isEmpty()) return null;
|
||||
return new CommandInfo(cmd, arg);
|
||||
return out;
|
||||
}
|
||||
|
||||
static PairChannelSelector findPersonalPairChannel(Connection c, String ownerLogin, String partnerLogin) throws SQLException {
|
||||
@@ -526,19 +609,29 @@ final class ChannelsReadSupport {
|
||||
|
||||
static final class ChannelMeta {
|
||||
String channelName;
|
||||
String displayName;
|
||||
String channelDescription;
|
||||
int channelTypeCode;
|
||||
int channelTypeVersion;
|
||||
String avaAr = "";
|
||||
String avaSha256 = "";
|
||||
long avaSize = 0L;
|
||||
long metaUpdatedAtMs = 0L;
|
||||
}
|
||||
|
||||
static final class CommandInfo {
|
||||
final String command;
|
||||
final String arg;
|
||||
|
||||
CommandInfo(String command, String arg) {
|
||||
this.command = command;
|
||||
this.arg = arg;
|
||||
}
|
||||
static final class MetaEvent {
|
||||
String kind;
|
||||
String login;
|
||||
String bchName;
|
||||
int blockNumber;
|
||||
byte[] blockHash;
|
||||
Integer lineStep;
|
||||
long createdAtMs;
|
||||
String title;
|
||||
String description;
|
||||
String avaAr;
|
||||
String avaSha256;
|
||||
long avaSize;
|
||||
}
|
||||
|
||||
static final class PairChannelSelector {
|
||||
|
||||
+26
@@ -57,7 +57,12 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
|
||||
ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, lineCode);
|
||||
channel.setChannelName(meta.channelName);
|
||||
channel.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
|
||||
channel.setChannelDescription(meta.channelDescription);
|
||||
channel.setAvaAr(meta.avaAr);
|
||||
channel.setAvaSha256(meta.avaSha256);
|
||||
channel.setAvaSize(meta.avaSize);
|
||||
channel.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
|
||||
channel.setChannelTypeCode(meta.channelTypeCode);
|
||||
channel.setChannelTypeVersion(meta.channelTypeVersion);
|
||||
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||
@@ -66,6 +71,27 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
channel.setChannelRoot(rootRef);
|
||||
resp.setChannel(channel);
|
||||
|
||||
List<Net_GetChannelMessages_Response.ChannelMetaEvent> metaEvents = new ArrayList<>();
|
||||
for (ChannelsReadSupport.MetaEvent event : ChannelsReadSupport.channelMetaEvents(c, ownerBch, lineCode)) {
|
||||
Net_GetChannelMessages_Response.ChannelMetaEvent outEvent = new Net_GetChannelMessages_Response.ChannelMetaEvent();
|
||||
outEvent.setKind(event.kind);
|
||||
Net_GetChannelMessages_Response.BlockRef eventRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||
eventRef.setBlockNumber(event.blockNumber);
|
||||
eventRef.setBlockHash(ChannelsReadSupport.toHex(event.blockHash));
|
||||
outEvent.setMessageRef(eventRef);
|
||||
outEvent.setAuthorLogin(event.login);
|
||||
outEvent.setAuthorBlockchainName(event.bchName);
|
||||
outEvent.setLineStep(event.lineStep);
|
||||
outEvent.setCreatedAtMs(event.createdAtMs);
|
||||
outEvent.setTitle(event.title);
|
||||
outEvent.setDescription(event.description);
|
||||
outEvent.setAvaAr(event.avaAr);
|
||||
outEvent.setAvaSha256(event.avaSha256);
|
||||
outEvent.setAvaSize(event.avaSize);
|
||||
metaEvents.add(outEvent);
|
||||
}
|
||||
resp.setMetaEvents(metaEvents);
|
||||
|
||||
List<ChannelsReadSupport.PostBlock> posts = new ArrayList<>(
|
||||
ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc)
|
||||
);
|
||||
|
||||
+5
@@ -65,7 +65,12 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
||||
channelRef.setOwnerLogin(key.ownerLogin);
|
||||
channelRef.setOwnerBlockchainName(key.ownerBch);
|
||||
channelRef.setChannelName(meta.channelName);
|
||||
channelRef.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName);
|
||||
channelRef.setChannelDescription(meta.channelDescription);
|
||||
channelRef.setAvaAr(meta.avaAr);
|
||||
channelRef.setAvaSha256(meta.avaSha256);
|
||||
channelRef.setAvaSize(meta.avaSize);
|
||||
channelRef.setMetaUpdatedAtMs(meta.metaUpdatedAtMs);
|
||||
channelRef.setChannelTypeCode(meta.channelTypeCode);
|
||||
channelRef.setChannelTypeVersion(meta.channelTypeVersion);
|
||||
channelRef.setPersonal(meta.channelTypeCode == 100);
|
||||
|
||||
+71
@@ -8,6 +8,7 @@ import java.util.List;
|
||||
public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
private Channel channel;
|
||||
private List<MessageItem> messages = new ArrayList<>();
|
||||
private List<ChannelMetaEvent> metaEvents = new ArrayList<>();
|
||||
|
||||
public Channel getChannel() { return channel; }
|
||||
public void setChannel(Channel channel) { this.channel = channel; }
|
||||
@@ -15,12 +16,20 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
public List<MessageItem> getMessages() { return messages; }
|
||||
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
||||
|
||||
public List<ChannelMetaEvent> getMetaEvents() { return metaEvents; }
|
||||
public void setMetaEvents(List<ChannelMetaEvent> metaEvents) { this.metaEvents = metaEvents; }
|
||||
|
||||
|
||||
public static class Channel {
|
||||
private String ownerLogin;
|
||||
private String ownerBlockchainName;
|
||||
private String channelName;
|
||||
private String displayName;
|
||||
private String channelDescription;
|
||||
private String avaAr;
|
||||
private String avaSha256;
|
||||
private Long avaSize;
|
||||
private Long metaUpdatedAtMs;
|
||||
private Integer channelTypeCode;
|
||||
private Integer channelTypeVersion;
|
||||
private BlockRef channelRoot;
|
||||
@@ -34,9 +43,24 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
public String getChannelName() { return channelName; }
|
||||
public void setChannelName(String channelName) { this.channelName = channelName; }
|
||||
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
|
||||
public String getChannelDescription() { return channelDescription; }
|
||||
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
||||
|
||||
public String getAvaAr() { return avaAr; }
|
||||
public void setAvaAr(String avaAr) { this.avaAr = avaAr; }
|
||||
|
||||
public String getAvaSha256() { return avaSha256; }
|
||||
public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; }
|
||||
|
||||
public Long getAvaSize() { return avaSize; }
|
||||
public void setAvaSize(Long avaSize) { this.avaSize = avaSize; }
|
||||
|
||||
public Long getMetaUpdatedAtMs() { return metaUpdatedAtMs; }
|
||||
public void setMetaUpdatedAtMs(Long metaUpdatedAtMs) { this.metaUpdatedAtMs = metaUpdatedAtMs; }
|
||||
|
||||
public Integer getChannelTypeCode() { return channelTypeCode; }
|
||||
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
|
||||
|
||||
@@ -47,6 +71,53 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
|
||||
}
|
||||
|
||||
public static class ChannelMetaEvent {
|
||||
private String kind;
|
||||
private BlockRef messageRef;
|
||||
private String authorLogin;
|
||||
private String authorBlockchainName;
|
||||
private Integer lineStep;
|
||||
private long createdAtMs;
|
||||
private String title;
|
||||
private String description;
|
||||
private String avaAr;
|
||||
private String avaSha256;
|
||||
private Long avaSize;
|
||||
|
||||
public String getKind() { return kind; }
|
||||
public void setKind(String kind) { this.kind = kind; }
|
||||
|
||||
public BlockRef getMessageRef() { return messageRef; }
|
||||
public void setMessageRef(BlockRef messageRef) { this.messageRef = messageRef; }
|
||||
|
||||
public String getAuthorLogin() { return authorLogin; }
|
||||
public void setAuthorLogin(String authorLogin) { this.authorLogin = authorLogin; }
|
||||
|
||||
public String getAuthorBlockchainName() { return authorBlockchainName; }
|
||||
public void setAuthorBlockchainName(String authorBlockchainName) { this.authorBlockchainName = authorBlockchainName; }
|
||||
|
||||
public Integer getLineStep() { return lineStep; }
|
||||
public void setLineStep(Integer lineStep) { this.lineStep = lineStep; }
|
||||
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public String getAvaAr() { return avaAr; }
|
||||
public void setAvaAr(String avaAr) { this.avaAr = avaAr; }
|
||||
|
||||
public String getAvaSha256() { return avaSha256; }
|
||||
public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; }
|
||||
|
||||
public Long getAvaSize() { return avaSize; }
|
||||
public void setAvaSize(Long avaSize) { this.avaSize = avaSize; }
|
||||
}
|
||||
|
||||
public static class MessageItem {
|
||||
private BlockRef messageRef;
|
||||
private Integer msgSubType;
|
||||
|
||||
+20
@@ -46,7 +46,12 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
private String ownerLogin;
|
||||
private String ownerBlockchainName;
|
||||
private String channelName;
|
||||
private String displayName;
|
||||
private String channelDescription;
|
||||
private String avaAr;
|
||||
private String avaSha256;
|
||||
private Long avaSize;
|
||||
private Long metaUpdatedAtMs;
|
||||
private Integer channelTypeCode;
|
||||
private Integer channelTypeVersion;
|
||||
private boolean personal;
|
||||
@@ -61,9 +66,24 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
public String getChannelName() { return channelName; }
|
||||
public void setChannelName(String channelName) { this.channelName = channelName; }
|
||||
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
|
||||
public String getChannelDescription() { return channelDescription; }
|
||||
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
||||
|
||||
public String getAvaAr() { return avaAr; }
|
||||
public void setAvaAr(String avaAr) { this.avaAr = avaAr; }
|
||||
|
||||
public String getAvaSha256() { return avaSha256; }
|
||||
public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; }
|
||||
|
||||
public Long getAvaSize() { return avaSize; }
|
||||
public void setAvaSize(Long avaSize) { this.avaSize = avaSize; }
|
||||
|
||||
public Long getMetaUpdatedAtMs() { return metaUpdatedAtMs; }
|
||||
public void setMetaUpdatedAtMs(Long metaUpdatedAtMs) { this.metaUpdatedAtMs = metaUpdatedAtMs; }
|
||||
|
||||
public Integer getChannelTypeCode() { return channelTypeCode; }
|
||||
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
|
||||
|
||||
|
||||
+3
-2
@@ -35,10 +35,11 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
String prefix = req.getPrefix().trim();
|
||||
Boolean isServer = req.getIsServer();
|
||||
|
||||
try {
|
||||
CurrentUsersDAO dao = CurrentUsersDAO.getInstance();
|
||||
List<CurrentUserEntry> users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5
|
||||
List<CurrentUserEntry> users = dao.searchByLoginPrefix(prefix, isServer); // case-insensitive + LIMIT 5
|
||||
|
||||
List<String> logins = new ArrayList<>();
|
||||
for (CurrentUserEntry u : users) {
|
||||
@@ -53,7 +54,7 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler {
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogins(logins);
|
||||
|
||||
log.info("✅ SearchUsers ok: prefix='{}' -> {}", prefix, logins.size());
|
||||
log.info("✅ SearchUsers ok: prefix='{}', isServer={} -> {}", prefix, isServer, logins.size());
|
||||
return resp;
|
||||
|
||||
} catch (SQLException e) {
|
||||
|
||||
+4
-1
@@ -18,7 +18,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
public class Net_SearchUsers_Request extends Net_Request {
|
||||
|
||||
private String prefix;
|
||||
private Boolean isServer;
|
||||
|
||||
public String getPrefix() { return prefix; }
|
||||
public void setPrefix(String prefix) { this.prefix = prefix; }
|
||||
}
|
||||
public Boolean getIsServer() { return isServer; }
|
||||
public void setIsServer(Boolean isServer) { this.isServer = isServer; }
|
||||
}
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
import shine.db.dao.SignedMessagesDAO;
|
||||
import shine.db.entities.SignedMessageEntry;
|
||||
|
||||
public final class DmSyncApplySupport {
|
||||
private DmSyncApplySupport() {}
|
||||
|
||||
public static ApplyResult applySyncedBlob(String ownerLogin, String blobB64) throws Exception {
|
||||
if (ownerLogin == null || ownerLogin.isBlank()) {
|
||||
throw new IllegalArgumentException("EMPTY_OWNER_LOGIN");
|
||||
}
|
||||
if (blobB64 == null || blobB64.isBlank()) {
|
||||
throw new IllegalArgumentException("EMPTY_BLOB");
|
||||
}
|
||||
|
||||
SignedMessageBlock block = SignedMessagesCore.parseFromB64(blobB64);
|
||||
SignedMessagesCore.verifyUsersAndSignature(block);
|
||||
SignedMessageEntry entry = SignedMessagesCore.toEntry(block, "DmSyncBatch", null);
|
||||
String owner = ownerLogin.trim();
|
||||
boolean ownerMatchesTarget = entry.getTargetLogin().equalsIgnoreCase(owner);
|
||||
boolean ownerMatchesDeletePair = block.isDeleteType()
|
||||
&& (entry.getFromLogin().equalsIgnoreCase(owner) || entry.getToLogin().equalsIgnoreCase(owner));
|
||||
if (!ownerMatchesTarget && !ownerMatchesDeletePair) {
|
||||
throw new IllegalArgumentException("TARGET_LOGIN_MISMATCH");
|
||||
}
|
||||
|
||||
SignedMessagesDAO.ApplyStatus status;
|
||||
if (block.isContentType()) {
|
||||
status = SignedMessagesDAO.getInstance().upsertIncomingCopy(entry);
|
||||
} else if (block.isReadReceiptType()) {
|
||||
status = SignedMessagesDAO.getInstance().insertIfAbsent(entry);
|
||||
} else if (block.isMessageDeleteType()) {
|
||||
status = SignedMessagesDAO.getInstance().applyDeleteMessage(entry);
|
||||
} else if (block.isConversationDeleteType()) {
|
||||
status = SignedMessagesDAO.getInstance().applyDeleteConversation(entry);
|
||||
} else {
|
||||
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
|
||||
}
|
||||
|
||||
return new ApplyResult(entry.getMessageKey(), entry.getBaseKey(), entry.getMessageType(), status);
|
||||
}
|
||||
|
||||
public record ApplyResult(
|
||||
String messageKey,
|
||||
String baseKey,
|
||||
int messageType,
|
||||
SignedMessagesDAO.ApplyStatus status
|
||||
) {}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.SignedMessagesDAO;
|
||||
import shine.db.dao.UserAccessServersCurrentDAO;
|
||||
import shine.db.entities.SignedMessageEntry;
|
||||
import shine.db.entities.UserAccessServerRouteEntry;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_DmSyncBatch_Handler implements JsonMessageHandler {
|
||||
private static final int DEFAULT_LIMIT = 500;
|
||||
private static final int MAX_LIMIT = 500;
|
||||
private static final int DEFAULT_MAX_BYTES = 3_000_000;
|
||||
private static final int MAX_BYTES_CAP = 5_000_000;
|
||||
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
Net_DmSyncBatch_Request req = (Net_DmSyncBatch_Request) baseRequest;
|
||||
String ownerLogin = normalizeOriginal(req.getOwnerLogin());
|
||||
if (ownerLogin == null) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_OWNER_LOGIN", "ownerLogin обязателен");
|
||||
}
|
||||
|
||||
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
|
||||
if (ownServerLogin == null) {
|
||||
return NetExceptionResponseFactory.error(req, 500, "LOCAL_SERVER_NOT_CONFIGURED", "server.SHiNE.login не настроен");
|
||||
}
|
||||
if (!isLocalAccessServer(ownerLogin, ownServerLogin)) {
|
||||
return NetExceptionResponseFactory.error(req, 403, "LOCAL_SERVER_NOT_ACCESS_SERVER", "Локальный сервер не является access-сервером пользователя");
|
||||
}
|
||||
|
||||
int limit = clamp(req.getLimit() == null ? DEFAULT_LIMIT : req.getLimit(), 1, MAX_LIMIT);
|
||||
int maxBytes = clamp(req.getMaxBytes() == null ? DEFAULT_MAX_BYTES : req.getMaxBytes(), 64_000, MAX_BYTES_CAP);
|
||||
long afterStoredAtMs = Math.max(0L, req.getAfterStoredAtMs() == null ? 0L : req.getAfterStoredAtMs());
|
||||
String afterMessageKey = req.getAfterMessageKey() == null ? "" : req.getAfterMessageKey().trim();
|
||||
|
||||
SignedMessagesDAO.SyncBatch batch = SignedMessagesDAO.getInstance().listSyncBatch(
|
||||
ownerLogin,
|
||||
afterStoredAtMs,
|
||||
afterMessageKey,
|
||||
limit,
|
||||
maxBytes
|
||||
);
|
||||
|
||||
Net_DmSyncBatch_Response resp = new Net_DmSyncBatch_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setOwnerLogin(ownerLogin);
|
||||
resp.setLimit(limit);
|
||||
resp.setRawBytes(batch.rawBytes());
|
||||
resp.setHasMore(batch.hasMore());
|
||||
resp.setNextStoredAtMs(afterStoredAtMs);
|
||||
resp.setNextMessageKey(afterMessageKey);
|
||||
|
||||
List<Net_DmSyncBatch_Response.Item> items = new ArrayList<>();
|
||||
Base64.Encoder encoder = Base64.getEncoder();
|
||||
for (SignedMessageEntry entry : batch.items()) {
|
||||
Net_DmSyncBatch_Response.Item item = new Net_DmSyncBatch_Response.Item();
|
||||
item.setMessageKey(entry.getMessageKey());
|
||||
item.setBaseKey(entry.getBaseKey());
|
||||
item.setTargetLogin(entry.getTargetLogin());
|
||||
item.setFromLogin(entry.getFromLogin());
|
||||
item.setToLogin(entry.getToLogin());
|
||||
item.setMessageType(entry.getMessageType());
|
||||
item.setTimeMs(entry.getTimeMs());
|
||||
item.setStoredAtMs(entry.getCreatedAtMs());
|
||||
item.setBlobB64(encoder.encodeToString(entry.getRawBlock()));
|
||||
items.add(item);
|
||||
resp.setNextStoredAtMs(entry.getCreatedAtMs());
|
||||
resp.setNextMessageKey(entry.getMessageKey());
|
||||
}
|
||||
resp.setItems(items);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private boolean isLocalAccessServer(String ownerLogin, String ownServerLogin) throws Exception {
|
||||
for (UserAccessServerRouteEntry route : UserAccessServersCurrentDAO.getInstance().listByUserLogin(ownerLogin)) {
|
||||
if (route == null || route.getServerLogin() == null) continue;
|
||||
if (ownServerLogin.equals(normalize(route.getServerLogin()))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int clamp(int value, int min, int max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
if (value == null) return null;
|
||||
String s = value.trim().toLowerCase();
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
|
||||
private static String normalizeOriginal(String value) {
|
||||
if (value == null) return null;
|
||||
String s = value.trim();
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
}
|
||||
+5
@@ -53,6 +53,11 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
||||
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||
if (status.applied()) {
|
||||
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
||||
server.sync.DmFederationService.fanOutIncomingToRecipientAccessServers(
|
||||
incoming.toLogin,
|
||||
req.getIncomingBlobB64().trim(),
|
||||
req.getSourceServerLogin()
|
||||
);
|
||||
}
|
||||
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_DmSyncBatch_Request extends Net_Request {
|
||||
private String ownerLogin;
|
||||
private Long afterStoredAtMs;
|
||||
private String afterMessageKey;
|
||||
private Integer limit;
|
||||
private Integer maxBytes;
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
public Long getAfterStoredAtMs() { return afterStoredAtMs; }
|
||||
public void setAfterStoredAtMs(Long afterStoredAtMs) { this.afterStoredAtMs = afterStoredAtMs; }
|
||||
public String getAfterMessageKey() { return afterMessageKey; }
|
||||
public void setAfterMessageKey(String afterMessageKey) { this.afterMessageKey = afterMessageKey; }
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
public Integer getMaxBytes() { return maxBytes; }
|
||||
public void setMaxBytes(Integer maxBytes) { this.maxBytes = maxBytes; }
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_DmSyncBatch_Response extends Net_Response {
|
||||
private String ownerLogin;
|
||||
private int limit;
|
||||
private int rawBytes;
|
||||
private boolean hasMore;
|
||||
private long nextStoredAtMs;
|
||||
private String nextMessageKey;
|
||||
private List<Item> items = new ArrayList<>();
|
||||
|
||||
public String getOwnerLogin() { return ownerLogin; }
|
||||
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
|
||||
public int getLimit() { return limit; }
|
||||
public void setLimit(int limit) { this.limit = limit; }
|
||||
public int getRawBytes() { return rawBytes; }
|
||||
public void setRawBytes(int rawBytes) { this.rawBytes = rawBytes; }
|
||||
public boolean isHasMore() { return hasMore; }
|
||||
public void setHasMore(boolean hasMore) { this.hasMore = hasMore; }
|
||||
public long getNextStoredAtMs() { return nextStoredAtMs; }
|
||||
public void setNextStoredAtMs(long nextStoredAtMs) { this.nextStoredAtMs = nextStoredAtMs; }
|
||||
public String getNextMessageKey() { return nextMessageKey; }
|
||||
public void setNextMessageKey(String nextMessageKey) { this.nextMessageKey = nextMessageKey; }
|
||||
public List<Item> getItems() { return items; }
|
||||
public void setItems(List<Item> items) { this.items = items; }
|
||||
|
||||
public static class Item {
|
||||
private String messageKey;
|
||||
private String baseKey;
|
||||
private String targetLogin;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private int messageType;
|
||||
private long timeMs;
|
||||
private long storedAtMs;
|
||||
private String blobB64;
|
||||
|
||||
public String getMessageKey() { return messageKey; }
|
||||
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
|
||||
public String getBaseKey() { return baseKey; }
|
||||
public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
|
||||
public String getTargetLogin() { return targetLogin; }
|
||||
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
public int getMessageType() { return messageType; }
|
||||
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||
public long getTimeMs() { return timeMs; }
|
||||
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||
public long getStoredAtMs() { return storedAtMs; }
|
||||
public void setStoredAtMs(long storedAtMs) { this.storedAtMs = storedAtMs; }
|
||||
public String getBlobB64() { return blobB64; }
|
||||
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
|
||||
}
|
||||
}
|
||||
+3
@@ -4,7 +4,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_ReceiveIncomingMessage_Request extends Net_Request {
|
||||
private String incomingBlobB64;
|
||||
private String sourceServerLogin;
|
||||
|
||||
public String getIncomingBlobB64() { return incomingBlobB64; }
|
||||
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
|
||||
public String getSourceServerLogin() { return sourceServerLogin; }
|
||||
public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; }
|
||||
}
|
||||
|
||||
+3
@@ -5,9 +5,12 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
public class Net_SendMessagePair_Request extends Net_Request {
|
||||
private String incomingBlobB64;
|
||||
private String outgoingBlobB64;
|
||||
private String sourceServerLogin;
|
||||
|
||||
public String getIncomingBlobB64() { return incomingBlobB64; }
|
||||
public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; }
|
||||
public String getOutgoingBlobB64() { return outgoingBlobB64; }
|
||||
public void setOutgoingBlobB64(String outgoingBlobB64) { this.outgoingBlobB64 = outgoingBlobB64; }
|
||||
public String getSourceServerLogin() { return sourceServerLogin; }
|
||||
public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; }
|
||||
}
|
||||
|
||||
+51
-25
@@ -2,7 +2,8 @@ package server.sync;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService;
|
||||
import shine.db.dao.UserAccessServersCurrentDAO;
|
||||
import shine.db.entities.UserAccessServerRouteEntry;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -18,26 +19,48 @@ public final class DmFederationService {
|
||||
|
||||
public static void fanOutPair(String fromLogin, String toLogin, String incomingBlobB64, String outgoingBlobB64) {
|
||||
try {
|
||||
Map<String, SolanaUserPdaImportService.ParsedServerRoute> senderRoutes =
|
||||
routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin));
|
||||
Map<String, SolanaUserPdaImportService.ParsedServerRoute> recipientRoutes =
|
||||
routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin));
|
||||
Map<String, UserAccessServerRouteEntry> senderRoutes =
|
||||
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin));
|
||||
Map<String, UserAccessServerRouteEntry> recipientRoutes =
|
||||
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin));
|
||||
|
||||
String ownServerLogin = ownServerLogin();
|
||||
for (SolanaUserPdaImportService.ParsedServerRoute route : senderRoutes.values()) {
|
||||
for (UserAccessServerRouteEntry route : senderRoutes.values()) {
|
||||
if (isOwnServer(route, ownServerLogin)) continue;
|
||||
REMOTE.sendMessagePair(route.serverAddress(), incomingBlobB64, outgoingBlobB64);
|
||||
REMOTE.sendMessagePair(route.getServerUrl(), incomingBlobB64, outgoingBlobB64, ownServerLogin);
|
||||
}
|
||||
for (SolanaUserPdaImportService.ParsedServerRoute route : recipientRoutes.values()) {
|
||||
for (UserAccessServerRouteEntry route : recipientRoutes.values()) {
|
||||
if (isOwnServer(route, ownServerLogin)) continue;
|
||||
if (senderRoutes.containsKey(route.login())) continue;
|
||||
REMOTE.receiveIncomingMessage(route.serverAddress(), incomingBlobB64);
|
||||
if (senderRoutes.containsKey(normalize(route.getServerLogin()))) continue;
|
||||
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void fanOutIncomingToRecipientAccessServers(
|
||||
String toLogin,
|
||||
String incomingBlobB64,
|
||||
String sourceServerLogin
|
||||
) {
|
||||
try {
|
||||
Map<String, UserAccessServerRouteEntry> recipientRoutes =
|
||||
routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin));
|
||||
String ownServerLogin = ownServerLogin();
|
||||
String normalizedSource = normalize(sourceServerLogin);
|
||||
for (UserAccessServerRouteEntry route : recipientRoutes.values()) {
|
||||
String routeLogin = normalize(route.getServerLogin());
|
||||
if (routeLogin == null) continue;
|
||||
if (ownServerLogin != null && ownServerLogin.equalsIgnoreCase(routeLogin)) continue;
|
||||
if (normalizedSource != null && normalizedSource.equalsIgnoreCase(routeLogin)) continue;
|
||||
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("DM federation incoming relay failed: to={}", toLogin, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void fanOutDeleteMessage(String fromLogin, String toLogin, int messageType, String blobB64) {
|
||||
fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true);
|
||||
}
|
||||
@@ -48,17 +71,17 @@ public final class DmFederationService {
|
||||
|
||||
private static void fanOutSingleDelete(String fromLogin, String toLogin, int messageType, String blobB64, boolean oneMessageDelete) {
|
||||
try {
|
||||
Map<String, SolanaUserPdaImportService.ParsedServerRoute> routes = routesByLogin(
|
||||
SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin),
|
||||
SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin)
|
||||
Map<String, UserAccessServerRouteEntry> routes = routesByLogin(
|
||||
UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin),
|
||||
UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin)
|
||||
);
|
||||
String ownServerLogin = ownServerLogin();
|
||||
for (SolanaUserPdaImportService.ParsedServerRoute route : routes.values()) {
|
||||
for (UserAccessServerRouteEntry route : routes.values()) {
|
||||
if (isOwnServer(route, ownServerLogin)) continue;
|
||||
if (oneMessageDelete) {
|
||||
REMOTE.deleteMessage(route.serverAddress(), blobB64);
|
||||
REMOTE.deleteMessage(route.getServerUrl(), blobB64);
|
||||
} else {
|
||||
REMOTE.deleteConversation(route.serverAddress(), blobB64);
|
||||
REMOTE.deleteConversation(route.getServerUrl(), blobB64);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@@ -68,15 +91,15 @@ public final class DmFederationService {
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private static Map<String, SolanaUserPdaImportService.ParsedServerRoute> routesByLogin(
|
||||
List<SolanaUserPdaImportService.ParsedServerRoute>... routeLists
|
||||
private static Map<String, UserAccessServerRouteEntry> routesByLogin(
|
||||
List<UserAccessServerRouteEntry>... routeLists
|
||||
) {
|
||||
Map<String, SolanaUserPdaImportService.ParsedServerRoute> out = new LinkedHashMap<>();
|
||||
for (List<SolanaUserPdaImportService.ParsedServerRoute> routeList : routeLists) {
|
||||
for (SolanaUserPdaImportService.ParsedServerRoute route : routeList) {
|
||||
Map<String, UserAccessServerRouteEntry> out = new LinkedHashMap<>();
|
||||
for (List<UserAccessServerRouteEntry> routeList : routeLists) {
|
||||
for (UserAccessServerRouteEntry route : routeList) {
|
||||
if (route == null) continue;
|
||||
String login = normalize(route.login());
|
||||
String address = route.serverAddress() == null ? "" : route.serverAddress().trim();
|
||||
String login = normalize(route.getServerLogin());
|
||||
String address = route.getServerUrl() == null ? "" : route.getServerUrl().trim();
|
||||
if (login == null || address.isBlank()) continue;
|
||||
out.putIfAbsent(login, route);
|
||||
}
|
||||
@@ -84,8 +107,11 @@ public final class DmFederationService {
|
||||
return out;
|
||||
}
|
||||
|
||||
private static boolean isOwnServer(SolanaUserPdaImportService.ParsedServerRoute route, String ownServerLogin) {
|
||||
return ownServerLogin != null && ownServerLogin.equalsIgnoreCase(route.login());
|
||||
private static boolean isOwnServer(UserAccessServerRouteEntry route, String ownServerLogin) {
|
||||
return ownServerLogin != null
|
||||
&& route != null
|
||||
&& route.getServerLogin() != null
|
||||
&& ownServerLogin.equalsIgnoreCase(route.getServerLogin());
|
||||
}
|
||||
|
||||
private static String ownServerLogin() {
|
||||
|
||||
+75
-6
@@ -8,6 +8,8 @@ import java.net.http.HttpClient;
|
||||
import java.net.http.WebSocket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
@@ -21,36 +23,83 @@ public final class RemoteDmSyncClient {
|
||||
.connectTimeout(Duration.ofSeconds(6))
|
||||
.build();
|
||||
|
||||
public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64) throws Exception {
|
||||
public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64, String sourceServerLogin) throws Exception {
|
||||
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
||||
String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64);
|
||||
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
{
|
||||
"op":"ReceiveOutcomingMessage",
|
||||
"requestId":%s,
|
||||
"payload":{
|
||||
"incomingBlobB64":%s,
|
||||
"outgoingBlobB64":%s
|
||||
"outgoingBlobB64":%s%s
|
||||
}
|
||||
}
|
||||
""".formatted("%s", incomingJson, outgoingJson));
|
||||
""".formatted("%s", incomingJson, outgoingJson, sourceServerLoginJson));
|
||||
ensureOk("ReceiveOutcomingMessage", response);
|
||||
}
|
||||
|
||||
public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64) throws Exception {
|
||||
public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64, String sourceServerLogin) throws Exception {
|
||||
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
|
||||
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
{
|
||||
"op":"ReceiveIncomingMessage",
|
||||
"requestId":%s,
|
||||
"payload":{
|
||||
"incomingBlobB64":%s
|
||||
"incomingBlobB64":%s%s
|
||||
}
|
||||
}
|
||||
""".formatted("%s", incomingJson));
|
||||
""".formatted("%s", incomingJson, sourceServerLoginJson));
|
||||
ensureOk("ReceiveIncomingMessage", response);
|
||||
}
|
||||
|
||||
public RemoteDmBatch dmSyncBatch(
|
||||
String serverAddressRaw,
|
||||
String ownerLogin,
|
||||
long afterStoredAtMs,
|
||||
String afterMessageKey,
|
||||
int limit,
|
||||
int maxBytes
|
||||
) throws Exception {
|
||||
String ownerLoginJson = MAPPER.writeValueAsString(ownerLogin);
|
||||
String afterMessageKeyJson = MAPPER.writeValueAsString(afterMessageKey == null ? "" : afterMessageKey);
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
{
|
||||
"op":"DmSyncBatch",
|
||||
"requestId":%s,
|
||||
"payload":{
|
||||
"ownerLogin":%s,
|
||||
"afterStoredAtMs":%d,
|
||||
"afterMessageKey":%s,
|
||||
"limit":%d,
|
||||
"maxBytes":%d
|
||||
}
|
||||
}
|
||||
""".formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson, limit, maxBytes));
|
||||
ensureOk("DmSyncBatch", response);
|
||||
|
||||
JsonNode payload = response.path("payload");
|
||||
List<RemoteDmItem> items = new ArrayList<>();
|
||||
JsonNode arr = payload.path("items");
|
||||
if (arr.isArray()) {
|
||||
for (JsonNode item : arr) {
|
||||
items.add(new RemoteDmItem(
|
||||
item.path("messageKey").asText(""),
|
||||
item.path("storedAtMs").asLong(0L),
|
||||
item.path("blobB64").asText("")
|
||||
));
|
||||
}
|
||||
}
|
||||
return new RemoteDmBatch(
|
||||
payload.path("nextStoredAtMs").asLong(afterStoredAtMs),
|
||||
payload.path("nextMessageKey").asText(afterMessageKey == null ? "" : afterMessageKey),
|
||||
payload.path("hasMore").asBoolean(false),
|
||||
items
|
||||
);
|
||||
}
|
||||
|
||||
public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception {
|
||||
String blobJson = MAPPER.writeValueAsString(blobB64);
|
||||
JsonNode response = send(serverAddressRaw, """
|
||||
@@ -107,6 +156,13 @@ public final class RemoteDmSyncClient {
|
||||
return MAPPER.readTree(responseJson);
|
||||
}
|
||||
|
||||
private String toOptionalJsonField(String fieldName, String value) throws Exception {
|
||||
if (value == null || value.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return ",\n \"" + fieldName + "\":" + MAPPER.writeValueAsString(value.trim());
|
||||
}
|
||||
|
||||
private void ensureOk(String op, JsonNode response) {
|
||||
int status = response.path("status").asInt(500);
|
||||
if (status >= 200 && status < 300) return;
|
||||
@@ -126,6 +182,19 @@ public final class RemoteDmSyncClient {
|
||||
}
|
||||
}
|
||||
|
||||
public record RemoteDmBatch(
|
||||
long nextStoredAtMs,
|
||||
String nextMessageKey,
|
||||
boolean hasMore,
|
||||
List<RemoteDmItem> items
|
||||
) {}
|
||||
|
||||
public record RemoteDmItem(
|
||||
String messageKey,
|
||||
long storedAtMs,
|
||||
String blobB64
|
||||
) {}
|
||||
|
||||
private static final class SyncWsListener implements WebSocket.Listener {
|
||||
private final CompletableFuture<String> responseFuture;
|
||||
private final CountDownLatch openLatch;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package server.sync;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.messages.DmSyncApplySupport;
|
||||
import shine.db.dao.DmSyncPeerStateDAO;
|
||||
import shine.db.dao.UserAccessServersCurrentDAO;
|
||||
import shine.db.entities.DmSyncPeerStateEntry;
|
||||
import shine.db.entities.UserAccessServerRouteEntry;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Периодическая догоняющая синхронизация личной переписки между access-серверами пользователя.
|
||||
*/
|
||||
public final class PeriodicDmSyncService {
|
||||
private static final Logger log = LoggerFactory.getLogger(PeriodicDmSyncService.class);
|
||||
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
|
||||
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||
|
||||
private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient();
|
||||
private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance();
|
||||
private static final DmSyncPeerStateDAO STATE_DAO = DmSyncPeerStateDAO.getInstance();
|
||||
|
||||
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
|
||||
@Override
|
||||
public Thread newThread(Runnable r) {
|
||||
Thread t = new Thread(r, "periodic-dm-sync");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
}
|
||||
});
|
||||
|
||||
private PeriodicDmSyncService() {}
|
||||
|
||||
public static void startOrLog() {
|
||||
if (!isEnabled()) {
|
||||
log.info("Periodic DM sync disabled by dm.sync.enabled=false");
|
||||
return;
|
||||
}
|
||||
if (!STARTED.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
long initialDelaySec = configLong("dm.sync.initialDelaySeconds", 60L, 0L, 3600L);
|
||||
long periodHours = configLong("dm.sync.periodHours", 6L, 1L, 168L);
|
||||
EXECUTOR.scheduleWithFixedDelay(
|
||||
PeriodicDmSyncService::runCycleSafe,
|
||||
initialDelaySec,
|
||||
TimeUnit.HOURS.toSeconds(periodHours),
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
log.info("Periodic DM sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours);
|
||||
}
|
||||
|
||||
private static void runCycleSafe() {
|
||||
try {
|
||||
runCycle();
|
||||
} catch (Exception e) {
|
||||
log.error("Periodic DM sync failed unexpectedly", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void runCycle() throws Exception {
|
||||
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
|
||||
if (ownServerLogin == null) {
|
||||
log.warn("Periodic DM sync skipped: {} is empty", SERVER_LOGIN_CONFIG);
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin);
|
||||
Set<String> owners = new LinkedHashSet<>(ownersRaw);
|
||||
if (owners.isEmpty()) {
|
||||
log.info("Periodic DM sync skipped: no local access-server users for {}", ownServerLogin);
|
||||
return;
|
||||
}
|
||||
|
||||
int syncedPeers = 0;
|
||||
int appliedEvents = 0;
|
||||
for (String ownerLogin : owners) {
|
||||
List<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 {
|
||||
appliedEvents += syncOwnerFromRemote(ownerLogin, route);
|
||||
syncedPeers++;
|
||||
} catch (Exception e) {
|
||||
STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e));
|
||||
log.warn("Periodic DM sync peer failed: owner={} remoteServer={} reason={}",
|
||||
ownerLogin, route.getServerLogin(), String.valueOf(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("Periodic DM sync cycle finished: owners={} syncedPeers={} appliedEvents={}",
|
||||
owners.size(), syncedPeers, appliedEvents);
|
||||
}
|
||||
|
||||
private static int syncOwnerFromRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception {
|
||||
int limit = (int) configLong("dm.sync.batchLimit", 500L, 1L, 500L);
|
||||
int maxBytes = (int) configLong("dm.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L);
|
||||
int maxPages = (int) configLong("dm.sync.maxPagesPerPeer", 50L, 1L, 500L);
|
||||
|
||||
DmSyncPeerStateEntry state = STATE_DAO.getOrCreate(ownerLogin, route.getServerLogin(), route.getServerUrl());
|
||||
long cursorStoredAtMs = state.getCursorStoredAtMs();
|
||||
String cursorMessageKey = state.getCursorMessageKey() == null ? "" : state.getCursorMessageKey();
|
||||
int applied = 0;
|
||||
boolean bootstrapCompleted = false;
|
||||
|
||||
for (int page = 0; page < maxPages; page++) {
|
||||
RemoteDmSyncClient.RemoteDmBatch batch = REMOTE.dmSyncBatch(
|
||||
route.getServerUrl(),
|
||||
ownerLogin,
|
||||
cursorStoredAtMs,
|
||||
cursorMessageKey,
|
||||
limit,
|
||||
maxBytes
|
||||
);
|
||||
|
||||
for (RemoteDmSyncClient.RemoteDmItem item : batch.items()) {
|
||||
if (item == null || item.blobB64() == null || item.blobB64().isBlank()) continue;
|
||||
DmSyncApplySupport.ApplyResult result = DmSyncApplySupport.applySyncedBlob(ownerLogin, item.blobB64());
|
||||
if (result.status().applied()) {
|
||||
applied++;
|
||||
}
|
||||
}
|
||||
|
||||
cursorStoredAtMs = Math.max(cursorStoredAtMs, batch.nextStoredAtMs());
|
||||
cursorMessageKey = batch.nextMessageKey() == null ? "" : batch.nextMessageKey();
|
||||
bootstrapCompleted = !batch.hasMore();
|
||||
STATE_DAO.updateSuccess(
|
||||
ownerLogin,
|
||||
route.getServerLogin(),
|
||||
route.getServerUrl(),
|
||||
cursorStoredAtMs,
|
||||
cursorMessageKey,
|
||||
bootstrapCompleted
|
||||
);
|
||||
|
||||
if (!batch.hasMore() || batch.items().isEmpty()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bootstrapCompleted) {
|
||||
log.info("Periodic DM sync peer paused by page limit: owner={} remoteServer={} maxPages={}",
|
||||
ownerLogin, route.getServerLogin(), maxPages);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
private static boolean isEnabled() {
|
||||
String raw = AppConfig.getInstance().getParam("dm.sync.enabled");
|
||||
return raw == null || raw.isBlank() || Boolean.parseBoolean(raw.trim());
|
||||
}
|
||||
|
||||
private static long configLong(String key, long defaultValue, long min, long max) {
|
||||
String raw = AppConfig.getInstance().getParam(key);
|
||||
if (raw == null || raw.isBlank()) return defaultValue;
|
||||
try {
|
||||
long parsed = Long.parseLong(raw.trim());
|
||||
return Math.max(min, Math.min(max, parsed));
|
||||
} catch (Exception ignored) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
if (value == null) return null;
|
||||
String s = value.trim().toLowerCase(Locale.ROOT);
|
||||
return s.isEmpty() ? null : s;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory;
|
||||
import server.debug.DebugApiConfigurator;
|
||||
import server.sync.BlockchainResyncRecoveryOnStartup;
|
||||
import server.sync.PeriodicBlockchainSyncService;
|
||||
import server.sync.PeriodicDmSyncService;
|
||||
import server.sync.SolanaUsersSyncStartupService;
|
||||
import server.sync.SyncServersBootstrapService;
|
||||
import utils.config.AppConfig;
|
||||
@@ -102,6 +103,7 @@ public final class WsServer {
|
||||
|
||||
server.start();
|
||||
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
|
||||
PeriodicDmSyncService.startOrLog();
|
||||
server.join();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,14 @@ sync.importUserProfileFromPartner.enabled=false
|
||||
# Если какое-то значение не задано, сервер вернёт пустую строку.
|
||||
# ------------------------------------------------------------
|
||||
server.version=${projectVersion}
|
||||
|
||||
# Межсерверная догоняющая синхронизация личных сообщений.
|
||||
dm.sync.enabled=true
|
||||
dm.sync.initialDelaySeconds=60
|
||||
dm.sync.periodHours=6
|
||||
dm.sync.batchLimit=500
|
||||
dm.sync.batchMaxBytes=3000000
|
||||
dm.sync.maxPagesPerPeer=50
|
||||
server.info.url=
|
||||
server.info.physicalRegion=
|
||||
server.info.description=
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
client.version=1.2.355
|
||||
server.version=1.2.339
|
||||
client.version=1.5.1
|
||||
server.version=1.4.5
|
||||
|
||||
@@ -8,7 +8,8 @@ EXPECTED_CADDY_SITE="server2.shineup.me" \
|
||||
DEPLOY_SERVER_LOGIN="server2shineupme" \
|
||||
DEPLOY_SERVER_ADDRESS="server2.shineup.me" \
|
||||
DEPLOY_SOLANA_CLUSTER="mainnet" \
|
||||
DEPLOY_SOLANA_ENDPOINT="https://mainnet.helius-rpc.com/?api-key=5e1bfa84-b376-4aa6-8a7b-4a4250181622" \
|
||||
DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \
|
||||
DEPLOY_WEBPUSH_VAPID_PUBLIC="BGI4ZG5ADm8MrUG0EQi2G0ZxoqTbZZyfUOzt3O-HrM6z4-zFPUpGGIGcA7Tx3N-O0r5Pie0ndGSdx6kRU2X2x4g" \
|
||||
TARGET_URL="https://server2.shineup.me" \
|
||||
REQUIRE_BACKUP="1" \
|
||||
bash "$(dirname "$0")/deploy_ui.sh"
|
||||
|
||||
@@ -8,7 +8,8 @@ EXPECTED_CADDY_SITE="shineup.me" \
|
||||
DEPLOY_SERVER_LOGIN="shineupme" \
|
||||
DEPLOY_SERVER_ADDRESS="shineup.me" \
|
||||
DEPLOY_SOLANA_CLUSTER="mainnet" \
|
||||
DEPLOY_SOLANA_ENDPOINT="https://solana-rpc.publicnode.com" \
|
||||
DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \
|
||||
DEPLOY_WEBPUSH_VAPID_PUBLIC="BEX12-68cSJVYvWSlwgLpd8TqITZrxxh8n20EulUK0OK4u5uWCsuu-iam86zVGYoZJTl1QKxlikGYhB-GLKSsxQ" \
|
||||
TARGET_URL="https://shineup.me" \
|
||||
REQUIRE_BACKUP="1" \
|
||||
bash "$(dirname "$0")/deploy_ui.sh"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
Сейчас здесь два метода:
|
||||
|
||||
- `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных;
|
||||
- `SearchUsers` — dev/test поиск логинов по префиксу.
|
||||
- `SearchUsers` — dev/test поиск логинов по префиксу, при необходимости с фильтром только по server PDA.
|
||||
|
||||
Регистрация выполняется только через Solana.
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
### Назначение
|
||||
|
||||
Поиск пользователей по префиксу логина. Операция зарегистрирована в серверном API и используется как вспомогательная dev/test операция.
|
||||
При необходимости можно включить фильтр только по server PDA.
|
||||
|
||||
### Запрос
|
||||
|
||||
@@ -119,11 +120,16 @@
|
||||
"op": "SearchUsers",
|
||||
"requestId": "search-001",
|
||||
"payload": {
|
||||
"prefix": "an"
|
||||
"prefix": "an",
|
||||
"isServer": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `prefix` — обязательный префикс логина.
|
||||
- `isServer` — необязательный boolean-флаг; если `true`, сервер вернёт только логины,
|
||||
у которых в `solana_user_pda_current` стоит `is_server = true`.
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
- `limit_exceeded`
|
||||
- `chain_resync_in_progress` — цепочка временно заблокирована полным resync
|
||||
- `repost_disabled` — репосты временно отключены до будущей реализации
|
||||
- `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META`
|
||||
- `internal_error`
|
||||
|
||||
## 5. Какие блоки реально можно добавлять через `AddBlock`
|
||||
@@ -104,6 +105,7 @@
|
||||
- `TEXT_REPLY (20)`
|
||||
- `TEXT_EDIT_REPLY (21)`
|
||||
- `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
|
||||
- `TEXT_CHANNEL_META (70)` — скрытый технический снимок профиля канала
|
||||
|
||||
3. **REACTION (type=2)**
|
||||
- `REACTION_LIKE (1)`
|
||||
@@ -133,7 +135,57 @@
|
||||
5. **USER_PARAM (type=4)**
|
||||
- `USER_PARAM_TEXT_TEXT (1)`
|
||||
|
||||
## 6. Хватает ли функций сейчас
|
||||
## 6. Практические payload-форматы для каналов и вложений
|
||||
|
||||
`AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата.
|
||||
|
||||
### Вложения в сообщениях
|
||||
|
||||
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `SHiNE:attach v=1` или `v=2`.
|
||||
|
||||
Пример текстового содержимого body:
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
||||
Текст сообщения
|
||||
```
|
||||
|
||||
Пример вложения с отдельным preview-файлом:
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
||||
```
|
||||
|
||||
Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`.
|
||||
|
||||
### Создание публичного канала с профилем
|
||||
|
||||
Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст:
|
||||
|
||||
```text
|
||||
<SHiNE:title;v=1;Человекочитаемое имя канала>
|
||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
||||
Описание канала
|
||||
```
|
||||
|
||||
Ограничение `channelDescription` — до `2048` UTF-8 байт. Новый клиент не пишет отдельный `TEXT_CHANNEL_META` сразу после создания канала: создание канала и начальный профиль должны попадать в один `TECH_CREATE_CHANNEL`.
|
||||
|
||||
### Изменение профиля канала
|
||||
|
||||
Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=70)`.
|
||||
|
||||
Текстовое содержимое body использует тот же формат полного снимка профиля:
|
||||
|
||||
```text
|
||||
<SHiNE:title;v=1;Новое имя канала>
|
||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=AbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdE>
|
||||
Новое описание канала
|
||||
```
|
||||
|
||||
Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `SHiNE:avatar` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`.
|
||||
|
||||
## 7. Хватает ли функций сейчас
|
||||
|
||||
Коротко: **для записи событий в блокчейн — хватает**, для полноценного клиентского чтения — **пока не хватает**.
|
||||
|
||||
@@ -147,7 +199,7 @@
|
||||
- нет API списка подписок с серверными счётчиками непрочитанного;
|
||||
- нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC.
|
||||
|
||||
## 7. Рекомендации по клиенту при записи блоков
|
||||
## 8. Рекомендации по клиенту при записи блоков
|
||||
|
||||
1. Перед отправкой держать локальный `lastNumber/lastHash`.
|
||||
2. При `bad_prev_hash` или `bad_block_number`:
|
||||
@@ -157,7 +209,7 @@
|
||||
4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост.
|
||||
|
||||
|
||||
## 8. USER_PARAM для «личных данных»
|
||||
## 9. USER_PARAM для «личных данных»
|
||||
|
||||
Да, на текущем API это можно добавить **без изменения серверного кода**:
|
||||
|
||||
@@ -183,7 +235,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 9. `GetBlockchainBlock`
|
||||
## 10. `GetBlockchainBlock`
|
||||
|
||||
### Назначение
|
||||
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
"ownerLogin": "Alice",
|
||||
"ownerBlockchainName": "alice-001",
|
||||
"channelName": "0",
|
||||
"displayName": "Мой канал",
|
||||
"channelDescription": "Короткое описание",
|
||||
"avaAr": "ArweaveTxId...",
|
||||
"avaSha256": "0123...",
|
||||
"avaSize": 248193,
|
||||
"metaUpdatedAtMs": 1760000000000,
|
||||
"personal": true,
|
||||
"channelRoot": { "blockNumber": 0, "blockHash": "..." }
|
||||
},
|
||||
@@ -141,8 +147,42 @@
|
||||
"ownerLogin": "Bob",
|
||||
"ownerBlockchainName": "bob-001",
|
||||
"channelName": "news",
|
||||
"displayName": "Новости Bob",
|
||||
"channelDescription": "Канал о новостях",
|
||||
"avaAr": "ArweaveTxId...",
|
||||
"avaSha256": "0123...",
|
||||
"avaSize": 248193,
|
||||
"metaUpdatedAtMs": 1760000000000,
|
||||
"channelRoot": { "blockNumber": 123, "blockHash": "..." }
|
||||
},
|
||||
"metaEvents": [
|
||||
{
|
||||
"kind": "created",
|
||||
"messageRef": { "blockNumber": 123, "blockHash": "..." },
|
||||
"authorLogin": "Bob",
|
||||
"authorBlockchainName": "bob-001",
|
||||
"lineStep": 1,
|
||||
"createdAtMs": 1760000000000,
|
||||
"title": "news",
|
||||
"description": "",
|
||||
"avaAr": "",
|
||||
"avaSha256": "",
|
||||
"avaSize": 0
|
||||
},
|
||||
{
|
||||
"kind": "profile_updated",
|
||||
"messageRef": { "blockNumber": 130, "blockHash": "..." },
|
||||
"authorLogin": "Bob",
|
||||
"authorBlockchainName": "bob-001",
|
||||
"lineStep": 2,
|
||||
"createdAtMs": 1760000500000,
|
||||
"title": "Новости Bob",
|
||||
"description": "Канал о новостях",
|
||||
"avaAr": "ArweaveTxId...",
|
||||
"avaSha256": "0123...",
|
||||
"avaSize": 248193
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"messageRef": { "blockNumber": 140, "blockHash": "..." },
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
|
||||
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
||||
| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки |
|
||||
| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | межсерверная догоняющая синхронизация DM по курсору |
|
||||
| `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога |
|
||||
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
||||
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
Важно:
|
||||
|
||||
- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API;
|
||||
- для DM v1 нужно использовать только `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`.
|
||||
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
|
||||
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
|
||||
|
||||
## 1. `UpsertPushToken`
|
||||
|
||||
@@ -139,11 +140,14 @@
|
||||
"op": "ReceiveIncomingMessage",
|
||||
"requestId": "dm-in-001",
|
||||
"payload": {
|
||||
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK"
|
||||
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK",
|
||||
"sourceServerLogin": "server-a"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику.
|
||||
|
||||
## 5. `DeleteMessage`
|
||||
|
||||
Принимает один signed DM-блок `type=5` или `type=6`.
|
||||
@@ -241,7 +245,69 @@
|
||||
|
||||
Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа.
|
||||
|
||||
## 8. `AckSessionDelivery`
|
||||
## 8. `DmSyncBatch`
|
||||
|
||||
Межсерверная операция для догоняющей синхронизации истории одного пользователя. В текущей реализации не требует авторизации сервера-источника, но удалённый сервер отдаёт данные только если сам является access-сервером `ownerLogin` по `user_access_servers_current`.
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "DmSyncBatch",
|
||||
"requestId": "dm-sync-001",
|
||||
"payload": {
|
||||
"ownerLogin": "alice",
|
||||
"afterStoredAtMs": 1774700000000,
|
||||
"afterMessageKey": "alice|bob|1774699999000|123456780|2",
|
||||
"limit": 500,
|
||||
"maxBytes": 3000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`afterStoredAtMs` и `afterMessageKey` образуют курсор. Если курсора нет, сервер передаёт `0` и пустую строку. `limit` ограничен максимумом `500`.
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "DmSyncBatch",
|
||||
"requestId": "dm-sync-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"ownerLogin": "alice",
|
||||
"limit": 500,
|
||||
"rawBytes": 84512,
|
||||
"hasMore": true,
|
||||
"nextStoredAtMs": 1774700100000,
|
||||
"nextMessageKey": "alice|bob|1774700000123|123456789|1",
|
||||
"items": [
|
||||
{
|
||||
"messageKey": "alice|bob|1774700000123|123456789|1",
|
||||
"baseKey": "alice|bob|1774700000123|123456789",
|
||||
"targetLogin": "alice",
|
||||
"fromLogin": "bob",
|
||||
"toLogin": "alice",
|
||||
"messageType": 1,
|
||||
"timeMs": 1774700000123,
|
||||
"storedAtMs": 1774700100000,
|
||||
"blobB64": "BASE64_SIGNED_BLOCK"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
События в `items` идут по `storedAtMs ASC, messageKey ASC`. В пачке могут быть сообщения любых диалогов пользователя, read-receipt и delete/tombstone типов `5/6/7/8`.
|
||||
|
||||
Ошибки:
|
||||
|
||||
- `400 / EMPTY_OWNER_LOGIN` — не передан `ownerLogin`
|
||||
- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER` — этот сервер не является access-сервером пользователя
|
||||
- `500 / LOCAL_SERVER_NOT_CONFIGURED` — не настроен `server.SHiNE.login`
|
||||
|
||||
## 9. `AckSessionDelivery`
|
||||
|
||||
Требует авторизации. Подтверждает доставку в текущую сессию.
|
||||
|
||||
@@ -257,7 +323,7 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Событие `SignedMessageArrived`
|
||||
## 10. Событие `SignedMessageArrived`
|
||||
|
||||
Сервер присылает его по WebSocket в активные сессии адресата.
|
||||
|
||||
@@ -282,15 +348,15 @@
|
||||
|
||||
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
|
||||
|
||||
## 10. `CallInviteBroadcast`
|
||||
## 11. `CallInviteBroadcast`
|
||||
|
||||
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
|
||||
|
||||
## 10. `CallSignalToSession`
|
||||
## 12. `CallSignalToSession`
|
||||
|
||||
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
|
||||
|
||||
## 11. Замечания
|
||||
## 13. Замечания
|
||||
|
||||
- все DM-типы `1..8` используют `SHiNE_DM`
|
||||
- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE
|
||||
|
||||
@@ -9,6 +9,23 @@ Payload включает:
|
||||
4. `channelType` (`uint16`, 2 байта);
|
||||
5. `channelTypeVersion` (`uint16`, 2 байта).
|
||||
|
||||
### `channelDescription`
|
||||
|
||||
`channelDescription` хранится как `uint16 length + UTF-8`, максимум `2048` байт.
|
||||
|
||||
Для публичных каналов (`channelType=1`) поле является начальным снимком профиля канала и использует тот же текстовый meta-формат, что `TEXT_CHANNEL_META`:
|
||||
|
||||
```text
|
||||
<SHiNE:title;v=1;Название канала>
|
||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
||||
Описание канала.
|
||||
```
|
||||
|
||||
Если `title` отсутствует, человекочитаемым именем считается техническое `channelName`.
|
||||
Если `avatar` отсутствует, канал создаётся без аватара.
|
||||
|
||||
Новые UI-клиенты создают публичный канал одним блоком `TECH_CREATE_CHANNEL`; отдельный `TEXT_CHANNEL_META` при создании не пишется. `TEXT_CHANNEL_META` используется для последующих изменений профиля канала.
|
||||
|
||||
## 2. Типы каналов
|
||||
- `0` — `stories` (root-канал пользователя).
|
||||
- `1` — публичный канал.
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
# Командные сообщения каналов
|
||||
|
||||
## 1. Общий префикс
|
||||
Командные сообщения распознаются по префиксу:
|
||||
Команды в обычных `TEXT_POST` сообщениях каналов больше не используются для изменения профиля канала.
|
||||
|
||||
`/.`
|
||||
## Удалённая команда `/.desc`
|
||||
|
||||
Пример:
|
||||
- `/.desc Новый комментарий канала`
|
||||
Команда `/.desc <text>` больше не поддерживается и не применяется сервером.
|
||||
|
||||
## 2. Поддерживаемые команды
|
||||
Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок:
|
||||
|
||||
### Для всех типов каналов (`0`, `1`, `100`, `200`)
|
||||
- `/.desc <text>` — смена описания канала.
|
||||
- `msg_type=1`
|
||||
- `subType=70`
|
||||
- `TEXT_CHANNEL_META`
|
||||
|
||||
Примечание:
|
||||
- Описание канала в чтении определяется последней командой `/.desc` в линии канала.
|
||||
- Если `/.desc` не было, используется описание из `CreateChannel`.
|
||||
Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
|
||||
|
||||
### Дополнительно для `type=200`
|
||||
- `/.add <login> <channelName>`
|
||||
- `/.remove <login> <channelName>`
|
||||
## Зарезервированные команды type=200
|
||||
|
||||
Формат аргументов фиксирован: через пробел.
|
||||
|
||||
## 3. Текущая модель применения
|
||||
- Команды передаются как обычные `TEXT_POST` сообщения.
|
||||
- Сервер уже применяет `/.desc` при вычислении актуального описания канала.
|
||||
- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации.
|
||||
|
||||
## 4. Статус для MVP
|
||||
- В текущем UI каналы `type=100` и `type=200` не используются.
|
||||
- Соответственно, `/.add` и `/.remove` считаются запланированными и пока не участвуют в рабочем UI-сценарии.
|
||||
Команды `/.add` и `/.remove` для групповых каналов `type=200` остаются зарезервированными для будущей модели участников. В обычном UI текущего этапа они не используются.
|
||||
|
||||
@@ -27,6 +27,14 @@ TEXT-тип хранит сообщения и редактирования.
|
||||
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
|
||||
- временно отключён для записи через `AddBlock` до будущей реализации репостов.
|
||||
|
||||
6. `subType=70` — `TEXT_CHANNEL_META`
|
||||
- скрытый технический снимок профиля канала;
|
||||
- содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием;
|
||||
- не отображается как обычное сообщение ленты;
|
||||
- применяется сервером к текущему состоянию канала.
|
||||
|
||||
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
|
||||
|
||||
## Правило для edit
|
||||
|
||||
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
||||
@@ -36,3 +44,43 @@ TEXT-тип хранит сообщения и редактирования.
|
||||
- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`.
|
||||
- Такой edit трактуется как логическое удаление содержимого сообщения.
|
||||
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
|
||||
|
||||
## Вложения в текстовых сообщениях (`SHiNE:attach v=1/v=2`)
|
||||
|
||||
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`.
|
||||
|
||||
Подробная спецификация: [15_TEXT_Attachments.md](./15_TEXT_Attachments.md).
|
||||
|
||||
Бинарный формат `TextBody` не меняется: вложения являются частью UTF-8 текста.
|
||||
|
||||
Один блок вложения:
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123...;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
||||
```
|
||||
|
||||
Для файлов с отдельным превью клиент может использовать расширенный вариант:
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbb...;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeee...>
|
||||
```
|
||||
|
||||
Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст.
|
||||
|
||||
Обязательные поля:
|
||||
|
||||
- `v=1` или `v=2`
|
||||
- `name` - имя файла, закодированное через `encodeURIComponent`
|
||||
- `size` - размер файла в байтах
|
||||
- `sha256` - SHA-256 исходного файла в hex
|
||||
- `ar` - короткий Arweave Transaction ID, без полного URL
|
||||
- для `v=2` дополнительно могут присутствовать `previewAr` и `previewSha256`
|
||||
|
||||
Правила клиента:
|
||||
|
||||
- если сообщение начинается с одного или нескольких валидных `<SHiNE:attach;...>` блоков, новый клиент скрывает эти блоки и показывает карточки вложений;
|
||||
- если блок битый, клиент может игнорировать только этот блок и продолжить разбор остальных;
|
||||
- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст;
|
||||
- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок.
|
||||
|
||||
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `previewAr/previewSha256`.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Вложения в TEXT-сообщениях (`SHiNE:attach v=1/v=2`)
|
||||
|
||||
Документ фиксирует текущий формат вложений в текстовых блоках SHiNE.
|
||||
|
||||
## Область применения
|
||||
|
||||
Формат применяется к UTF-8 полю `text` в TEXT-блоках:
|
||||
|
||||
- `TEXT_POST`
|
||||
- `TEXT_REPLY`
|
||||
- `TEXT_EDIT_POST`
|
||||
- `TEXT_EDIT_REPLY`
|
||||
|
||||
Бинарный формат `TextBody` не меняется. Вложения являются техническим префиксом внутри обычного текста.
|
||||
|
||||
## Общий вид
|
||||
|
||||
Один attach-блок:
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
||||
```
|
||||
|
||||
Блок с превью:
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
||||
```
|
||||
|
||||
Несколько вложений идут подряд в самом начале текста:
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
||||
Текст сообщения
|
||||
```
|
||||
|
||||
После последнего attach-блока идёт обычный пользовательский текст. Пользовательский текст может быть пустым, если есть хотя бы одно валидное вложение.
|
||||
|
||||
Клиент SHiNE ограничивает одно сообщение максимум 10 вложениями и сохраняет порядок attach-блоков в том порядке, в котором пользователь прикрепил файлы.
|
||||
|
||||
## Поля
|
||||
|
||||
Обязательные поля:
|
||||
|
||||
- `v=1` - версия формата attach-блока;
|
||||
- `name` - имя файла, закодированное через `encodeURIComponent`;
|
||||
- `size` - размер файла в байтах;
|
||||
- `sha256` - SHA-256 исходного файла в hex, 64 символа;
|
||||
- `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL.
|
||||
|
||||
Дополнительные поля для `v=2`:
|
||||
|
||||
- `previewAr` - короткий Arweave Transaction ID файла-превью;
|
||||
- `previewSha256` - SHA-256 файла-превью в hex.
|
||||
|
||||
## Хранение файла
|
||||
|
||||
Файл хранится вне блокчейна в Arweave. В блокчейне хранится только метаинформация, достаточная для отображения и проверки:
|
||||
|
||||
- имя файла;
|
||||
- размер;
|
||||
- SHA-256;
|
||||
- Arweave `txId`.
|
||||
|
||||
Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `previewAr/previewSha256`.
|
||||
|
||||
## Создание вложения в UI
|
||||
|
||||
UI поддерживает три сценария:
|
||||
|
||||
- загрузить новый файл в Arweave выбранным Arweave-кошельком;
|
||||
- ввести существующий `txId`, скачать файл через Arweave gateway и локально посчитать `size/sha256`;
|
||||
- выбрать файл из журнала файлов, загруженных в текущей браузерной сессии.
|
||||
|
||||
Для видеофайлов UI может по желанию пользователя автоматически создать превью из первого кадра. Технически клиент загружает два файла:
|
||||
|
||||
- основной видеофайл;
|
||||
- отдельное изображение-превью.
|
||||
|
||||
После успешной загрузки в журнале хранится один элемент основного файла, но с полями `previewAr/previewSha256`. В UI такой элемент помечается как файл `С превью`.
|
||||
|
||||
При ручном добавлении существующего `txId` для видео UI также позволяет вручную указать `txId` файла-превью.
|
||||
|
||||
Журнал хранится только в `sessionStorage` текущего браузера и не является частью блокчейна.
|
||||
В настройках клиента есть отдельный экран `Загрузка файлов`: пользователь может заранее загрузить файл в Arweave без создания сообщения, увидеть компактную плитку с именем, размером, временем загрузки, `txId` и статусом доступности через gateway, а затем выбрать этот файл из истории при создании сообщения.
|
||||
Если файл загружен заранее, но ещё не был отправлен ни в одном сообщении SHiNE, клиент показывает локальный флаг `Не добавлен в SHiNE`. Флаг снимается после успешной отправки сообщения с этим вложением.
|
||||
Если история очищена, уже созданные сообщения не меняются: в блокчейне остаются attach-блоки с `txId`.
|
||||
|
||||
## Отображение
|
||||
|
||||
Новый клиент при чтении сообщения:
|
||||
|
||||
1. читает валидные attach-блоки только из начала текста;
|
||||
2. скрывает технические attach-строки;
|
||||
3. отображает вложения над пользовательским текстом;
|
||||
4. показывает вложения горизонтальной каруселью: одно вложение на экране, переключение свайпом или стрелками, счётчик вида `1 из 3`;
|
||||
5. определяет тип вложения по расширению имени файла:
|
||||
- изображение показывает как ограниченное по размеру превью;
|
||||
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
|
||||
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
|
||||
6. если у видео есть `previewAr/previewSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок;
|
||||
7. при перелистывании останавливает воспроизводящееся видео;
|
||||
8. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
|
||||
|
||||
Если файл по ссылке Arweave gateway недоступен, клиент показывает диагностический блок:
|
||||
|
||||
- если сообщение создано менее 20 минут назад: файл, скорее всего, ещё не распространился в Arweave, нужно повторить через несколько минут;
|
||||
- если сообщение старше 20 минут: файл считается недоступным.
|
||||
|
||||
Старые клиенты без поддержки формата могут показывать attach-блоки как обычный текст.
|
||||
|
||||
## Редактирование
|
||||
|
||||
При редактировании сообщения UI сохраняет существующие attach-блоки и меняет только пользовательский текст. Удаление сообщения по-прежнему выполняется через edit-блок с пустым текстом, как описано в `11_TEXT_Blocks.md`.
|
||||
|
||||
## Совместимость и ограничения
|
||||
|
||||
- `ar` допускает только короткий Arweave `txId`, полный URL не используется;
|
||||
- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
|
||||
- старые блоки `v=1` без превью остаются валидными и читаются без изменений;
|
||||
- `previewAr/previewSha256` используются только если присутствуют оба поля и оба валидны;
|
||||
- MIME type, width, height, duration и thumbnail не записываются в блокчейн отдельными полями;
|
||||
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
|
||||
@@ -0,0 +1,61 @@
|
||||
# TEXT_CHANNEL_META: профиль канала
|
||||
|
||||
`TEXT_CHANNEL_META` — скрытый технический `TEXT`-блок для полного снимка профиля канала после создания.
|
||||
|
||||
## Блок
|
||||
|
||||
- `msg_type = 1`
|
||||
- `subType = 70`
|
||||
- `msgVersion = 1`
|
||||
- body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст.
|
||||
|
||||
Блок пишется только в линию канала владельца. В обычной ленте сообщений он не отображается как пользовательский пост.
|
||||
|
||||
Первый снимок профиля публичного канала хранится прямо в `TECH_CREATE_CHANNEL.channelDescription` тем же текстовым форматом. Отдельный `TEXT_CHANNEL_META` при создании нового публичного канала UI не пишет.
|
||||
|
||||
## Текстовый формат
|
||||
|
||||
В начале текста могут идти технические теги, после них обычный текст описания:
|
||||
|
||||
```text
|
||||
<SHiNE:title;v=1;Название канала>
|
||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
||||
Описание канала.
|
||||
```
|
||||
|
||||
Поддерживаемые теги:
|
||||
|
||||
- `<SHiNE:title;v=1;...>` — человекочитаемое имя канала.
|
||||
- `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>` — аватар канала в Arweave.
|
||||
|
||||
## Правила
|
||||
|
||||
- Каждый `TEXT_CHANNEL_META` задаёт полный снимок состояния профиля канала.
|
||||
- Старые meta-блоки остаются в блокчейне как история, но текущее состояние берётся из последнего валидного meta-блока.
|
||||
- Если `title` отсутствует, используется техническое имя канала.
|
||||
- Если `avatar` отсутствует, аватар считается сброшенным.
|
||||
- Если описание после тегов отсутствует, описание считается пустым.
|
||||
- `meta_updated_at_ms` равен времени самого валидного meta-блока.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Длина `title` — максимум 50 Unicode code points.
|
||||
- В `title` запрещены `<`, `>`, `;`, табуляция, перевод строки и `\0`.
|
||||
- Длина описания — максимум 250 Unicode code points.
|
||||
- `avatar.ar` — Arweave transaction id из 43 символов.
|
||||
- `avatar.sha256` — 64 hex-символа.
|
||||
- `avatar.size` — положительный размер файла в байтах.
|
||||
|
||||
Если meta-блок невалиден, сервер не применяет его целиком.
|
||||
|
||||
## UI
|
||||
|
||||
- При создании публичного канала UI записывает человекочитаемое имя, аватар и описание в `TECH_CREATE_CHANNEL.channelDescription`.
|
||||
- При создании канала UI показывает системную карточку `Создан канал ...`.
|
||||
- При записи `TEXT_CHANNEL_META` UI показывает системную карточку `Изменён профиль канала`.
|
||||
- У системных карточек нет лайка, ответа, репоста, редактирования и других действий обычного сообщения.
|
||||
- По клику на системную карточку показываются аватар, человекочитаемое имя и описание на момент этого события.
|
||||
|
||||
## Замена старых команд
|
||||
|
||||
Команда `/.desc` больше не используется и не применяется сервером. Описание канала меняется только через `TEXT_CHANNEL_META`.
|
||||
@@ -1,5 +1,56 @@
|
||||
# История изменений документации блокчейна
|
||||
|
||||
## 2026-08-04 12:00:00 +0400
|
||||
- Базовый коммит-ориентир: `391b18a`.
|
||||
- Формат вложений расширен до `SHiNE:attach v=2` для опционального второго preview-файла: добавлены поля `previewAr` и `previewSha256` при сохранении совместимости со старыми `v=1`.
|
||||
- В UI загрузки для видео добавлен сценарий `Добавить превью`: клиент может извлечь первый кадр, загрузить его отдельным файлом в Arweave или через Turbo и сохранить связь preview с основным вложением.
|
||||
- В журнале загрузок файлы с preview помечаются явно и показываются расширенной плиткой с картинкой превью.
|
||||
|
||||
## 2026-07-31 17:22:07 +0400
|
||||
- Базовый коммит-ориентир: `05bd9f5`.
|
||||
- Уточнён UI вложений: превью последнего сообщения в списке каналов скрывает технические attach-блоки, карусель не показывает неактивные стрелки на краях, обычный файл отображается компактной кликабельной полоской без `txId`; формат attach-блока не изменён.
|
||||
|
||||
## 2026-07-31 17:13:26 +0400
|
||||
- Базовый коммит-ориентир: `60ab476`.
|
||||
- Уточнён UI журнала загрузок Arweave: статус доступности перенесён в начало второй строки плитки, верхняя панель закреплена как fixed-полоса вне прокрутки, в меню добавлен пункт `Добавить файл`, меню закрывается при клике вне него; формат attach-блока не изменён.
|
||||
|
||||
## 2026-07-31 17:06:55 +0400
|
||||
- Базовый коммит-ориентир: `bcae285`.
|
||||
- Уточнён UI журнала загрузок Arweave: плитки принудительно не растягиваются по высоте, а контейнер журнала запрещает горизонтальную прокрутку; формат attach-блока не изменён.
|
||||
|
||||
## 2026-07-31 16:58:00 +0400
|
||||
- Базовый коммит-ориентир: `cd67176`.
|
||||
- Уточнён UI журнала загрузок Arweave: экран называется `Загрузка файлов`, верхняя панель стала компактной и закреплённой сверху, плитки журнала тёмные и уменьшены по высоте; формат attach-блока не изменён.
|
||||
|
||||
## 2026-07-31 13:48:57 +0400
|
||||
- Базовый коммит-ориентир: `98eba54`.
|
||||
- Уточнено UI-поведение журнала загрузок Arweave: экран называется `Загрузить файлы в блокчейн`, использует компактные плитки и локальный флаг `Не добавлен в SHiNE` для файлов, которые загружены заранее, но ещё не отправлены во вложении сообщения.
|
||||
- Зафиксировано, что флаг хранится только в клиентском sessionStorage и снимается после успешной отправки сообщения с выбранным вложением; формат attach-блока не изменён.
|
||||
|
||||
## 2026-07-31 13:12:24 +0400
|
||||
- Базовый коммит-ориентир: `c530627`.
|
||||
- Для UI-вложений добавлен отдельный экран журнала загрузок Arweave в настройках: загрузка файла без создания сообщения, плитки истории с `txId`, размером, датой и статусом доступности через gateway.
|
||||
- Уточнено, что журнал хранится только в `sessionStorage` текущего браузера и не влияет на уже записанные attach-блоки в блокчейне.
|
||||
|
||||
## 2026-07-31 12:47:41 +0400
|
||||
- Базовый коммит-ориентир: `f961947`.
|
||||
- Для `SHiNE:attach v=1` уточнено клиентское отображение вложений: горизонтальная карусель над текстом сообщения, счётчик позиции, просмотр image/video в модальном окне и скачивание файлов.
|
||||
- Зафиксирован клиентский лимит: максимум 10 вложений в одном сообщении с сохранением порядка attach-блоков.
|
||||
- Описана диагностика недоступного файла через Arweave gateway: отдельное сообщение для свежих сообщений младше 20 минут и отдельное сообщение для старых недоступных файлов.
|
||||
|
||||
## 2026-07-30 11:12:47 +0400
|
||||
- Базовый коммит-ориентир: `e9e6628`.
|
||||
- Добавлен отдельный документ `docs/Blockchain/15_TEXT_Attachments.md` с полной спецификацией `SHiNE:attach v=1`.
|
||||
- В `docs/Blockchain/README.md` добавлена ссылка на документ вложений.
|
||||
- В UI порядок отображения сообщения с вложениями закреплён как: вложения сверху, пользовательский текст снизу.
|
||||
|
||||
## 2026-07-30 10:31:26 +0400
|
||||
- Базовый коммит-ориентир: `c7684d6`.
|
||||
- Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксирован клиентский формат вложений `SHiNE:attach v=1`.
|
||||
- Бинарный `TextBody` не изменён: вложения записываются техническими строками в начале UTF-8 `text`.
|
||||
- Вложение хранит `name`, `size`, `sha256` и короткий Arweave `txId` в поле `ar`; имя файла кодируется через `encodeURIComponent`.
|
||||
- В `docs/Blockchain/11_TEXT_Blocks.md` добавлены правила совместимости, отображения и допустимости пустого пользовательского текста при наличии вложений.
|
||||
|
||||
## 2026-07-14 20:43:43 +0400
|
||||
- Базовый коммит-ориентир: `118be41`.
|
||||
- В `docs/ИТХ/Спецификация_ИТХ_v1.md` изменён магик файла чекпоинта:
|
||||
@@ -68,6 +119,27 @@
|
||||
- В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации.
|
||||
- В `docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`.
|
||||
|
||||
## 2026-08-01 01:45:29 +0400
|
||||
- Базовый коммит-ориентир: `24afcba`.
|
||||
- Добавлен новый `TEXT`-подтип `TEXT_CHANNEL_META (subType=70)` для скрытого технического снимка профиля канала.
|
||||
- Зафиксирован формат тегов `<SHiNE:title;v=1;...>` и `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>`; описание хранится хвостовым UTF-8 текстом.
|
||||
- UI создания канала может сразу после `TECH_CREATE_CHANNEL` записать первый `TEXT_CHANNEL_META` с человекочитаемым именем, описанием и аватаром.
|
||||
- Команда `/.desc` удалена из актуальной модели изменения описания канала; профиль канала меняется только через `TEXT_CHANNEL_META`.
|
||||
- API чтения каналов расширен полями `displayName`, `avaAr`, `avaSha256`, `avaSize`, `metaUpdatedAtMs` и списком `metaEvents`.
|
||||
|
||||
## 2026-08-01 02:18:00 +0400
|
||||
- Базовый коммит-ориентир: `8125d1a`.
|
||||
- Увеличен лимит `CreateChannelBody.channelDescription` до `2048` UTF-8 байт.
|
||||
- Для публичных каналов начальный профиль канала теперь хранится прямо в `TECH_CREATE_CHANNEL.channelDescription` тем же meta-форматом, что `TEXT_CHANNEL_META`.
|
||||
- UI создания публичного канала больше не пишет отдельный `TEXT_CHANNEL_META` сразу после создания; отдельный meta-блок используется только для последующих изменений профиля.
|
||||
- Bootstrap `channel_names_state` при старте сервера теперь применяет последующие `TEXT_CHANNEL_META` поверх начального профиля из `TECH_CREATE_CHANNEL`.
|
||||
|
||||
## 2026-07-31 17:40:31 +0400
|
||||
- Базовый коммит-ориентир: `d640dd6`.
|
||||
- Формат блокчейна не менялся.
|
||||
- В UI каналов добавлен технический просмотр данных сообщения из уже полученного ответа сервера: цепочка автора, номер записи, хэш, тип, время, текст, public key/подпись при наличии и сырой JSON-блок.
|
||||
- Удалённые сообщения в UI теперь отображаются компактной строкой с переходом в историю версий.
|
||||
|
||||
## 2026-05-21 19:05:00 +0300
|
||||
- Базовый коммит-ориентир: `5344c42`.
|
||||
- Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`:
|
||||
|
||||
+14
-10
@@ -3,25 +3,29 @@
|
||||
Этот каталог описывает только текущий рабочий формат протокола для MVP.
|
||||
|
||||
## Основные документы
|
||||
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md)
|
||||
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md)
|
||||
Единый бинарный формат блока (Frame v0), подпись, базовые проверки.
|
||||
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
|
||||
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
|
||||
Виды цепочек и правила line-полей.
|
||||
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md)
|
||||
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md)
|
||||
Системные блоки (`msg_type=0`).
|
||||
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
|
||||
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
|
||||
Текстовые блоки (`msg_type=1`).
|
||||
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
|
||||
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
|
||||
Реакции (`msg_type=2`).
|
||||
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
|
||||
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
|
||||
Социальные связи (`msg_type=3`).
|
||||
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
|
||||
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
|
||||
Параметры пользователя (`msg_type=4`).
|
||||
8. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
||||
8. [15_TEXT_Attachments.md](./15_TEXT_Attachments.md)
|
||||
Вложения в TEXT-сообщениях через `SHiNE:attach v=1/v=2`, включая опциональные `previewAr/previewSha256` для видео и крупных изображений.
|
||||
9. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md)
|
||||
Скрытый `TEXT_CHANNEL_META` для профиля канала.
|
||||
10. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
||||
Типы каналов и формат `CreateChannelBody`.
|
||||
9. [02_Channel_Commands.md](./02_Channel_Commands.md)
|
||||
11. [02_Channel_Commands.md](./02_Channel_Commands.md)
|
||||
Команды в текстовых сообщениях каналов.
|
||||
10. [CHANGELOG.md](./CHANGELOG.md)
|
||||
12. [CHANGELOG.md](./CHANGELOG.md)
|
||||
Журнал изменений документации.
|
||||
|
||||
## Смежная документация
|
||||
|
||||
@@ -155,8 +155,6 @@
|
||||
При переносе нужно отдельно учитывать:
|
||||
- заголовок и стрелку назад;
|
||||
- поля логина и пароля;
|
||||
- переключатель режима 12 слов;
|
||||
- сетку слов;
|
||||
- строку статуса длины пароля;
|
||||
- строку статуса проверки логина;
|
||||
- кнопку проверки логина;
|
||||
|
||||
@@ -446,11 +446,14 @@ Request:
|
||||
"op": "ReceiveIncomingMessage",
|
||||
"requestId": "req-456",
|
||||
"payload": {
|
||||
"incomingBlobB64": "..."
|
||||
"incomingBlobB64": "...",
|
||||
"sourceServerLogin": "server-a"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`sourceServerLogin` необязателен и используется как best-effort подсказка, чтобы сервер при дальнейшей пересылке не отправлял то же событие обратно серверу-источнику.
|
||||
|
||||
### 10.3. `DeleteMessage`
|
||||
|
||||
Назначение:
|
||||
@@ -559,7 +562,50 @@ UI-следствие для клиента:
|
||||
|
||||
Идемпотентность обязательна.
|
||||
|
||||
### 11.3. Ошибки доставки
|
||||
### 11.3. Догоняющая синхронизация истории
|
||||
|
||||
Для восстановления пропущенных DM-событий между access-серверами используется отдельная операция:
|
||||
|
||||
- `DmSyncBatch`
|
||||
|
||||
Сервер-получатель синхронизации запрашивает у другого access-сервера историю одного пользователя по курсору:
|
||||
|
||||
- `ownerLogin`;
|
||||
- `afterStoredAtMs`;
|
||||
- `afterMessageKey`;
|
||||
- `limit`, максимум `500`;
|
||||
- `maxBytes`, ограничение суммарного размера raw-блоков пачки.
|
||||
|
||||
Удалённый сервер отдаёт все DM-события, относящиеся к этому пользователю:
|
||||
|
||||
- контентные копии и read-receipt по `target_login`;
|
||||
- tombstone типов `5/6/7/8`, где пользователь участвует как `fromLogin` или `toLogin`.
|
||||
|
||||
Порядок пачки:
|
||||
|
||||
- `created_at_ms ASC`;
|
||||
- `message_key ASC`.
|
||||
|
||||
Курсор хранится локально для пары:
|
||||
|
||||
- пользователь;
|
||||
- удалённый access-сервер.
|
||||
|
||||
При первом добавлении сервера или отсутствии курсора синхронизация стартует с `0` и постепенно подтягивает всю доступную историю пачками.
|
||||
|
||||
При применении событий, полученных через `DmSyncBatch`, сервер:
|
||||
|
||||
- проверяет формат `SHiNE_DM`;
|
||||
- проверяет подпись;
|
||||
- применяет существующие правила ревизий, read-receipt и tombstone;
|
||||
- не отправляет realtime/push-уведомления клиентам;
|
||||
- не запускает повторный fan-out, чтобы не создавать циклы.
|
||||
|
||||
Плановый sync запускается фоном после старта WebSocket-сервера и повторяется раз в 6 часов.
|
||||
|
||||
В текущей реализации межсерверная авторизация для `DmSyncBatch` ещё не включена. Сервер отдаёт пачку только если сам локально является access-сервером `ownerLogin` по актуальной таблице `user_access_servers_current`.
|
||||
|
||||
### 11.4. Ошибки доставки
|
||||
|
||||
Если часть серверов временно недоступна:
|
||||
|
||||
@@ -571,7 +617,7 @@ UI-следствие для клиента:
|
||||
|
||||
Основная таблица остаётся:
|
||||
|
||||
- `signed_messages_v2`
|
||||
- `signed_messages`
|
||||
|
||||
В ней должны сохраняться:
|
||||
|
||||
@@ -590,6 +636,12 @@ UI-следствие для клиента:
|
||||
|
||||
Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются.
|
||||
|
||||
Для догоняющей межсерверной синхронизации дополнительно используются:
|
||||
|
||||
- индекс по `target_login`, `created_at_ms`, `message_key`;
|
||||
- отдельные индексы по delete-событиям для `from_login` и `to_login`;
|
||||
- таблица `dm_sync_peer_state` с курсором чтения для пары `ownerLogin + remoteServerLogin`.
|
||||
|
||||
## 13. Что обязательно должно измениться в коде относительно v0.5
|
||||
|
||||
- сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`;
|
||||
@@ -610,4 +662,4 @@ UI-следствие для клиента:
|
||||
- хранение отдельного `keyId` шифрования в DM;
|
||||
- ротация `clientKey`;
|
||||
- финальная конкретная UI-реализация массовой перешифровки;
|
||||
- физическая полная реализация DM federation в текущем коде.
|
||||
- межсерверная авторизация `DmSyncBatch`.
|
||||
|
||||
@@ -327,3 +327,5 @@ ReadReceiptBody_v1_0
|
||||
## 13. Примечание о поддержке
|
||||
|
||||
В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`.
|
||||
|
||||
Межсерверная операция `DmSyncBatch` не вводит новый байтовый формат DM. Она передаёт уже сохранённые raw-контейнеры `SHiNE_DM` в Base64 вместе с серверными метаданными курсора (`storedAtMs`, `messageKey`), а принимающий сервер заново проверяет подпись и применяет тот же контейнер по его `messageType`.
|
||||
|
||||
+11
-1
@@ -9,7 +9,7 @@
|
||||
<link rel="apple-touch-icon" href="./img/logo.jpg" />
|
||||
<title>СИЯНИЕ</title>
|
||||
<script>
|
||||
window.__SHINE_BUILD_HASH__ = '20260715214500';
|
||||
window.__SHINE_BUILD_HASH__ = '20260806223040';
|
||||
window.__SHINE_CLIENT_VERSION__ = '1.2.10';
|
||||
</script>
|
||||
<script>
|
||||
@@ -26,6 +26,16 @@ window.__SHINE_BUILD_HASH__ = '20260715214500';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="initial-splash" class="initial-splash" aria-label="Сияние">
|
||||
<div class="initial-splash__logo-wrap">
|
||||
<img
|
||||
class="initial-splash__logo"
|
||||
src="./img/shine-logo-transparent-final.png"
|
||||
alt="Логотип Сияние"
|
||||
/>
|
||||
</div>
|
||||
<div class="initial-splash__brand">Сияние</div>
|
||||
</div>
|
||||
<div class="app-shell">
|
||||
<main id="app-screen" class="screen-content"></main>
|
||||
<div id="toolbar-slot" class="toolbar-slot"></div>
|
||||
|
||||
@@ -56,8 +56,10 @@ import * as profileView from './pages/profile-view.js?v=202607150910';
|
||||
import * as profileEditView from './pages/profile-edit-view.js';
|
||||
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||
import * as settingsView from './pages/settings-view.js';
|
||||
import * as accessServersView from './pages/access-servers-view.js';
|
||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
||||
import * as arweaveUploadsView from './pages/arweave-uploads-view.js';
|
||||
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
|
||||
import * as deviceView from './pages/device-view.js?v=202606131435';
|
||||
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
|
||||
@@ -116,8 +118,10 @@ const routes = {
|
||||
'profile-edit-view': profileEditView,
|
||||
'wallet-view': walletView,
|
||||
'settings-view': settingsView,
|
||||
'access-servers-view': accessServersView,
|
||||
'developer-settings-view': developerSettingsView,
|
||||
'server-settings-view': serverSettingsView,
|
||||
'arweave-uploads-view': arweaveUploadsView,
|
||||
'remote-addblock-session-view': remoteAddBlockSessionView,
|
||||
'device-view': deviceView,
|
||||
'connect-device-view': connectDeviceView,
|
||||
@@ -148,6 +152,7 @@ const routes = {
|
||||
const screenEl = document.getElementById('app-screen');
|
||||
const toolbarEl = document.getElementById('toolbar-slot');
|
||||
const appShellEl = document.querySelector('.app-shell');
|
||||
const initialSplashEl = document.getElementById('initial-splash');
|
||||
|
||||
const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000;
|
||||
const SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS = 2000;
|
||||
@@ -173,6 +178,7 @@ let uiVersionCheckInFlight = false;
|
||||
let uiVersionPeriodicIntervalId = null;
|
||||
let hiddenDmAudioContext = null;
|
||||
let hiddenDmAudioUnlocked = false;
|
||||
let initialConnectionCompleted = false;
|
||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||
const GUEST_ALLOWED_PAGES = new Set([
|
||||
'start-view',
|
||||
@@ -327,6 +333,11 @@ function refreshConnectionUi() {
|
||||
const bannerEl = ensureConnectionRetryBannerEl();
|
||||
if (!bannerEl) return;
|
||||
|
||||
if (!initialConnectionCompleted) {
|
||||
bannerEl.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === 'connected' || state === 'updating') {
|
||||
bannerEl.hidden = true;
|
||||
stopConnectionCountdown();
|
||||
@@ -356,6 +367,21 @@ function refreshConnectionUi() {
|
||||
bannerEl.textContent = 'Проблема с соединением. Нажмите для повтора';
|
||||
}
|
||||
|
||||
function finishInitialConnectionSplash() {
|
||||
if (initialConnectionCompleted) return;
|
||||
initialConnectionCompleted = true;
|
||||
if (!initialSplashEl) {
|
||||
refreshConnectionUi();
|
||||
return;
|
||||
}
|
||||
|
||||
initialSplashEl.classList.add('is-leaving');
|
||||
const removeSplash = () => initialSplashEl.remove();
|
||||
initialSplashEl.addEventListener('transitionend', removeSplash, { once: true });
|
||||
window.setTimeout(removeSplash, 700);
|
||||
refreshConnectionUi();
|
||||
}
|
||||
|
||||
function startConnectionCountdown() {
|
||||
if (connectionStatusCountdownId) return;
|
||||
connectionStatusCountdownId = window.setInterval(() => {
|
||||
@@ -463,6 +489,9 @@ function setConnectionStatus(nextState, text = '') {
|
||||
if (!state) return;
|
||||
connectionState = state;
|
||||
connectionStatusText = String(text || '').trim();
|
||||
if (state === 'connected') {
|
||||
finishInitialConnectionSplash();
|
||||
}
|
||||
if (state === 'disconnected') {
|
||||
if (!connectionNextRetryAtMs || connectionNextRetryAtMs <= Date.now()) {
|
||||
connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,56 +1,7 @@
|
||||
import { authService } from '../state.js';
|
||||
import { getArweaveBalance, getArweaveWalletFromStoredClientKey } from '../services/arweave-wallet-service.js';
|
||||
import {
|
||||
buildArweaveDataUrl,
|
||||
getArweaveUploadPrice,
|
||||
prepareAvatarImageFile,
|
||||
sha256HexFromArrayBuffer,
|
||||
uploadArweaveFile,
|
||||
validateArweaveTxId,
|
||||
validateSha256Hex,
|
||||
validateAvatarSourceFile,
|
||||
} from '../services/arweave-file-service.js';
|
||||
import { bytesToBase64 } from '../services/crypto-utils.js';
|
||||
import { openArweaveAttachmentManager } from './arweave-attachment-manager.js';
|
||||
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
|
||||
|
||||
const DEFAULT_FREE_AVATAR_MAX_BYTES = 128 * 1024;
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes || 0);
|
||||
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatAr(ar) {
|
||||
const n = Number(ar);
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
return n.toLocaleString('ru-RU', { maximumFractionDigits: 6 });
|
||||
}
|
||||
|
||||
function clearJwk(ctx) {
|
||||
if (!ctx?.jwk || typeof ctx.jwk !== 'object') return;
|
||||
Object.keys(ctx.jwk).forEach((key) => {
|
||||
ctx.jwk[key] = '';
|
||||
});
|
||||
ctx.jwk = null;
|
||||
}
|
||||
|
||||
function setNodeText(node, text) {
|
||||
if (node) node.textContent = String(text || '');
|
||||
}
|
||||
|
||||
export function openAvatarWizard({
|
||||
export async function openAvatarWizard({
|
||||
login,
|
||||
storagePwd,
|
||||
gateway,
|
||||
@@ -58,568 +9,37 @@ export function openAvatarWizard({
|
||||
onAvatarSaved,
|
||||
onStatus,
|
||||
} = {}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return Promise.resolve(false);
|
||||
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanStoragePwd = String(storagePwd || '').trim();
|
||||
const cleanGateway = String(gateway || '').trim();
|
||||
if (!cleanLogin || !cleanStoragePwd) {
|
||||
return Promise.reject(new Error('Нет активной сессии.'));
|
||||
throw new Error('Нет активной сессии.');
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
let lastPreviewUrl = '';
|
||||
let walletCtx = null;
|
||||
let balanceInfo = null;
|
||||
let optimized = null;
|
||||
let priceInfo = null;
|
||||
let uploadedTxId = '';
|
||||
let uploadedSha256Hex = '';
|
||||
let uploadedInfoText = '';
|
||||
let freeQuotaInfo = null;
|
||||
|
||||
function revokePreviewUrl() {
|
||||
if (!lastPreviewUrl) return;
|
||||
URL.revokeObjectURL(lastPreviewUrl);
|
||||
lastPreviewUrl = '';
|
||||
}
|
||||
|
||||
function close(result = false, resolve) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
revokePreviewUrl();
|
||||
clearJwk(walletCtx);
|
||||
walletCtx = null;
|
||||
root.innerHTML = '';
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
async function ensurePreviewImage(url, imageEl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = imageEl;
|
||||
img.onload = () => resolve(true);
|
||||
img.onerror = () => reject(new Error('Не удалось загрузить изображение по этому Transaction ID'));
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const showStepChoice = () => {
|
||||
if (closed) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-avatar-wizard-modal="true">
|
||||
<div class="modal-card stack avatar-wizard-card">
|
||||
<h3 class="modal-title">Сменить аватар</h3>
|
||||
<p class="meta-muted">Вы можете использовать уже загруженный файл Arweave, загрузить новый файл со своего кошелька или попробовать временную бесплатную загрузку через сервер.</p>
|
||||
<div class="avatar-wizard-choice-grid">
|
||||
<button class="primary-btn" type="button" data-action="use-existing">Использовать существующий файл в Arweave</button>
|
||||
<button class="primary-btn" type="button" data-action="upload-new">Загрузить новый файл в Arweave</button>
|
||||
<button class="primary-btn" type="button" data-action="upload-free">Залить аватар бесплатно</button>
|
||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
|
||||
modal?.addEventListener('click', (event) => {
|
||||
if (event.target === modal) close(false, resolve);
|
||||
});
|
||||
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
|
||||
root.querySelector('[data-action="use-existing"]')?.addEventListener('click', showStepExistingInput);
|
||||
root.querySelector('[data-action="upload-new"]')?.addEventListener('click', () => { void showStepUpload(); });
|
||||
root.querySelector('[data-action="upload-free"]')?.addEventListener('click', () => { void showStepFreeUpload(); });
|
||||
};
|
||||
|
||||
const showStepExistingInput = () => {
|
||||
if (closed) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-avatar-wizard-modal="true">
|
||||
<div class="modal-card stack avatar-wizard-card">
|
||||
<h3 class="modal-title">Использовать существующий файл в Arweave</h3>
|
||||
<label class="meta-muted" for="avatar-existing-txid">Transaction ID Arweave</label>
|
||||
<input class="input" id="avatar-existing-txid" type="text" maxlength="64" placeholder="Введите Transaction ID" />
|
||||
<p class="avatar-wizard-error" data-error="true"></p>
|
||||
<div class="avatar-wizard-actions">
|
||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||||
<button class="primary-btn" type="button" data-action="next">Далее</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
|
||||
const inputEl = root.querySelector('#avatar-existing-txid');
|
||||
const errorEl = root.querySelector('[data-error="true"]');
|
||||
modal?.addEventListener('click', (event) => {
|
||||
if (event.target === modal) close(false, resolve);
|
||||
});
|
||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
|
||||
root.querySelector('[data-action="next"]')?.addEventListener('click', () => {
|
||||
const txId = String(inputEl?.value || '').trim();
|
||||
if (!validateArweaveTxId(txId)) {
|
||||
setNodeText(errorEl, 'Некорректный Transaction ID Arweave.');
|
||||
return;
|
||||
}
|
||||
setNodeText(errorEl, '');
|
||||
void showStepExistingPreview(txId);
|
||||
});
|
||||
window.setTimeout(() => inputEl?.focus(), 0);
|
||||
};
|
||||
|
||||
const showStepExistingPreview = async (txId) => {
|
||||
if (closed) return;
|
||||
const previewUrl = buildArweaveDataUrl({ gateway: cleanGateway, txId });
|
||||
let existingSha256Hex = '';
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-avatar-wizard-modal="true">
|
||||
<div class="modal-card stack avatar-wizard-card">
|
||||
<h3 class="modal-title">Предпросмотр аватара</h3>
|
||||
<div class="avatar-preview-circle avatar-wizard-preview">
|
||||
<img alt="Предпросмотр аватара" data-preview-image="true" />
|
||||
</div>
|
||||
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
|
||||
<p class="avatar-wizard-error" data-error="true"></p>
|
||||
<div class="avatar-wizard-actions">
|
||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||||
<button class="primary-btn" type="button" data-action="save">Сохранить аватар</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
|
||||
const imageEl = root.querySelector('[data-preview-image="true"]');
|
||||
const errorEl = root.querySelector('[data-error="true"]');
|
||||
const saveBtn = root.querySelector('[data-action="save"]');
|
||||
modal?.addEventListener('click', (event) => {
|
||||
if (event.target === modal) close(false, resolve);
|
||||
});
|
||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepExistingInput);
|
||||
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
await ensurePreviewImage(previewUrl, imageEl);
|
||||
const response = await fetch(previewUrl, { method: 'GET', cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('BAD_AVATAR_FETCH');
|
||||
existingSha256Hex = await sha256HexFromArrayBuffer(await response.arrayBuffer());
|
||||
if (!validateSha256Hex(existingSha256Hex)) throw new Error('BAD_AVATAR_HASH');
|
||||
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = false;
|
||||
} catch (error) {
|
||||
setNodeText(errorEl, 'Не удалось проверить файл по этому Transaction ID.');
|
||||
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
|
||||
}
|
||||
|
||||
saveBtn?.addEventListener('click', async () => {
|
||||
try {
|
||||
await saveProfileAvatarArweave(cleanLogin, txId, existingSha256Hex);
|
||||
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
|
||||
close(true, resolve);
|
||||
} catch {
|
||||
setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const showStepZeroBalance = () => {
|
||||
if (closed) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-avatar-wizard-modal="true">
|
||||
<div class="modal-card stack avatar-wizard-card">
|
||||
<h3 class="modal-title">Недостаточно средств</h3>
|
||||
<p class="meta-muted">У вас нулевой баланс Arweave. Для загрузки аватара нужен AR.</p>
|
||||
<div class="avatar-wizard-actions">
|
||||
<button class="primary-btn" type="button" data-action="to-wallet">Перейти в кошелёк</button>
|
||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.querySelector('[data-action="to-wallet"]')?.addEventListener('click', () => {
|
||||
close(false, resolve);
|
||||
if (typeof navigate === 'function') navigate('wallet-view');
|
||||
});
|
||||
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
|
||||
};
|
||||
|
||||
const showStepUploadForm = () => {
|
||||
if (closed) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-avatar-wizard-modal="true">
|
||||
<div class="modal-card stack avatar-wizard-card">
|
||||
<h3 class="modal-title">Загрузить новый файл в Arweave</h3>
|
||||
<p class="meta-muted">Баланс: ${formatAr(balanceInfo?.ar)} AR</p>
|
||||
<label class="meta-muted" for="avatar-file-input">Выберите изображение</label>
|
||||
<input class="input" id="avatar-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
|
||||
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Максимальный исходный файл — 10 MB. Перед загрузкой изображение будет уменьшено для аватарки.</p>
|
||||
<div class="avatar-preview-circle avatar-wizard-preview" hidden data-preview-wrap="true">
|
||||
<img alt="Предпросмотр аватара" data-preview-image="true" />
|
||||
</div>
|
||||
<div class="avatar-wizard-meta" data-meta="true"></div>
|
||||
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
|
||||
<p class="avatar-wizard-error" data-error="true"></p>
|
||||
<div class="avatar-wizard-actions">
|
||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||||
<button class="primary-btn" type="button" data-action="upload" disabled>Загрузить в Arweave</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
|
||||
const fileInput = root.querySelector('#avatar-file-input');
|
||||
const errorEl = root.querySelector('[data-error="true"]');
|
||||
const metaEl = root.querySelector('[data-meta="true"]');
|
||||
const previewWrap = root.querySelector('[data-preview-wrap="true"]');
|
||||
const previewImage = root.querySelector('[data-preview-image="true"]');
|
||||
const uploadBtn = root.querySelector('[data-action="upload"]');
|
||||
|
||||
let selectedFile = null;
|
||||
optimized = null;
|
||||
priceInfo = null;
|
||||
uploadedSha256Hex = '';
|
||||
|
||||
modal?.addEventListener('click', (event) => {
|
||||
if (event.target === modal) close(false, resolve);
|
||||
});
|
||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
|
||||
|
||||
fileInput?.addEventListener('change', async () => {
|
||||
setNodeText(errorEl, '');
|
||||
setNodeText(metaEl, '');
|
||||
uploadBtn.disabled = true;
|
||||
revokePreviewUrl();
|
||||
|
||||
selectedFile = fileInput.files?.[0] || null;
|
||||
if (!selectedFile) {
|
||||
setNodeText(errorEl, 'Выберите файл изображения.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
validateAvatarSourceFile(selectedFile);
|
||||
optimized = await prepareAvatarImageFile(selectedFile);
|
||||
priceInfo = await getArweaveUploadPrice({
|
||||
gateway: cleanGateway,
|
||||
byteLength: optimized.file.size,
|
||||
});
|
||||
|
||||
lastPreviewUrl = URL.createObjectURL(optimized.file);
|
||||
previewImage.src = lastPreviewUrl;
|
||||
previewWrap.hidden = false;
|
||||
|
||||
const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston);
|
||||
metaEl.innerHTML = `
|
||||
<div>Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}</div>
|
||||
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
|
||||
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
|
||||
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
|
||||
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
|
||||
<div>Примерная цена загрузки: ${escapeHtml(formatAr(priceInfo.ar))} AR</div>
|
||||
`;
|
||||
|
||||
if (!hasFunds) {
|
||||
setNodeText(errorEl, 'Недостаточно средств для загрузки.');
|
||||
uploadBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
uploadBtn.disabled = false;
|
||||
} catch (error) {
|
||||
const message = String(error?.message || '');
|
||||
if (message === 'Выберите файл изображения.' || message === 'Поддерживаются только JPEG, PNG или WebP.' || message === 'Файл слишком большой. Максимум 10 MB.') {
|
||||
setNodeText(errorEl, message);
|
||||
} else if (message.includes('цену загрузки')) {
|
||||
setNodeText(errorEl, 'Не удалось получить баланс Arweave.');
|
||||
} else {
|
||||
setNodeText(errorEl, 'Не удалось подготовить изображение.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
uploadBtn?.addEventListener('click', async () => {
|
||||
setNodeText(errorEl, '');
|
||||
if (!optimized?.file) {
|
||||
setNodeText(errorEl, 'Выберите файл изображения.');
|
||||
return;
|
||||
}
|
||||
if (!priceInfo || BigInt(balanceInfo.winston) < BigInt(priceInfo.winston)) {
|
||||
setNodeText(errorEl, 'Недостаточно средств для загрузки.');
|
||||
return;
|
||||
}
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
try {
|
||||
uploadedInfoText = '';
|
||||
const uploaded = await uploadArweaveFile({
|
||||
gateway: cleanGateway,
|
||||
jwk: walletCtx?.jwk,
|
||||
file: optimized.file,
|
||||
tags: [
|
||||
{ name: 'SHiNE-Profile-Login', value: cleanLogin },
|
||||
],
|
||||
});
|
||||
uploadedTxId = String(uploaded.id || '').trim();
|
||||
uploadedSha256Hex = String(optimized?.sha256Hex || '').trim().toLowerCase();
|
||||
if (!uploadedTxId) {
|
||||
throw new Error('Пустой Transaction ID');
|
||||
}
|
||||
if (!validateSha256Hex(uploadedSha256Hex)) {
|
||||
throw new Error('Некорректный SHA256');
|
||||
}
|
||||
showStepUploaded();
|
||||
} catch {
|
||||
setNodeText(errorEl, 'Не удалось загрузить файл в Arweave.');
|
||||
uploadBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const showStepFreeLimitExhausted = () => {
|
||||
if (closed) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-avatar-wizard-modal="true">
|
||||
<div class="modal-card stack avatar-wizard-card">
|
||||
<h3 class="modal-title">Лимит исчерпан</h3>
|
||||
<p class="meta-muted">Вы исчерпали бесплатный лимит аватарок.</p>
|
||||
<div class="avatar-wizard-actions">
|
||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||||
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
|
||||
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
|
||||
};
|
||||
|
||||
const showStepFreeUploadForm = () => {
|
||||
if (closed) return;
|
||||
const remaining = Number(freeQuotaInfo?.remainingCount || 0);
|
||||
const limit = Number(freeQuotaInfo?.limit || 3);
|
||||
const maxBytes = Number(freeQuotaInfo?.maxBytes || DEFAULT_FREE_AVATAR_MAX_BYTES);
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-avatar-wizard-modal="true">
|
||||
<div class="modal-card stack avatar-wizard-card">
|
||||
<h3 class="modal-title">Залить аватар бесплатно</h3>
|
||||
<p class="meta-muted">Осталось бесплатных загрузок: ${remaining} из ${limit}.</p>
|
||||
<label class="meta-muted" for="avatar-free-file-input">Выберите изображение</label>
|
||||
<input class="input" id="avatar-free-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
|
||||
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Перед отправкой изображение уменьшается для аватарки. Итоговый файл должен быть не больше ${formatBytes(maxBytes)}.</p>
|
||||
<div class="avatar-preview-circle avatar-wizard-preview" hidden data-preview-wrap="true">
|
||||
<img alt="Предпросмотр аватара" data-preview-image="true" />
|
||||
</div>
|
||||
<div class="avatar-wizard-meta" data-meta="true"></div>
|
||||
<p class="meta-muted">Это временная тестовая бесплатная загрузка через серверный кошелёк Arweave.</p>
|
||||
<p class="avatar-wizard-error" data-error="true"></p>
|
||||
<div class="avatar-wizard-actions">
|
||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||||
<button class="primary-btn" type="button" data-action="upload" disabled>Залить бесплатно</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
|
||||
const fileInput = root.querySelector('#avatar-free-file-input');
|
||||
const errorEl = root.querySelector('[data-error="true"]');
|
||||
const metaEl = root.querySelector('[data-meta="true"]');
|
||||
const previewWrap = root.querySelector('[data-preview-wrap="true"]');
|
||||
const previewImage = root.querySelector('[data-preview-image="true"]');
|
||||
const uploadBtn = root.querySelector('[data-action="upload"]');
|
||||
|
||||
optimized = null;
|
||||
uploadedSha256Hex = '';
|
||||
|
||||
modal?.addEventListener('click', (event) => {
|
||||
if (event.target === modal) close(false, resolve);
|
||||
});
|
||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
|
||||
|
||||
fileInput?.addEventListener('change', async () => {
|
||||
setNodeText(errorEl, '');
|
||||
setNodeText(metaEl, '');
|
||||
uploadBtn.disabled = true;
|
||||
revokePreviewUrl();
|
||||
|
||||
const selectedFile = fileInput.files?.[0] || null;
|
||||
if (!selectedFile) {
|
||||
setNodeText(errorEl, 'Выберите файл изображения.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
validateAvatarSourceFile(selectedFile);
|
||||
optimized = await prepareAvatarImageFile(selectedFile);
|
||||
if (Number(optimized?.file?.size || 0) > maxBytes) {
|
||||
throw new Error(`После уменьшения файл всё ещё больше ${formatBytes(maxBytes)}. Возьмите более простое изображение.`);
|
||||
}
|
||||
|
||||
lastPreviewUrl = URL.createObjectURL(optimized.file);
|
||||
previewImage.src = lastPreviewUrl;
|
||||
previewWrap.hidden = false;
|
||||
metaEl.innerHTML = `
|
||||
<div>Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}</div>
|
||||
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
|
||||
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
|
||||
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
|
||||
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
|
||||
`;
|
||||
uploadBtn.disabled = false;
|
||||
} catch (error) {
|
||||
setNodeText(errorEl, String(error?.message || 'Не удалось подготовить изображение.'));
|
||||
}
|
||||
});
|
||||
|
||||
uploadBtn?.addEventListener('click', async () => {
|
||||
setNodeText(errorEl, '');
|
||||
if (!optimized?.file) {
|
||||
setNodeText(errorEl, 'Выберите файл изображения.');
|
||||
return;
|
||||
}
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
try {
|
||||
const fileBytes = new Uint8Array(await optimized.file.arrayBuffer());
|
||||
const uploaded = await authService.uploadTestFreeAvatar({
|
||||
contentType: optimized.contentType,
|
||||
fileBytesBase64: bytesToBase64(fileBytes),
|
||||
sha256Hex: String(optimized.sha256Hex || '').trim().toLowerCase(),
|
||||
});
|
||||
uploadedTxId = String(uploaded.txId || '').trim();
|
||||
uploadedSha256Hex = String(uploaded.sha256Hex || optimized.sha256Hex || '').trim().toLowerCase();
|
||||
uploadedInfoText = `Осталось бесплатных загрузок: ${Number(uploaded.remainingCount || 0)} из ${Number(uploaded.limit || limit)}.`;
|
||||
if (!uploadedTxId) {
|
||||
throw new Error('Сервер не вернул Transaction ID.');
|
||||
}
|
||||
showStepUploaded();
|
||||
} catch (error) {
|
||||
setNodeText(errorEl, String(error?.message || 'Не удалось бесплатно загрузить аватар.'));
|
||||
uploadBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const showStepFreeUpload = async () => {
|
||||
if (closed) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-avatar-wizard-modal="true">
|
||||
<div class="modal-card stack avatar-wizard-card">
|
||||
<h3 class="modal-title">Подготовка бесплатной загрузки</h3>
|
||||
<p class="meta-muted" data-loading="true">Проверяем остаток бесплатных загрузок...</p>
|
||||
<p class="avatar-wizard-error" data-error="true"></p>
|
||||
<div class="avatar-wizard-actions">
|
||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const loadingEl = root.querySelector('[data-loading="true"]');
|
||||
const errorEl = root.querySelector('[data-error="true"]');
|
||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
|
||||
|
||||
try {
|
||||
freeQuotaInfo = await authService.getTestFreeAvatarQuota();
|
||||
} catch (error) {
|
||||
setNodeText(errorEl, error?.message || 'Не удалось получить остаток бесплатных загрузок.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!freeQuotaInfo?.enabled) {
|
||||
setNodeText(loadingEl, '');
|
||||
setNodeText(errorEl, 'Временная бесплатная загрузка аватаров сейчас отключена на сервере.');
|
||||
return;
|
||||
}
|
||||
if (Number(freeQuotaInfo?.remainingCount || 0) <= 0) {
|
||||
showStepFreeLimitExhausted();
|
||||
return;
|
||||
}
|
||||
showStepFreeUploadForm();
|
||||
};
|
||||
|
||||
const showStepUploaded = () => {
|
||||
if (closed) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-avatar-wizard-modal="true">
|
||||
<div class="modal-card stack avatar-wizard-card">
|
||||
<h3 class="modal-title">Файл загружен в Arweave</h3>
|
||||
<p class="meta-muted">Transaction ID:</p>
|
||||
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
|
||||
<p class="meta-muted">SHA-256:</p>
|
||||
<p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p>
|
||||
${uploadedInfoText ? `<p class="meta-muted">${escapeHtml(uploadedInfoText)}</p>` : ''}
|
||||
<p class="avatar-wizard-error" data-error="true"></p>
|
||||
<div class="avatar-wizard-actions">
|
||||
<button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button>
|
||||
<button class="primary-btn" type="button" data-action="set-avatar">Сделать аватаром</button>
|
||||
</div>
|
||||
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const errorEl = root.querySelector('[data-error="true"]');
|
||||
root.querySelector('[data-action="copy-id"]')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(uploadedTxId);
|
||||
setNodeText(errorEl, '');
|
||||
} catch {
|
||||
setNodeText(errorEl, 'Не удалось скопировать Transaction ID.');
|
||||
}
|
||||
});
|
||||
root.querySelector('[data-action="set-avatar"]')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await saveProfileAvatarArweave(cleanLogin, uploadedTxId, uploadedSha256Hex);
|
||||
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
|
||||
close(true, resolve);
|
||||
} catch {
|
||||
setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.');
|
||||
}
|
||||
});
|
||||
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
|
||||
};
|
||||
|
||||
const showStepUpload = async () => {
|
||||
if (closed) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-avatar-wizard-modal="true">
|
||||
<div class="modal-card stack avatar-wizard-card">
|
||||
<h3 class="modal-title">Подготовка загрузки</h3>
|
||||
<p class="meta-muted" data-loading="true">Загружаем Arweave-кошелёк...</p>
|
||||
<p class="avatar-wizard-error" data-error="true"></p>
|
||||
<div class="avatar-wizard-actions">
|
||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const loadingEl = root.querySelector('[data-loading="true"]');
|
||||
const errorEl = root.querySelector('[data-error="true"]');
|
||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
|
||||
try {
|
||||
walletCtx = await getArweaveWalletFromStoredClientKey({
|
||||
login: cleanLogin,
|
||||
storagePwd: cleanStoragePwd,
|
||||
onStatus: (message) => {
|
||||
setNodeText(loadingEl, message);
|
||||
if (typeof onStatus === 'function') onStatus(message);
|
||||
},
|
||||
});
|
||||
balanceInfo = await getArweaveBalance({
|
||||
gateway: cleanGateway,
|
||||
address: walletCtx.address,
|
||||
});
|
||||
} catch (error) {
|
||||
setNodeText(errorEl, 'Не удалось получить баланс Arweave.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (BigInt(balanceInfo.winston) <= 0n) {
|
||||
showStepZeroBalance();
|
||||
return;
|
||||
}
|
||||
showStepUploadForm();
|
||||
};
|
||||
|
||||
showStepChoice();
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: cleanLogin,
|
||||
storagePwd: cleanStoragePwd,
|
||||
gateway: cleanGateway,
|
||||
navigate,
|
||||
mode: 'avatar',
|
||||
historyPurpose: 'avatar',
|
||||
});
|
||||
|
||||
if (!item?.ar || !item?.sha256) return false;
|
||||
|
||||
if (typeof onStatus === 'function') {
|
||||
onStatus('Сохраняем аватар в профиль...');
|
||||
}
|
||||
|
||||
await saveProfileAvatarArweave(cleanLogin, item.ar, item.sha256);
|
||||
|
||||
if (typeof onAvatarSaved === 'function') {
|
||||
await onAvatarSaved(item);
|
||||
}
|
||||
|
||||
if (typeof onStatus === 'function') {
|
||||
onStatus('');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { base64ToBytes, bytesToBase58, publicKeyB64FromPkcs8Ed25519 } from '../services/crypto-utils.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
import { resolveShineServerByServerLogin } from '../services/shine-server-resolver.js';
|
||||
import { readShineUserPda, updateShineUserPdaOnSolana } from '../services/shine-user-pda-service.js';
|
||||
import { getTopupSiteUrl } from '../services/solana-wallet-service.js';
|
||||
|
||||
export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' };
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function normalizeLogin(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function equalBytes(a, b) {
|
||||
const left = a instanceof Uint8Array ? a : new Uint8Array(a || []);
|
||||
const right = b instanceof Uint8Array ? b : new Uint8Array(b || []);
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
if (left[i] !== right[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function uniqueLogins(values) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
(Array.isArray(values) ? values : []).forEach((value) => {
|
||||
const login = normalizeLogin(value);
|
||||
if (!login || seen.has(login)) return;
|
||||
seen.add(login);
|
||||
result.push(login);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function shortenSignature(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (text.length <= 16) return text;
|
||||
return `${text.slice(0, 8)}...${text.slice(-8)}`;
|
||||
}
|
||||
|
||||
function isInsufficientFundsForRentError(error) {
|
||||
const text = [
|
||||
error?.message,
|
||||
error?.transactionMessage,
|
||||
Array.isArray(error?.logs) ? error.logs.join('\n') : '',
|
||||
Array.isArray(error?.transactionLogs) ? error.transactionLogs.join('\n') : '',
|
||||
Array.isArray(error?.simulationLogs) ? error.simulationLogs.join('\n') : '',
|
||||
].filter(Boolean).join('\n').toLowerCase();
|
||||
return text.includes('insufficient funds') && text.includes('rent');
|
||||
}
|
||||
|
||||
function clientAddressFromPublicB64(publicKeyB64) {
|
||||
const bytes = base64ToBytes(publicKeyB64);
|
||||
if (bytes.length !== 32) throw new Error('client public key должен быть 32 байта');
|
||||
return bytesToBase58(bytes);
|
||||
}
|
||||
|
||||
async function clientAddressFromPrivatePkcs8(privatePkcs8B64) {
|
||||
return clientAddressFromPublicB64(await publicKeyB64FromPkcs8Ed25519(privatePkcs8B64));
|
||||
}
|
||||
|
||||
function createConfirmModal() {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!(root instanceof HTMLElement)) return null;
|
||||
|
||||
let onConfirm = null;
|
||||
let onCancel = null;
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
onConfirm = null;
|
||||
onCancel = null;
|
||||
};
|
||||
|
||||
return {
|
||||
open({
|
||||
title,
|
||||
text,
|
||||
note = '',
|
||||
confirmLabel = 'Да',
|
||||
cancelLabel = 'Нет',
|
||||
onConfirm: confirmHandler,
|
||||
onCancel: cancelHandler,
|
||||
}) {
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="access-servers-confirm-modal">
|
||||
<div class="modal-card stack" style="max-width:min(94vw,34rem);">
|
||||
<h3 class="modal-title" id="access-servers-dialog-title"></h3>
|
||||
<p class="meta-muted" id="access-servers-dialog-text" style="white-space:pre-wrap; line-height:1.45;"></p>
|
||||
<p class="meta-muted" id="access-servers-dialog-note"${note ? '' : ' hidden'} style="white-space:pre-wrap; line-height:1.45;"></p>
|
||||
<div class="auth-footer-actions">
|
||||
<button class="ghost-btn" type="button" id="access-servers-dialog-cancel"></button>
|
||||
<button class="primary-btn" type="button" id="access-servers-dialog-confirm"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const modal = root.querySelector('#access-servers-confirm-modal');
|
||||
const titleEl = root.querySelector('#access-servers-dialog-title');
|
||||
const textEl = root.querySelector('#access-servers-dialog-text');
|
||||
const noteEl = root.querySelector('#access-servers-dialog-note');
|
||||
const cancelBtn = root.querySelector('#access-servers-dialog-cancel');
|
||||
const confirmBtn = root.querySelector('#access-servers-dialog-confirm');
|
||||
if (!(modal instanceof HTMLElement)
|
||||
|| !(titleEl instanceof HTMLElement)
|
||||
|| !(textEl instanceof HTMLElement)
|
||||
|| !(noteEl instanceof HTMLElement)
|
||||
|| !(cancelBtn instanceof HTMLButtonElement)
|
||||
|| !(confirmBtn instanceof HTMLButtonElement)) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
titleEl.textContent = String(title || 'Подтверждение');
|
||||
textEl.textContent = String(text || '');
|
||||
if (note) {
|
||||
noteEl.hidden = false;
|
||||
noteEl.textContent = String(note);
|
||||
} else {
|
||||
noteEl.hidden = true;
|
||||
noteEl.textContent = '';
|
||||
}
|
||||
cancelBtn.textContent = String(cancelLabel || 'Нет');
|
||||
confirmBtn.textContent = String(confirmLabel || 'Да');
|
||||
onConfirm = confirmHandler || null;
|
||||
onCancel = cancelHandler || null;
|
||||
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) close();
|
||||
});
|
||||
cancelBtn.addEventListener('click', async () => {
|
||||
const handler = onCancel;
|
||||
close();
|
||||
if (typeof handler === 'function') await handler();
|
||||
});
|
||||
confirmBtn.addEventListener('click', async () => {
|
||||
const handler = onConfirm;
|
||||
close();
|
||||
if (typeof handler === 'function') await handler();
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPasswordModal() {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!(root instanceof HTMLElement)) return null;
|
||||
|
||||
return {
|
||||
open({ title, text, note = '' }) {
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="access-servers-password-modal">
|
||||
<div class="modal-card stack" style="max-width:min(94vw,36rem);">
|
||||
<h3 class="modal-title" id="access-servers-password-title"></h3>
|
||||
<p class="meta-muted" id="access-servers-password-text" style="white-space:pre-wrap; line-height:1.45;"></p>
|
||||
<p class="meta-muted" id="access-servers-password-note"${note ? '' : ' hidden'} style="white-space:pre-wrap; line-height:1.45;"></p>
|
||||
<label class="stack" style="gap:0.35rem;">
|
||||
<span class="field-label">Пароль аккаунта</span>
|
||||
<input class="input" id="access-servers-password-input" type="password" autocomplete="current-password" placeholder="Введите пароль" />
|
||||
</label>
|
||||
<div class="stack" style="gap:0.45rem;">
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="once" checked />
|
||||
<span>Использовать root key только сейчас</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="save" />
|
||||
<span>Сохранить root key на этом устройстве</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button>
|
||||
<button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const modal = root.querySelector('#access-servers-password-modal');
|
||||
const titleEl = root.querySelector('#access-servers-password-title');
|
||||
const textEl = root.querySelector('#access-servers-password-text');
|
||||
const noteEl = root.querySelector('#access-servers-password-note');
|
||||
const inputEl = root.querySelector('#access-servers-password-input');
|
||||
const cancelBtn = root.querySelector('#access-servers-password-cancel');
|
||||
const confirmBtn = root.querySelector('#access-servers-password-confirm');
|
||||
if (!(modal instanceof HTMLElement)
|
||||
|| !(titleEl instanceof HTMLElement)
|
||||
|| !(textEl instanceof HTMLElement)
|
||||
|| !(noteEl instanceof HTMLElement)
|
||||
|| !(inputEl instanceof HTMLInputElement)
|
||||
|| !(cancelBtn instanceof HTMLButtonElement)
|
||||
|| !(confirmBtn instanceof HTMLButtonElement)) {
|
||||
root.innerHTML = '';
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
titleEl.textContent = String(title || 'Введите пароль');
|
||||
textEl.textContent = String(text || '');
|
||||
if (note) {
|
||||
noteEl.hidden = false;
|
||||
noteEl.textContent = String(note);
|
||||
} else {
|
||||
noteEl.hidden = true;
|
||||
noteEl.textContent = '';
|
||||
}
|
||||
|
||||
const close = (result = null) => {
|
||||
root.innerHTML = '';
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) close(null);
|
||||
});
|
||||
cancelBtn.addEventListener('click', () => close(null));
|
||||
inputEl.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
confirmBtn.click();
|
||||
}
|
||||
});
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
const password = String(inputEl.value || '');
|
||||
if (!password.trim()) {
|
||||
inputEl.focus();
|
||||
return;
|
||||
}
|
||||
const mode = root.querySelector('input[name="access-servers-key-mode"]:checked');
|
||||
close({
|
||||
password,
|
||||
saveRoot: mode instanceof HTMLInputElement && mode.value === 'save',
|
||||
});
|
||||
});
|
||||
window.setTimeout(() => inputEl.focus(), 0);
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const sessionLogin = normalizeLogin(state.session.login);
|
||||
const solanaEndpoint = String(state.entrySettings.solanaServer || '').trim();
|
||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||
|
||||
let disposed = false;
|
||||
let operationBusy = false;
|
||||
let currentAccessServers = [];
|
||||
let selectedCandidate = null;
|
||||
let suggestionsLoading = false;
|
||||
|
||||
const confirmModal = createConfirmModal();
|
||||
const passwordModal = createPasswordModal();
|
||||
|
||||
const introCard = document.createElement('div');
|
||||
introCard.className = 'card stack';
|
||||
introCard.innerHTML = `
|
||||
<p class="field-label">Где хранятся личные данные</p>
|
||||
<p class="meta-muted">
|
||||
Серверы доступа хранят зашифрованную личную переписку пользователя и участвуют в звонках.
|
||||
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
|
||||
а доступ пользователя и приватная переписка хранятся только на этих серверах.
|
||||
</p>
|
||||
`;
|
||||
|
||||
const listCard = document.createElement('div');
|
||||
listCard.className = 'card stack';
|
||||
const listTitle = document.createElement('p');
|
||||
listTitle.className = 'field-label';
|
||||
listTitle.textContent = 'Текущий список серверов доступа';
|
||||
const listHint = document.createElement('p');
|
||||
listHint.className = 'meta-muted';
|
||||
listHint.textContent = sessionLogin
|
||||
? `Пользователь: @${sessionLogin}`
|
||||
: 'В текущей сессии не найден логин пользователя.';
|
||||
const listBody = document.createElement('div');
|
||||
listBody.className = 'stack';
|
||||
const listStatus = document.createElement('p');
|
||||
listStatus.className = 'meta-muted';
|
||||
listStatus.textContent = 'Загрузка данных из PDA...';
|
||||
listCard.append(listTitle, listHint, listBody, listStatus);
|
||||
|
||||
const addCard = document.createElement('div');
|
||||
addCard.className = 'card stack';
|
||||
const addTitle = document.createElement('p');
|
||||
addTitle.className = 'field-label';
|
||||
addTitle.textContent = 'Добавить сервер доступа';
|
||||
const addHint = document.createElement('p');
|
||||
addHint.className = 'meta-muted';
|
||||
addHint.textContent = 'Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.';
|
||||
const addInput = document.createElement('input');
|
||||
addInput.className = 'input';
|
||||
addInput.type = 'text';
|
||||
addInput.autocomplete = 'off';
|
||||
addInput.placeholder = 'Например: shineup';
|
||||
const suggestEl = document.createElement('div');
|
||||
suggestEl.className = 'profile-relative-search-suggest';
|
||||
suggestEl.hidden = true;
|
||||
const addStatus = document.createElement('p');
|
||||
addStatus.className = 'meta-muted';
|
||||
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
|
||||
const addButton = document.createElement('button');
|
||||
addButton.className = 'primary-btn';
|
||||
addButton.type = 'button';
|
||||
addButton.textContent = 'Добавить сервер';
|
||||
addButton.disabled = true;
|
||||
addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton);
|
||||
|
||||
const refreshAddButton = () => {
|
||||
addButton.disabled = operationBusy || (!selectedCandidate && !normalizeLogin(addInput.value));
|
||||
};
|
||||
|
||||
const setOperationBusy = (busy) => {
|
||||
operationBusy = busy;
|
||||
addInput.disabled = busy;
|
||||
refreshAddButton();
|
||||
listBody.querySelectorAll('button').forEach((button) => {
|
||||
button.disabled = busy;
|
||||
});
|
||||
};
|
||||
|
||||
const setSelectedCandidate = (candidate) => {
|
||||
selectedCandidate = candidate;
|
||||
refreshAddButton();
|
||||
if (candidate) {
|
||||
addInput.value = candidate.login;
|
||||
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
|
||||
} else {
|
||||
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
|
||||
}
|
||||
};
|
||||
|
||||
const renderServerList = () => {
|
||||
listBody.innerHTML = '';
|
||||
if (!currentAccessServers.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'meta-muted';
|
||||
empty.textContent = 'Список серверов доступа пока пуст.';
|
||||
listBody.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
currentAccessServers.forEach((server, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'text-btn';
|
||||
button.type = 'button';
|
||||
button.disabled = operationBusy;
|
||||
button.innerHTML = `
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>@${escapeHtml(server.login)}</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(server.url || 'URL не указан')}</span>
|
||||
</span>
|
||||
`;
|
||||
button.addEventListener('click', () => {
|
||||
if (operationBusy) return;
|
||||
const isLast = currentAccessServers.length <= 1;
|
||||
const performDisable = async () => {
|
||||
const nextList = currentAccessServers
|
||||
.map((item) => item.login)
|
||||
.filter((login) => login !== server.login);
|
||||
try {
|
||||
await updateAccessServers(nextList, {
|
||||
statusTarget: listStatus,
|
||||
successText: `Сервер доступа @${server.login} отключён.`,
|
||||
inFlightText: `Обновляем PDA и отключаем сервер @${server.login}...`,
|
||||
});
|
||||
} catch {
|
||||
// Сообщение уже показано в статусе.
|
||||
}
|
||||
};
|
||||
|
||||
const askFinal = (noteText = '') => {
|
||||
confirmModal?.open({
|
||||
title: isLast ? 'Последний сервер доступа' : 'Отключить сервер доступа?',
|
||||
text: isLast
|
||||
? 'Это последний сервер доступа пользователя. Если его отключить, личная переписка пользователя на серверах доступа будет удалена.'
|
||||
: `Хотите изменить запись в блокчейне Solana и отключить сервер доступа @${server.login}?`,
|
||||
note: noteText,
|
||||
confirmLabel: isLast ? 'Понимаю' : 'Да',
|
||||
cancelLabel: isLast ? 'Отмена' : 'Нет',
|
||||
onConfirm: performDisable,
|
||||
});
|
||||
};
|
||||
|
||||
if (isLast) {
|
||||
confirmModal?.open({
|
||||
title: 'Отключить последний сервер?',
|
||||
text: `У пользователя остался только один сервер доступа: @${server.login}.`,
|
||||
note: 'После отключения последнего сервера доступа личная переписка пользователя на серверах доступа будет удалена.',
|
||||
confirmLabel: 'Продолжить',
|
||||
cancelLabel: 'Отмена',
|
||||
onConfirm: () => askFinal('Это повторное предупреждение перед записью нового списка серверов в Solana PDA.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
askFinal();
|
||||
});
|
||||
listBody.append(button);
|
||||
if (index < currentAccessServers.length - 1) {
|
||||
const divider = document.createElement('div');
|
||||
divider.style.height = '1px';
|
||||
divider.style.background = 'rgba(255,255,255,0.08)';
|
||||
listBody.append(divider);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadCurrentServers = async () => {
|
||||
if (!sessionLogin) {
|
||||
currentAccessServers = [];
|
||||
renderServerList();
|
||||
listStatus.textContent = 'Нет активной пользовательской сессии.';
|
||||
return;
|
||||
}
|
||||
if (!solanaEndpoint) {
|
||||
currentAccessServers = [];
|
||||
renderServerList();
|
||||
listStatus.textContent = 'Не задан Solana RPC endpoint.';
|
||||
return;
|
||||
}
|
||||
|
||||
listStatus.textContent = 'Читаем серверы доступа из Solana PDA...';
|
||||
try {
|
||||
const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
|
||||
const logins = uniqueLogins(parsed?.accessServers);
|
||||
const rows = [];
|
||||
for (const login of logins) {
|
||||
try {
|
||||
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
||||
rows.push({ login: resolved.serverLogin, url: resolved.httpBase });
|
||||
} catch {
|
||||
rows.push({ login, url: '' });
|
||||
}
|
||||
}
|
||||
currentAccessServers = rows;
|
||||
renderServerList();
|
||||
listStatus.textContent = rows.length
|
||||
? `Найдено серверов доступа: ${rows.length}`
|
||||
: 'В PDA пользователя пока нет серверов доступа.';
|
||||
} catch (error) {
|
||||
currentAccessServers = [];
|
||||
renderServerList();
|
||||
listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.';
|
||||
}
|
||||
};
|
||||
|
||||
const renderSuggestions = (items) => {
|
||||
suggestEl.innerHTML = '';
|
||||
if (!items.length) {
|
||||
suggestEl.hidden = true;
|
||||
return;
|
||||
}
|
||||
suggestEl.hidden = false;
|
||||
suggestEl.innerHTML = items.map((item) => (
|
||||
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
|
||||
@${escapeHtml(item.login)}
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(item.url || 'URL не указан')}</span>
|
||||
</button>`
|
||||
)).join('');
|
||||
};
|
||||
|
||||
const showTopupRequiredStatus = (target, clientAddress) => {
|
||||
const address = String(clientAddress || '').trim();
|
||||
const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup';
|
||||
target.innerHTML = `
|
||||
<span style="display:block; margin-bottom:0.55rem;">
|
||||
Не хватает SOL на client key для оплаты Solana rent/fee при обновлении user PDA.
|
||||
</span>
|
||||
${address ? `<span style="display:block; overflow-wrap:anywhere; margin-bottom:0.55rem;">Кошелёк: ${escapeHtml(address)}</span>` : ''}
|
||||
<a class="primary-btn" href="${escapeHtml(topupUrl)}" target="_blank" rel="noopener" style="display:inline-flex; text-decoration:none;">Пополнить DEVNET кошелёк</a>
|
||||
`;
|
||||
};
|
||||
|
||||
const loadSuggestions = async () => {
|
||||
const prefix = normalizeLogin(addInput.value);
|
||||
if (suggestionsLoading || prefix.length < 2 || operationBusy) {
|
||||
suggestEl.hidden = true;
|
||||
suggestEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
suggestionsLoading = true;
|
||||
try {
|
||||
const logins = await authService.searchUsers(prefix, { isServer: true });
|
||||
const items = [];
|
||||
for (const login of logins.slice(0, 6)) {
|
||||
try {
|
||||
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
||||
items.push({ login: resolved.serverLogin, url: resolved.httpBase });
|
||||
} catch {
|
||||
// Если server PDA повреждена, не показываем подсказку.
|
||||
}
|
||||
}
|
||||
if (!disposed) renderSuggestions(items);
|
||||
} catch (error) {
|
||||
if (!disposed) {
|
||||
suggestEl.hidden = true;
|
||||
suggestEl.innerHTML = '';
|
||||
addStatus.textContent = error?.message || 'Не удалось получить список серверов.';
|
||||
}
|
||||
} finally {
|
||||
suggestionsLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveAccessServerSigningMaterial = async (currentPda) => {
|
||||
if (!sessionLogin) throw new Error('Нет активной пользовательской сессии.');
|
||||
if (!storagePwd) throw new Error('В памяти сессии нет storagePwd. Выполните вход заново.');
|
||||
|
||||
let saved = null;
|
||||
try {
|
||||
saved = await loadEncryptedUserSecrets(sessionLogin, storagePwd);
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
|
||||
const savedRoot = String(saved?.rootKey || '').trim();
|
||||
const savedClient = String(saved?.clientKey || '').trim();
|
||||
if (savedRoot && savedClient) {
|
||||
return {
|
||||
rootPrivatePkcs8B64: savedRoot,
|
||||
clientPrivatePkcs8B64: savedClient,
|
||||
clientAddress: await clientAddressFromPrivatePkcs8(savedClient),
|
||||
};
|
||||
}
|
||||
|
||||
const passwordResult = await passwordModal?.open({
|
||||
title: 'Нужен пароль для обновления серверов доступа',
|
||||
text: 'Чтобы изменить список серверов доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
note: savedClient
|
||||
? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.'
|
||||
: 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.',
|
||||
});
|
||||
if (!passwordResult) {
|
||||
throw new Error('Операция отменена пользователем.');
|
||||
}
|
||||
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password);
|
||||
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64);
|
||||
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
|
||||
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
|
||||
throw new Error('Пароль не подходит: root key не совпал с user PDA.');
|
||||
}
|
||||
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
|
||||
throw new Error('Пароль не подходит: client key не совпал с user PDA.');
|
||||
}
|
||||
|
||||
if (passwordResult.saveRoot) {
|
||||
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
|
||||
saveRoot: true,
|
||||
saveBlockchain: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
|
||||
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
|
||||
clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64),
|
||||
};
|
||||
};
|
||||
|
||||
const updateAccessServers = async (nextLogins, {
|
||||
statusTarget,
|
||||
successText,
|
||||
inFlightText,
|
||||
} = {}) => {
|
||||
const target = statusTarget instanceof HTMLElement ? statusTarget : listStatus;
|
||||
if (!sessionLogin) throw new Error('Нет активной пользовательской сессии.');
|
||||
if (!solanaEndpoint) throw new Error('Не задан Solana RPC endpoint.');
|
||||
if (operationBusy) return;
|
||||
|
||||
const normalizedList = uniqueLogins(nextLogins);
|
||||
setOperationBusy(true);
|
||||
target.textContent = String(inFlightText || 'Обновляем список серверов доступа...');
|
||||
let signingMaterial = null;
|
||||
try {
|
||||
const currentPda = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
|
||||
signingMaterial = await resolveAccessServerSigningMaterial(currentPda);
|
||||
const tx = await updateShineUserPdaOnSolana({
|
||||
login: sessionLogin,
|
||||
solanaEndpoint,
|
||||
rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64,
|
||||
clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64,
|
||||
accessServers: normalizedList,
|
||||
});
|
||||
await loadCurrentServers();
|
||||
target.textContent = `${String(successText || 'Список серверов доступа обновлён.')} Tx: ${shortenSignature(tx?.signature)}`;
|
||||
setSelectedCandidate(null);
|
||||
suggestEl.hidden = true;
|
||||
suggestEl.innerHTML = '';
|
||||
refreshAddButton();
|
||||
} catch (error) {
|
||||
if (isInsufficientFundsForRentError(error)) {
|
||||
showTopupRequiredStatus(target, signingMaterial?.clientAddress);
|
||||
} else {
|
||||
target.textContent = error?.message || 'Не удалось обновить список серверов доступа.';
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
setOperationBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
addInput.addEventListener('input', () => {
|
||||
setSelectedCandidate(null);
|
||||
refreshAddButton();
|
||||
void loadSuggestions();
|
||||
});
|
||||
addInput.addEventListener('focus', () => {
|
||||
void loadSuggestions();
|
||||
});
|
||||
suggestEl.addEventListener('click', (event) => {
|
||||
const target = event.target instanceof HTMLElement ? event.target.closest('[data-login]') : null;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
setSelectedCandidate({
|
||||
login: String(target.dataset.login || ''),
|
||||
url: String(target.dataset.url || ''),
|
||||
});
|
||||
suggestEl.hidden = true;
|
||||
suggestEl.innerHTML = '';
|
||||
});
|
||||
|
||||
addButton.addEventListener('click', async () => {
|
||||
if (operationBusy) return;
|
||||
const login = normalizeLogin(selectedCandidate?.login || addInput.value);
|
||||
if (!login) {
|
||||
setSelectedCandidate(null);
|
||||
addStatus.textContent = 'Сначала укажите логин сервера доступа.';
|
||||
return;
|
||||
}
|
||||
if (currentAccessServers.some((item) => item.login === login)) {
|
||||
addStatus.textContent = `Сервер @${login} уже есть в списке.`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint });
|
||||
confirmModal?.open({
|
||||
title: 'Добавить сервер доступа?',
|
||||
text: `Вы хотите добавить сервер доступа @${resolved.serverLogin}?`,
|
||||
note: resolved.httpBase
|
||||
? `Адрес сервера: ${resolved.httpBase}\nИзменение будет записано в Solana user PDA.`
|
||||
: 'Изменение будет записано в Solana user PDA.',
|
||||
onConfirm: async () => {
|
||||
const nextList = uniqueLogins([...currentAccessServers.map((item) => item.login), resolved.serverLogin]);
|
||||
try {
|
||||
await updateAccessServers(nextList, {
|
||||
statusTarget: addStatus,
|
||||
successText: `Сервер доступа @${resolved.serverLogin} добавлен.`,
|
||||
inFlightText: `Обновляем PDA и добавляем сервер @${resolved.serverLogin}...`,
|
||||
});
|
||||
} catch {
|
||||
// Сообщение уже показано в статусе.
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
addStatus.textContent = error?.message || 'Не удалось проверить выбранный сервер.';
|
||||
}
|
||||
});
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Серверы доступа',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
introCard,
|
||||
listCard,
|
||||
addCard,
|
||||
);
|
||||
|
||||
void loadCurrentServers();
|
||||
|
||||
screen.cleanup = () => {
|
||||
disposed = true;
|
||||
confirmModal?.destroy();
|
||||
passwordModal?.destroy();
|
||||
};
|
||||
|
||||
return screen;
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import { authService, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
channelNameErrorText,
|
||||
normalizeChannelDescription,
|
||||
normalizeChannelDisplayName,
|
||||
validateChannelDisplayName,
|
||||
} from '../services/channel-name-rules.js';
|
||||
import { openArweaveAttachmentManager, markArweaveAttachmentPlaced } from '../components/arweave-attachment-manager.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
|
||||
export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' };
|
||||
|
||||
@@ -21,13 +22,30 @@ function persistCreateSuccessFlash(message) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateDescription(value) {
|
||||
const normalized = normalizeChannelDescription(value);
|
||||
const bytes = new TextEncoder().encode(normalized).length;
|
||||
if (bytes > 200) {
|
||||
return { ok: false, normalized, bytes, error: 'Описание слишком длинное: максимум 200 байт UTF-8.' };
|
||||
function normalizeMetaText(value, max, label) {
|
||||
const normalized = String(value || '').trim();
|
||||
const length = Array.from(normalized).length;
|
||||
if (length > max) {
|
||||
return { ok: false, normalized, length, error: `${label}: максимум ${max} символов.` };
|
||||
}
|
||||
return { ok: true, normalized, bytes, error: '' };
|
||||
return { ok: true, normalized, length, error: '' };
|
||||
}
|
||||
|
||||
function renderAvatarPreview(slot, avatar, title) {
|
||||
if (!slot) return;
|
||||
slot.innerHTML = '';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
wrap.style.setProperty('--channel-avatar-size', '104px');
|
||||
if (avatar?.ar) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар канала';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar });
|
||||
wrap.append(img);
|
||||
} else {
|
||||
wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||
}
|
||||
slot.append(wrap);
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
@@ -50,13 +68,21 @@ export function render({ navigate }) {
|
||||
<p class="channel-head-meta">Можно использовать большие и маленькие буквы, но уникальность проверяется без учёта регистра.</p>
|
||||
<div class="meta-muted">Тип канала фиксирован: публичный (1).</div>
|
||||
|
||||
<label for="channel-name">Название канала</label>
|
||||
<label for="channel-name">Техническое имя канала</label>
|
||||
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required />
|
||||
<div id="channel-name-error" class="meta-muted inline-error"></div>
|
||||
|
||||
<label for="channel-title">Человекочитаемое имя</label>
|
||||
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" />
|
||||
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div>
|
||||
<div id="channel-title-error" class="meta-muted inline-error"></div>
|
||||
|
||||
<div id="channel-avatar-preview"></div>
|
||||
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
|
||||
|
||||
<label for="channel-description">Описание канала (необязательно)</label>
|
||||
<textarea id="channel-description" class="input" rows="4" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
|
||||
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
|
||||
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
|
||||
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div>
|
||||
<div id="channel-description-error" class="meta-muted inline-error"></div>
|
||||
|
||||
<div id="channel-create-error" class="meta-muted inline-error"></div>
|
||||
@@ -67,8 +93,13 @@ export function render({ navigate }) {
|
||||
`;
|
||||
|
||||
const nameEl = form.querySelector('#channel-name');
|
||||
const titleEl = form.querySelector('#channel-title');
|
||||
const descriptionEl = form.querySelector('#channel-description');
|
||||
const avatarPreviewEl = form.querySelector('#channel-avatar-preview');
|
||||
const avatarBtn = form.querySelector('#channel-avatar-btn');
|
||||
const nameErrorEl = form.querySelector('#channel-name-error');
|
||||
const titleErrorEl = form.querySelector('#channel-title-error');
|
||||
const titleCounterEl = form.querySelector('#channel-title-counter');
|
||||
const descriptionErrorEl = form.querySelector('#channel-description-error');
|
||||
const descriptionCounterEl = form.querySelector('#channel-description-counter');
|
||||
const errorEl = form.querySelector('#channel-create-error');
|
||||
@@ -76,38 +107,60 @@ export function render({ navigate }) {
|
||||
const cancelEl = form.querySelector('#cancel-create-channel');
|
||||
|
||||
let submitInFlight = false;
|
||||
let selectedAvatar = null;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
submitInFlight = !!busy;
|
||||
submitEl.disabled = submitInFlight;
|
||||
cancelEl.disabled = submitInFlight;
|
||||
nameEl.disabled = submitInFlight;
|
||||
titleEl.disabled = submitInFlight;
|
||||
descriptionEl.disabled = submitInFlight;
|
||||
avatarBtn.disabled = submitInFlight;
|
||||
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
|
||||
};
|
||||
|
||||
const updateValidation = () => {
|
||||
const nameCheck = validateChannelDisplayName(nameEl.value);
|
||||
const descriptionCheck = validateDescription(descriptionEl.value);
|
||||
const titleCheck = normalizeMetaText(titleEl.value, 50, 'Название');
|
||||
const descriptionCheck = normalizeMetaText(descriptionEl.value, 250, 'Описание');
|
||||
|
||||
nameErrorEl.textContent = nameCheck.ok ? '' : channelNameErrorText(nameCheck.code);
|
||||
titleErrorEl.textContent = titleCheck.error;
|
||||
descriptionErrorEl.textContent = descriptionCheck.error;
|
||||
|
||||
const descLength = Number(descriptionCheck.bytes || 0);
|
||||
descriptionCounterEl.textContent = `${descLength} / 200 байт`;
|
||||
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`;
|
||||
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250 символов`;
|
||||
|
||||
const ok = nameCheck.ok && descriptionCheck.ok;
|
||||
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
|
||||
submitEl.disabled = submitInFlight || !ok;
|
||||
renderAvatarPreview(avatarPreviewEl, selectedAvatar, titleCheck.normalized || nameCheck.normalized);
|
||||
|
||||
return {
|
||||
ok,
|
||||
name: nameCheck.normalized,
|
||||
title: titleCheck.normalized,
|
||||
description: descriptionCheck.normalized,
|
||||
};
|
||||
};
|
||||
|
||||
nameEl.addEventListener('input', updateValidation);
|
||||
titleEl.addEventListener('input', updateValidation);
|
||||
descriptionEl.addEventListener('input', updateValidation);
|
||||
avatarBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
selectedAvatar = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
mode: 'avatar',
|
||||
historyPurpose: 'avatar',
|
||||
});
|
||||
updateValidation();
|
||||
} catch (error) {
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.');
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -131,10 +184,13 @@ export function render({ navigate }) {
|
||||
login,
|
||||
storagePwd,
|
||||
channelName: normalizeChannelDisplayName(check.name),
|
||||
channelDescription: normalizeChannelDescription(check.description),
|
||||
channelDescription: check.description,
|
||||
channelProfileTitle: check.title,
|
||||
channelAvatar: selectedAvatar,
|
||||
channelType: CHANNEL_TYPE_PUBLIC,
|
||||
channelTypeVersion: 1,
|
||||
});
|
||||
if (selectedAvatar?.ar) markArweaveAttachmentPlaced(login, selectedAvatar);
|
||||
|
||||
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
|
||||
navigate('channels-list');
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
clearArweaveAttachmentHistory,
|
||||
formatArweaveHistoryTime,
|
||||
getArweaveAttachmentAvailability,
|
||||
openArweaveAttachmentManager,
|
||||
readArweaveAttachmentHistory,
|
||||
} from '../components/arweave-attachment-manager.js';
|
||||
import { formatBytes } from '../services/attachment-format.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'arweave-uploads-view', title: 'Загрузка файлов' };
|
||||
|
||||
function closeUploadsMenu(controls) {
|
||||
const menu = controls?.querySelector?.('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
}
|
||||
|
||||
function setStatusBadge(node, status, label) {
|
||||
if (!node) return;
|
||||
node.className = `ar-attachment-status ar-attachment-status--${status || 'pending'}`;
|
||||
node.textContent = label || 'Проверяем...';
|
||||
}
|
||||
|
||||
function renderTile(item, index) {
|
||||
const tile = document.createElement('article');
|
||||
tile.className = `ar-attachment-history-tile ar-attachment-history-tile--page${item?.preview?.ar ? ' ar-attachment-history-tile--with-preview' : ''}`;
|
||||
|
||||
const name = document.createElement('strong');
|
||||
name.className = 'ar-attachment-history-name';
|
||||
name.textContent = item.name;
|
||||
|
||||
const status = document.createElement('span');
|
||||
status.className = 'ar-attachment-status ar-attachment-status--pending';
|
||||
status.dataset.statusIndex = String(index);
|
||||
status.textContent = 'Проверяем...';
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'ar-attachment-history-meta-row';
|
||||
const size = document.createElement('span');
|
||||
size.textContent = formatBytes(item.size);
|
||||
const time = document.createElement('span');
|
||||
time.textContent = formatArweaveHistoryTime(item.uploadedAtMs);
|
||||
meta.append(status, size, time);
|
||||
if (item?.preview?.ar) {
|
||||
const previewFlag = document.createElement('span');
|
||||
previewFlag.className = 'ar-attachment-placement-flag';
|
||||
previewFlag.textContent = 'С превью';
|
||||
meta.append(previewFlag);
|
||||
}
|
||||
if (item.pendingPlacement) {
|
||||
const flag = document.createElement('span');
|
||||
flag.className = 'ar-attachment-placement-flag';
|
||||
flag.textContent = 'Не добавлен в SHiNE';
|
||||
meta.append(flag);
|
||||
}
|
||||
|
||||
const txId = document.createElement('div');
|
||||
txId.className = 'mono-cell ar-attachment-history-txid';
|
||||
txId.textContent = item.ar;
|
||||
|
||||
tile.append(name, meta, txId);
|
||||
return tile;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack arweave-uploads-screen';
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'arweave-uploads-toolbar';
|
||||
controls.innerHTML = `
|
||||
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
|
||||
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
|
||||
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
|
||||
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню">⋮</button>
|
||||
<div class="arweave-uploads-menu" data-menu="true" hidden>
|
||||
<button class="text-btn" type="button" data-action="upload-menu">Добавить файл</button>
|
||||
<button class="text-btn" type="button" data-action="clear">Очистить историю</button>
|
||||
<button class="text-btn" type="button" data-action="help">Справка</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const statusLine = document.createElement('p');
|
||||
statusLine.className = 'meta-muted inline-error';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ar-attachment-history-tiles arweave-uploads-list';
|
||||
|
||||
const uploadFile = async () => {
|
||||
statusLine.textContent = '';
|
||||
closeUploadsMenu(controls);
|
||||
try {
|
||||
await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
historyOnly: true,
|
||||
});
|
||||
renderHistory();
|
||||
} catch (error) {
|
||||
statusLine.textContent = toUserMessage(error, 'Не удалось загрузить файл.');
|
||||
}
|
||||
};
|
||||
|
||||
function renderHistory() {
|
||||
const rows = readArweaveAttachmentHistory(state.session.login).slice().reverse();
|
||||
list.innerHTML = '';
|
||||
statusLine.textContent = '';
|
||||
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Загруженные в этой сессии файлы появятся здесь.';
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
rows.forEach((item, index) => {
|
||||
list.append(renderTile(item, index));
|
||||
const statusEl = list.querySelector(`[data-status-index="${index}"]`);
|
||||
void getArweaveAttachmentAvailability({
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
attachment: item,
|
||||
})
|
||||
.then((result) => setStatusBadge(statusEl, result.status, result.label))
|
||||
.catch(() => setStatusBadge(statusEl, 'unavailable', 'Недоступен'));
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
controls.querySelector('[data-action="upload"]')?.addEventListener('click', () => {
|
||||
void uploadFile();
|
||||
});
|
||||
controls.querySelector('[data-action="upload-menu"]')?.addEventListener('click', () => {
|
||||
void uploadFile();
|
||||
});
|
||||
|
||||
controls.querySelector('[data-action="back"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||
controls.querySelector('[data-action="menu"]')?.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = !menu.hidden;
|
||||
});
|
||||
controls.querySelector('[data-action="clear"]')?.addEventListener('click', () => {
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?');
|
||||
if (!confirmed) return;
|
||||
clearArweaveAttachmentHistory(state.session.login);
|
||||
renderHistory();
|
||||
});
|
||||
controls.querySelector('[data-action="help"]')?.addEventListener('click', () => {
|
||||
const menu = controls.querySelector('[data-menu="true"]');
|
||||
if (menu) menu.hidden = true;
|
||||
window.alert(
|
||||
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. Кнопка «Загрузить через Turbo» находится внутри стандартного окна загрузки.\n\n'
|
||||
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
|
||||
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
|
||||
);
|
||||
});
|
||||
screen.addEventListener('click', (event) => {
|
||||
if (controls.contains(event.target)) return;
|
||||
closeUploadsMenu(controls);
|
||||
});
|
||||
|
||||
screen.append(controls, statusLine, list);
|
||||
renderHistory();
|
||||
return screen;
|
||||
}
|
||||
@@ -12,6 +12,14 @@ import {
|
||||
} from '../services/channels-ux.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import {
|
||||
composeMessageWithAttachments,
|
||||
createAttachmentCarouselElement,
|
||||
escapeHtml,
|
||||
MAX_MESSAGE_ATTACHMENTS,
|
||||
parseMessageAttachments,
|
||||
} from '../services/attachment-format.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
@@ -322,6 +330,91 @@ function bindSubmitOnPlainEnter(textarea, submit) {
|
||||
});
|
||||
}
|
||||
|
||||
function setActionTitle(button, label) {
|
||||
if (!button) return;
|
||||
button.title = label;
|
||||
button.setAttribute('aria-label', label);
|
||||
const labelEl = button.querySelector('.channel-action-label');
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text) {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.append(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return !!ok;
|
||||
}
|
||||
|
||||
function buildBlockchainDetails({ target, authorLogin, timestampMs, text, raw, localNumber, msgSubType }) {
|
||||
const source = raw && typeof raw === 'object' ? raw : {};
|
||||
return {
|
||||
authorLogin,
|
||||
authorBlockchainName: target?.blockchainName || source.authorBlockchainName || '',
|
||||
blockNumber: target?.blockNumber ?? source?.messageRef?.blockNumber ?? '',
|
||||
blockHash: target?.blockHash || source?.messageRef?.blockHash || '',
|
||||
localNumber,
|
||||
msgSubType: msgSubType ?? source.msgSubType ?? '',
|
||||
createdAtMs: timestampMs || source.createdAtMs || '',
|
||||
text: String(text || ''),
|
||||
signature: source.signature || source.blockSignature || source.authorSignature || 'нет в ответе сервера',
|
||||
publicKey: source.publicKey || source.authorPublicKey || 'нет в ответе сервера',
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
function openBlockchainDetailsModal(details) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const rawText = JSON.stringify(details.raw || details, null, 2);
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-blockchain-details-modal">
|
||||
<div class="modal-card stack blockchain-details-card">
|
||||
<h3 class="modal-title">Данные блокчейна сообщения</h3>
|
||||
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<div class="blockchain-details-grid">
|
||||
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
|
||||
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
|
||||
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
|
||||
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
|
||||
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
|
||||
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}</code>
|
||||
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
|
||||
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
|
||||
</div>
|
||||
<label class="field-label" for="thread-blockchain-details-text">Текст записи</label>
|
||||
<textarea class="input" id="thread-blockchain-details-text" rows="4" readonly>${escapeHtml(details.text)}</textarea>
|
||||
<pre class="blockchain-raw-block" id="thread-blockchain-raw-block" hidden>${escapeHtml(rawText)}</pre>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="thread-blockchain-details-copy" type="button">Скопировать</button>
|
||||
<button class="secondary-btn" id="thread-blockchain-details-raw" type="button">Показать сырой блок</button>
|
||||
</div>
|
||||
<button class="secondary-btn" id="thread-blockchain-details-close" type="button">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.querySelector('#thread-blockchain-details-close')?.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
});
|
||||
root.querySelector('#thread-blockchain-details-copy')?.addEventListener('click', async () => {
|
||||
await copyTextToClipboard(rawText);
|
||||
showToast('Данные блокчейна скопированы');
|
||||
});
|
||||
root.querySelector('#thread-blockchain-details-raw')?.addEventListener('click', () => {
|
||||
const rawEl = root.querySelector('#thread-blockchain-raw-block');
|
||||
if (!rawEl) return;
|
||||
rawEl.hidden = !rawEl.hidden;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveNodeText(node) {
|
||||
return firstNonEmptyText(
|
||||
node?.text,
|
||||
@@ -331,6 +424,25 @@ function resolveNodeText(node) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderDraftAttachments(container, attachments) {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'draft-attachment-chip';
|
||||
button.textContent = `${item.name} · ${item.ar}`;
|
||||
button.title = 'Нажмите, чтобы убрать вложение';
|
||||
button.addEventListener('click', () => {
|
||||
const ok = window.confirm('Отменить вложение?');
|
||||
if (!ok) return;
|
||||
attachments.splice(index, 1);
|
||||
renderDraftAttachments(container, attachments);
|
||||
});
|
||||
container.append(button);
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit, navigate }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
@@ -338,6 +450,8 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Ответ</h3>
|
||||
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
||||
<div class="draft-attachments" id="thread-reply-attachments"></div>
|
||||
<button class="secondary-btn attachment-trigger-btn" id="thread-reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||
<div class="meta-muted inline-error" id="thread-reply-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
|
||||
@@ -348,14 +462,17 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#thread-reply-text');
|
||||
const attachmentsEl = root.querySelector('#thread-reply-attachments');
|
||||
const errorEl = root.querySelector('#thread-reply-error');
|
||||
const submitEl = root.querySelector('#thread-reply-submit');
|
||||
const attachments = [];
|
||||
let inFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
inFlight = !!busy;
|
||||
submitEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
root.querySelector('#thread-reply-attach')?.toggleAttribute('disabled', inFlight);
|
||||
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||
};
|
||||
|
||||
@@ -368,8 +485,8 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
if (inFlight) return;
|
||||
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text) {
|
||||
errorEl.textContent = 'Введите текст ответа.';
|
||||
if (!text && attachments.length === 0) {
|
||||
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -377,7 +494,8 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit(text);
|
||||
await onSubmit(composeMessageWithAttachments(text, attachments));
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
@@ -385,6 +503,26 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelector('#thread-reply-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
|
||||
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#thread-reply-submit')?.click());
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
@@ -504,7 +642,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
|
||||
});
|
||||
}
|
||||
|
||||
function openEditMessageModal({ initialText = '', onSave, onDelete }) {
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-edit-modal">
|
||||
@@ -531,7 +669,7 @@ function openEditMessageModal({ initialText = '', onSave, onDelete }) {
|
||||
root.querySelector('#thread-edit-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#thread-edit-save')?.addEventListener('click', async () => {
|
||||
const value = String(textEl?.value || '').trim();
|
||||
if (!value) {
|
||||
if (!value && !allowEmptyText) {
|
||||
errorEl.textContent = 'Введите текст сообщения.';
|
||||
return;
|
||||
}
|
||||
@@ -569,6 +707,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0;
|
||||
const msgSubType = Number(node?.msgSubType || 0);
|
||||
const repostTarget = msgSubType === 30 ? buildRepostTargetFromNode(node) : null;
|
||||
const parsedText = parseMessageAttachments(text);
|
||||
|
||||
const headingText = String(heading || '').trim();
|
||||
if (headingText) {
|
||||
@@ -618,11 +757,36 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
authorTile.append(avatar, authorBlock);
|
||||
|
||||
const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено';
|
||||
const body = document.createElement('p');
|
||||
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
|
||||
body.textContent = isDeletedMessage ? 'Сообщение удалено' : text;
|
||||
|
||||
card.append(authorTile, body);
|
||||
if (isDeletedMessage) {
|
||||
card.classList.add('channel-message-card--deleted-compact');
|
||||
const deleted = document.createElement('button');
|
||||
deleted.type = 'button';
|
||||
deleted.className = 'deleted-message-pill';
|
||||
deleted.textContent = `Удалённое сообщение от ${author}`;
|
||||
deleted.title = 'Открыть историю изменений';
|
||||
deleted.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openMessageHistoryModal({
|
||||
title: `История #${localNumber}`,
|
||||
versions,
|
||||
});
|
||||
});
|
||||
card.append(deleted);
|
||||
return card;
|
||||
} else {
|
||||
card.append(authorTile);
|
||||
if (parsedText.attachments.length > 0) {
|
||||
card.append(createAttachmentCarouselElement(parsedText.attachments, {
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
messageTimestampMs: node?.createdAtMs,
|
||||
}));
|
||||
}
|
||||
const body = document.createElement('p');
|
||||
body.className = 'channel-message-body';
|
||||
body.textContent = parsedText.text;
|
||||
card.append(body);
|
||||
}
|
||||
|
||||
const target = buildTargetFromNode(node);
|
||||
const refKey = messageRefKey(target);
|
||||
@@ -649,6 +813,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter">${likes}</span>
|
||||
`;
|
||||
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
@@ -660,6 +825,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
}
|
||||
await longPressFeel(event.currentTarget, 130);
|
||||
likeButton.disabled = true;
|
||||
setActionTitle(likeButton, 'Лайк...');
|
||||
try {
|
||||
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
||||
} catch (error) {
|
||||
@@ -680,6 +846,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
<span class="channel-action-counter">${replies}</span>
|
||||
`;
|
||||
setActionTitle(replyButton, 'Ответить');
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
@@ -696,6 +863,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||
<span class="channel-action-label">Отправить</span>
|
||||
`;
|
||||
setActionTitle(shareButton, 'Отправить');
|
||||
shareButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
@@ -713,6 +881,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
<span class="channel-action-icon" aria-hidden="true">↪</span>
|
||||
<span class="channel-action-label">Оригинал</span>
|
||||
`;
|
||||
setActionTitle(originalButton, 'Оригинал');
|
||||
originalButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const ok = window.confirm('Перейти к оригинальному сообщению?');
|
||||
@@ -727,6 +896,27 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
});
|
||||
actions.append(originalButton);
|
||||
}
|
||||
const detailsButton = document.createElement('button');
|
||||
detailsButton.type = 'button';
|
||||
detailsButton.className = 'channel-action-item';
|
||||
detailsButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">⛓</span>
|
||||
<span class="channel-action-label">Данные блокчейна</span>
|
||||
`;
|
||||
setActionTitle(detailsButton, 'Данные блокчейна');
|
||||
detailsButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openBlockchainDetailsModal(buildBlockchainDetails({
|
||||
target,
|
||||
authorLogin: author,
|
||||
timestampMs: node?.createdAtMs,
|
||||
text,
|
||||
raw: node,
|
||||
localNumber,
|
||||
msgSubType,
|
||||
}));
|
||||
});
|
||||
actions.append(detailsButton);
|
||||
if (isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
@@ -740,8 +930,9 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openEditMessageModal({
|
||||
initialText: String(text || '').trim() === 'удалено' ? '' : text,
|
||||
onSave: async (nextText) => handlers.onEdit(target, nextText, { isChannelPost }),
|
||||
initialText: String(text || '').trim() === 'удалено' ? '' : parsedText.text,
|
||||
allowEmptyText: parsedText.attachments.length > 0,
|
||||
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsedText.attachments), { isChannelPost }),
|
||||
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,12 +18,21 @@ import {
|
||||
} from '../services/channels-ux.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
|
||||
import {
|
||||
composeMessageWithAttachments,
|
||||
createAttachmentCarouselElement,
|
||||
escapeHtml,
|
||||
MAX_MESSAGE_ATTACHMENTS,
|
||||
parseMessageAttachments,
|
||||
} from '../services/attachment-format.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import {
|
||||
extractLoginFromBlockchainName,
|
||||
makeProfileRoute,
|
||||
makeShineMessageRoute,
|
||||
} from '../services/shine-routes.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
@@ -278,22 +287,144 @@ function resolveMessageTimestampMs(message) {
|
||||
);
|
||||
}
|
||||
|
||||
function openAboutChannelModal(channel) {
|
||||
function createChannelAvatarElement(channel, size = 72) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channel-profile-avatar';
|
||||
wrap.style.setProperty('--channel-avatar-size', `${size}px`);
|
||||
const txId = String(channel?.avaAr || '').trim();
|
||||
if (txId) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = 'Аватар канала';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId });
|
||||
wrap.append(img);
|
||||
} else {
|
||||
wrap.textContent = String(channel?.displayTitle || channel?.name || 'К').trim().slice(0, 1).toUpperCase() || 'К';
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function openChannelMetaDetailsModal({ title = 'Данные канала', channel = {}, canEdit = false, onEdit = null } = {}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="about-channel-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">О канале</h3>
|
||||
<p><strong>${channel.displayName || channel.name}</strong></p>
|
||||
<p class="meta-muted">${channel.description || 'Описание не задано.'}</p>
|
||||
<div class="modal-card stack channel-profile-card">
|
||||
<div class="channel-profile-modal-head">
|
||||
<h3 class="modal-title">${escapeHtml(title)}</h3>
|
||||
${canEdit ? '<button class="secondary-btn channel-profile-edit-btn" id="about-channel-edit" type="button" title="Изменить">✏️</button>' : ''}
|
||||
</div>
|
||||
<div id="about-channel-avatar-slot"></div>
|
||||
<p><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name)}</strong></p>
|
||||
<p class="meta-muted">${escapeHtml(`${channel.ownerName || 'автор'} / ${channel.name || 'channel'}`)}</p>
|
||||
<p class="meta-muted">${escapeHtml(channel.description || 'Описание не задано.')}</p>
|
||||
<button class="secondary-btn" id="about-channel-close" type="button">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.querySelector('#about-channel-avatar-slot')?.append(createChannelAvatarElement(channel, 86));
|
||||
|
||||
root.querySelector('#about-channel-close')?.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
});
|
||||
root.querySelector('#about-channel-edit')?.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
if (typeof onEdit === 'function') onEdit();
|
||||
});
|
||||
}
|
||||
|
||||
function openAboutChannelModal(channel, options = {}) {
|
||||
openChannelMetaDetailsModal({
|
||||
title: 'О канале',
|
||||
channel,
|
||||
canEdit: options.canEdit === true,
|
||||
onEdit: options.onEdit,
|
||||
});
|
||||
}
|
||||
|
||||
function openEditChannelModal({ channel, onSave }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
let selectedAvatar = channel?.avaAr ? {
|
||||
name: 'Аватар',
|
||||
ar: channel.avaAr,
|
||||
sha256: channel.avaSha256,
|
||||
size: channel.avaSize,
|
||||
purpose: 'avatar',
|
||||
} : null;
|
||||
|
||||
const renderAvatarPreview = () => {
|
||||
const slot = root.querySelector('#edit-channel-avatar-preview');
|
||||
if (!slot) return;
|
||||
slot.innerHTML = '';
|
||||
slot.append(createChannelAvatarElement({
|
||||
...channel,
|
||||
avaAr: selectedAvatar?.ar || '',
|
||||
displayTitle: root.querySelector('#edit-channel-title')?.value || channel.displayTitle,
|
||||
}, 86));
|
||||
};
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="edit-channel-modal">
|
||||
<div class="modal-card stack channel-profile-card">
|
||||
<h3 class="modal-title">Изменение канала</h3>
|
||||
<div id="edit-channel-avatar-preview"></div>
|
||||
<button class="secondary-btn" id="edit-channel-avatar" type="button">Выбрать аватар</button>
|
||||
<label class="meta-muted" for="edit-channel-title">Человекочитаемое имя</label>
|
||||
<input class="input" id="edit-channel-title" type="text" maxlength="50" value="${escapeHtml(channel.displayTitle || channel.name || '')}" />
|
||||
<label class="meta-muted" for="edit-channel-description">Описание</label>
|
||||
<textarea class="input" id="edit-channel-description" rows="4" maxlength="250">${escapeHtml(channel.description || '')}</textarea>
|
||||
<p class="meta-muted inline-error" id="edit-channel-error"></p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="edit-channel-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="edit-channel-save" type="button">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
renderAvatarPreview();
|
||||
|
||||
root.querySelector('#edit-channel-cancel')?.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
});
|
||||
root.querySelector('#edit-channel-title')?.addEventListener('input', renderAvatarPreview);
|
||||
root.querySelector('#edit-channel-avatar')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
mode: 'avatar',
|
||||
historyPurpose: 'avatar',
|
||||
});
|
||||
if (!item) return;
|
||||
selectedAvatar = item;
|
||||
renderAvatarPreview();
|
||||
} catch (error) {
|
||||
const errorEl = root.querySelector('#edit-channel-error');
|
||||
if (errorEl) errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.');
|
||||
}
|
||||
});
|
||||
root.querySelector('#edit-channel-save')?.addEventListener('click', async () => {
|
||||
const saveBtn = root.querySelector('#edit-channel-save');
|
||||
const errorEl = root.querySelector('#edit-channel-error');
|
||||
const title = String(root.querySelector('#edit-channel-title')?.value || '').trim();
|
||||
const description = String(root.querySelector('#edit-channel-description')?.value || '').trim();
|
||||
if (Array.from(title).length > 50) {
|
||||
if (errorEl) errorEl.textContent = 'Название: максимум 50 символов.';
|
||||
return;
|
||||
}
|
||||
if (Array.from(description).length > 250) {
|
||||
if (errorEl) errorEl.textContent = 'Описание: максимум 250 символов.';
|
||||
return;
|
||||
}
|
||||
saveBtn.disabled = true;
|
||||
if (errorEl) errorEl.textContent = '';
|
||||
try {
|
||||
await onSave({ title, description, avatar: selectedAvatar });
|
||||
root.innerHTML = '';
|
||||
} catch (error) {
|
||||
saveBtn.disabled = false;
|
||||
if (errorEl) errorEl.textContent = toUserMessage(error, 'Не удалось изменить канал.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindSubmitOnPlainEnter(textarea, submit) {
|
||||
@@ -306,6 +437,110 @@ function bindSubmitOnPlainEnter(textarea, submit) {
|
||||
});
|
||||
}
|
||||
|
||||
function setActionTitle(button, label) {
|
||||
if (!button) return;
|
||||
button.title = label;
|
||||
button.setAttribute('aria-label', label);
|
||||
const labelEl = button.querySelector('.channel-action-label');
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text) {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.append(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return !!ok;
|
||||
}
|
||||
|
||||
function buildBlockchainDetails({ messageRef, authorLogin, timestampMs, text, raw, localNumber, msgSubType }) {
|
||||
const source = raw && typeof raw === 'object' ? raw : {};
|
||||
return {
|
||||
authorLogin,
|
||||
authorBlockchainName: messageRef?.blockchainName || source.authorBlockchainName || '',
|
||||
blockNumber: messageRef?.blockNumber ?? source?.messageRef?.blockNumber ?? '',
|
||||
blockHash: messageRef?.blockHash || source?.messageRef?.blockHash || '',
|
||||
localNumber,
|
||||
msgSubType: msgSubType ?? source.msgSubType ?? '',
|
||||
createdAtMs: timestampMs || source.createdAtMs || '',
|
||||
text: String(text || ''),
|
||||
signature: source.signature || source.blockSignature || source.authorSignature || 'нет в ответе сервера',
|
||||
publicKey: source.publicKey || source.authorPublicKey || 'нет в ответе сервера',
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
function openBlockchainDetailsModal(details) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const rawText = JSON.stringify(details.raw || details, null, 2);
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="blockchain-details-modal">
|
||||
<div class="modal-card stack blockchain-details-card">
|
||||
<h3 class="modal-title">Данные блокчейна сообщения</h3>
|
||||
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<div class="blockchain-details-grid">
|
||||
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
|
||||
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
|
||||
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
|
||||
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
|
||||
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
|
||||
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}</code>
|
||||
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
|
||||
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
|
||||
</div>
|
||||
<label class="field-label" for="blockchain-details-text">Текст записи</label>
|
||||
<textarea class="input" id="blockchain-details-text" rows="4" readonly>${escapeHtml(details.text)}</textarea>
|
||||
<pre class="blockchain-raw-block" id="blockchain-raw-block" hidden>${escapeHtml(rawText)}</pre>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="blockchain-details-copy" type="button">Скопировать</button>
|
||||
<button class="secondary-btn" id="blockchain-details-raw" type="button">Показать сырой блок</button>
|
||||
</div>
|
||||
<button class="secondary-btn" id="blockchain-details-close" type="button">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.querySelector('#blockchain-details-close')?.addEventListener('click', () => {
|
||||
root.innerHTML = '';
|
||||
});
|
||||
root.querySelector('#blockchain-details-copy')?.addEventListener('click', async () => {
|
||||
await copyTextToClipboard(rawText);
|
||||
showToast('Данные блокчейна скопированы');
|
||||
});
|
||||
root.querySelector('#blockchain-details-raw')?.addEventListener('click', () => {
|
||||
const rawEl = root.querySelector('#blockchain-raw-block');
|
||||
if (!rawEl) return;
|
||||
rawEl.hidden = !rawEl.hidden;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDraftAttachments(container, attachments) {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'draft-attachment-chip';
|
||||
button.textContent = `${item.name} · ${item.ar}`;
|
||||
button.title = 'Нажмите, чтобы убрать вложение';
|
||||
button.addEventListener('click', () => {
|
||||
const ok = window.confirm('Отменить вложение?');
|
||||
if (!ok) return;
|
||||
attachments.splice(index, 1);
|
||||
renderDraftAttachments(container, attachments);
|
||||
});
|
||||
container.append(button);
|
||||
});
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit, navigate }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
@@ -313,6 +548,8 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Ответ</h3>
|
||||
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
||||
<div class="draft-attachments" id="reply-attachments"></div>
|
||||
<button class="secondary-btn attachment-trigger-btn" id="reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||
<div class="meta-muted inline-error" id="reply-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
|
||||
@@ -323,14 +560,17 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#reply-text');
|
||||
const attachmentsEl = root.querySelector('#reply-attachments');
|
||||
const errorEl = root.querySelector('#reply-error');
|
||||
const submitEl = root.querySelector('#reply-submit');
|
||||
const attachments = [];
|
||||
let inFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
inFlight = !!busy;
|
||||
submitEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
root.querySelector('#reply-attach')?.toggleAttribute('disabled', inFlight);
|
||||
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||
};
|
||||
|
||||
@@ -343,8 +583,8 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
if (inFlight) return;
|
||||
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text) {
|
||||
errorEl.textContent = 'Введите текст ответа.';
|
||||
if (!text && attachments.length === 0) {
|
||||
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -352,7 +592,8 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit(text);
|
||||
await onSubmit(composeMessageWithAttachments(text, attachments));
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
@@ -360,6 +601,26 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelector('#reply-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
|
||||
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
@@ -447,6 +708,8 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
<h3 class="modal-title">Новое сообщение в канале</h3>
|
||||
<p class="meta-muted">${channelName}</p>
|
||||
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
|
||||
<div class="draft-attachments" id="channel-message-attachments"></div>
|
||||
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||
<div class="meta-muted inline-error" id="channel-message-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
|
||||
@@ -457,14 +720,17 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#channel-message-text');
|
||||
const attachmentsEl = root.querySelector('#channel-message-attachments');
|
||||
const errorEl = root.querySelector('#channel-message-error');
|
||||
const submitEl = root.querySelector('#channel-message-submit');
|
||||
const attachments = [];
|
||||
let inFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
inFlight = !!busy;
|
||||
submitEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight);
|
||||
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||
};
|
||||
|
||||
@@ -477,8 +743,8 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
if (inFlight) return;
|
||||
|
||||
const body = String(textEl?.value || '').trim();
|
||||
if (!body) {
|
||||
errorEl.textContent = 'Введите текст сообщения.';
|
||||
if (!body && attachments.length === 0) {
|
||||
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -486,7 +752,8 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit(body);
|
||||
await onSubmit(composeMessageWithAttachments(body, attachments));
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
@@ -494,6 +761,26 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelector('#channel-message-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
const item = await openArweaveAttachmentManager({
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
selectedTxIds: attachments.map((attachment) => attachment.ar),
|
||||
});
|
||||
if (!item) return;
|
||||
attachments.push(item);
|
||||
renderDraftAttachments(attachmentsEl, attachments);
|
||||
} catch (error) {
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
|
||||
}
|
||||
});
|
||||
|
||||
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
@@ -535,7 +822,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
|
||||
});
|
||||
}
|
||||
|
||||
function openEditMessageModal({ initialText = '', onSave, onDelete }) {
|
||||
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="edit-message-modal">
|
||||
@@ -563,7 +850,7 @@ function openEditMessageModal({ initialText = '', onSave, onDelete }) {
|
||||
root.querySelector('#edit-message-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#edit-message-save')?.addEventListener('click', async () => {
|
||||
const value = String(textEl?.value || '').trim();
|
||||
if (!value) {
|
||||
if (!value && !allowEmptyText) {
|
||||
errorEl.textContent = 'Введите текст сообщения.';
|
||||
return;
|
||||
}
|
||||
@@ -615,6 +902,7 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
||||
repliesCount: Number(message?.repliesCount || 0),
|
||||
timestampMs: resolveMessageTimestampMs(message),
|
||||
messageRef,
|
||||
rawMessage: message,
|
||||
msgSubType: Number(message?.msgSubType || 0),
|
||||
targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber))
|
||||
? {
|
||||
@@ -776,11 +1064,17 @@ async function loadFromApi(route, channelId) {
|
||||
return {
|
||||
channel: {
|
||||
name: payload.channel?.channelName || 'неизвестный канал',
|
||||
displayTitle: String(payload.channel?.displayName || payload.channel?.channelName || 'неизвестный канал').trim(),
|
||||
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
||||
description: String(payload.channel?.channelDescription || '').trim(),
|
||||
avaAr: String(payload.channel?.avaAr || '').trim(),
|
||||
avaSha256: String(payload.channel?.avaSha256 || '').trim(),
|
||||
avaSize: Number(payload.channel?.avaSize || 0),
|
||||
metaUpdatedAtMs: Number(payload.channel?.metaUpdatedAtMs || 0),
|
||||
ownerName: ownerLogin || 'неизвестно',
|
||||
},
|
||||
posts,
|
||||
metaEvents: Array.isArray(payload?.metaEvents) ? payload.metaEvents : [],
|
||||
reverseChannelMissingWarning,
|
||||
isOwnChannel,
|
||||
isSubscribed,
|
||||
@@ -868,6 +1162,48 @@ function applyPendingScroll(screen, routeKey, forceBottom = false) {
|
||||
setTimeout(doScroll, 20);
|
||||
}
|
||||
|
||||
function mapChannelMetaEvent(event, fallbackChannel) {
|
||||
return {
|
||||
kind: String(event?.kind || '').trim(),
|
||||
messageRef: event?.messageRef || null,
|
||||
timestampMs: Number(event?.createdAtMs || 0),
|
||||
title: String(event?.title || fallbackChannel?.displayTitle || fallbackChannel?.name || '').trim(),
|
||||
description: String(event?.description || '').trim(),
|
||||
avaAr: String(event?.avaAr || '').trim(),
|
||||
avaSha256: String(event?.avaSha256 || '').trim(),
|
||||
avaSize: Number(event?.avaSize || 0),
|
||||
ownerName: fallbackChannel?.ownerName || '',
|
||||
name: fallbackChannel?.name || '',
|
||||
};
|
||||
}
|
||||
|
||||
function renderChannelMetaEventCard(event) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card channel-system-event-card';
|
||||
const label = event.kind === 'created'
|
||||
? `Создан канал ${event.title || event.name || ''}`.trim()
|
||||
: 'Изменён профиль канала';
|
||||
card.innerHTML = `
|
||||
<span>${escapeHtml(label)}</span>
|
||||
<small>${escapeHtml(event.timestampMs ? new Date(event.timestampMs).toLocaleString('ru-RU') : '')}</small>
|
||||
`;
|
||||
card.addEventListener('click', () => {
|
||||
openChannelMetaDetailsModal({
|
||||
title: event.kind === 'created' ? 'Канал создан' : 'Профиль канала изменён',
|
||||
channel: {
|
||||
name: event.name,
|
||||
displayTitle: event.title,
|
||||
description: event.description,
|
||||
avaAr: event.avaAr,
|
||||
avaSha256: event.avaSha256,
|
||||
avaSize: event.avaSize,
|
||||
ownerName: event.ownerName,
|
||||
},
|
||||
});
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderPostCard(post, {
|
||||
navigate,
|
||||
selector,
|
||||
@@ -932,11 +1268,37 @@ function renderPostCard(post, {
|
||||
});
|
||||
|
||||
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
|
||||
const body = document.createElement('p');
|
||||
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
|
||||
body.textContent = isDeletedMessage ? 'Сообщение удалено' : post.body;
|
||||
const parsedBody = parseMessageAttachments(post.body);
|
||||
|
||||
card.append(authorTile, body);
|
||||
if (isDeletedMessage) {
|
||||
card.classList.add('channel-message-card--deleted-compact');
|
||||
const deleted = document.createElement('button');
|
||||
deleted.type = 'button';
|
||||
deleted.className = 'deleted-message-pill';
|
||||
deleted.textContent = `Удалённое сообщение от ${post.authorLogin}`;
|
||||
deleted.title = 'Открыть историю изменений';
|
||||
deleted.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openMessageHistoryModal({
|
||||
title: `История #${post.localNumber}`,
|
||||
versions: post.versions,
|
||||
});
|
||||
});
|
||||
card.append(deleted);
|
||||
return card;
|
||||
} else {
|
||||
card.append(authorTile);
|
||||
if (parsedBody.attachments.length > 0) {
|
||||
card.append(createAttachmentCarouselElement(parsedBody.attachments, {
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
messageTimestampMs: post.timestampMs,
|
||||
}));
|
||||
}
|
||||
const body = document.createElement('p');
|
||||
body.className = 'channel-message-body';
|
||||
body.textContent = parsedBody.text;
|
||||
card.append(body);
|
||||
}
|
||||
|
||||
const refKey = messageRefKey(post.messageRef);
|
||||
if (refKey) {
|
||||
@@ -962,6 +1324,7 @@ function renderPostCard(post, {
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
||||
`;
|
||||
setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
@@ -973,8 +1336,7 @@ function renderPostCard(post, {
|
||||
}
|
||||
await longPressFeel(event.currentTarget, 130);
|
||||
likeButton.disabled = true;
|
||||
const labelEl = likeButton.querySelector('.channel-action-label');
|
||||
if (labelEl) labelEl.textContent = 'Лайк...';
|
||||
setActionTitle(likeButton, 'Лайк...');
|
||||
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
|
||||
});
|
||||
|
||||
@@ -986,6 +1348,7 @@ function renderPostCard(post, {
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||||
`;
|
||||
setActionTitle(replyButton, 'Ответить');
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
@@ -1005,6 +1368,7 @@ function renderPostCard(post, {
|
||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||
<span class="channel-action-label">Отправить</span>
|
||||
`;
|
||||
setActionTitle(shareButton, 'Отправить');
|
||||
shareButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
@@ -1021,6 +1385,7 @@ function renderPostCard(post, {
|
||||
<span class="channel-action-icon" aria-hidden="true">↪</span>
|
||||
<span class="channel-action-label">Оригинал</span>
|
||||
`;
|
||||
setActionTitle(originalBtn, 'Оригинал');
|
||||
originalBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName);
|
||||
@@ -1035,6 +1400,27 @@ function renderPostCard(post, {
|
||||
});
|
||||
actions.append(originalBtn);
|
||||
}
|
||||
const detailsButton = document.createElement('button');
|
||||
detailsButton.type = 'button';
|
||||
detailsButton.className = 'channel-action-item';
|
||||
detailsButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">⛓</span>
|
||||
<span class="channel-action-label">Данные блокчейна</span>
|
||||
`;
|
||||
setActionTitle(detailsButton, 'Данные блокчейна');
|
||||
detailsButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openBlockchainDetailsModal(buildBlockchainDetails({
|
||||
messageRef: post.messageRef,
|
||||
authorLogin: post.authorLogin,
|
||||
timestampMs: post.timestampMs,
|
||||
text: post.body,
|
||||
raw: post.rawMessage,
|
||||
localNumber: post.localNumber,
|
||||
msgSubType: post.msgSubType,
|
||||
}));
|
||||
});
|
||||
actions.append(detailsButton);
|
||||
if (post.isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
@@ -1048,8 +1434,9 @@ function renderPostCard(post, {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openEditMessageModal({
|
||||
initialText: String(post.body || '').trim() === 'удалено' ? '' : post.body,
|
||||
onSave: async (nextText) => onEdit(post.messageRef, nextText, { isDelete: false }),
|
||||
initialText: String(post.body || '').trim() === 'удалено' ? '' : parsedBody.text,
|
||||
allowEmptyText: parsedBody.attachments.length > 0,
|
||||
onSave: async (nextText) => onEdit(post.messageRef, composeMessageWithAttachments(nextText, parsedBody.attachments), { isDelete: false }),
|
||||
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
|
||||
});
|
||||
});
|
||||
@@ -1087,10 +1474,34 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
const feed = document.createElement('div');
|
||||
feed.className = 'stack channel-feed';
|
||||
const postsByKey = new Map();
|
||||
const metaEvents = (Array.isArray(channelData.metaEvents) ? channelData.metaEvents : [])
|
||||
.map((event) => mapChannelMetaEvent(event, channelData.channel));
|
||||
const feedItems = [
|
||||
...metaEvents.map((event) => ({
|
||||
type: 'meta',
|
||||
timestampMs: event.timestampMs,
|
||||
blockNumber: Number(event?.messageRef?.blockNumber || 0),
|
||||
event,
|
||||
})),
|
||||
...channelData.posts.map((post) => ({
|
||||
type: 'post',
|
||||
timestampMs: post.timestampMs,
|
||||
blockNumber: Number(post?.messageRef?.blockNumber || 0),
|
||||
post,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
const byTime = Number(a.timestampMs || 0) - Number(b.timestampMs || 0);
|
||||
if (byTime !== 0) return byTime;
|
||||
return Number(a.blockNumber || 0) - Number(b.blockNumber || 0);
|
||||
});
|
||||
|
||||
if (channelData.posts.length) {
|
||||
channelData.posts.forEach((post) => {
|
||||
const row = renderPostCard(post, {
|
||||
if (feedItems.length) {
|
||||
feedItems.forEach((item) => {
|
||||
if (item.type === 'meta') {
|
||||
feed.append(renderChannelMetaEventCard(item.event));
|
||||
return;
|
||||
}
|
||||
const row = renderPostCard(item.post, {
|
||||
navigate,
|
||||
selector: channelData.selector,
|
||||
onToggleLike: handlers.onToggleLike,
|
||||
@@ -1099,9 +1510,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
onShare: handlers.onShare,
|
||||
onEdit: handlers.onEdit,
|
||||
});
|
||||
const key = messageRefKey(post.messageRef);
|
||||
const key = messageRefKey(item.post.messageRef);
|
||||
if (key) {
|
||||
postsByKey.set(key, post);
|
||||
postsByKey.set(key, item.post);
|
||||
}
|
||||
feed.append(row);
|
||||
});
|
||||
@@ -1349,6 +1760,25 @@ export function render({ navigate, route }) {
|
||||
rerender();
|
||||
};
|
||||
|
||||
const onEditChannelMeta = async ({ title, description, avatar }) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
|
||||
throw new Error('Идентификатор канала не готов.');
|
||||
}
|
||||
await authService.addBlockChannelMeta({
|
||||
login,
|
||||
storagePwd,
|
||||
channel: activeSelector,
|
||||
title,
|
||||
description,
|
||||
avatar,
|
||||
});
|
||||
if (avatar?.ar) markArweaveAttachmentPlaced(login, avatar);
|
||||
softHaptic(12);
|
||||
showToast('Профиль канала обновлён');
|
||||
rerender();
|
||||
};
|
||||
|
||||
screen.append(header);
|
||||
screen.append(statusBox);
|
||||
|
||||
@@ -1360,14 +1790,19 @@ export function render({ navigate, route }) {
|
||||
try {
|
||||
const apiData = await loadFromApi(route, channelId);
|
||||
activeSelector = apiData?.selector || null;
|
||||
const channelRouteLabel = `Канал: ${apiData?.channel?.ownerName || 'owner'} / ${apiData?.channel?.name || 'channel'}`;
|
||||
const ownChannelLabel = `Ваш канал: ${apiData?.channel?.name || 'channel'}`;
|
||||
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
|
||||
if (channelHeaderButton) {
|
||||
channelHeaderButton.textContent = apiData?.isOwnChannel ? ownChannelLabel : channelRouteLabel;
|
||||
channelHeaderButton.textContent = titleLabel;
|
||||
channelHeaderButton.disabled = false;
|
||||
channelHeaderButton.onclick = (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
openAboutChannelModal(apiData.channel);
|
||||
openAboutChannelModal(apiData.channel, {
|
||||
canEdit: apiData?.isOwnChannel === true,
|
||||
onEdit: () => openEditChannelModal({
|
||||
channel: apiData.channel,
|
||||
onSave: onEditChannelMeta,
|
||||
}),
|
||||
});
|
||||
};
|
||||
}
|
||||
skeleton.remove();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { channels as mockChannels } from '../mock-data.js';
|
||||
import { authService, setChannelsFeed, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { parseMessageAttachments } from '../services/attachment-format.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from '../services/channels-ux.js';
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
|
||||
@@ -20,6 +22,11 @@ const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
|
||||
function cleanChannelMessagePreview(text) {
|
||||
const parsed = parseMessageAttachments(text);
|
||||
return String(parsed.text || '').trim() || (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
|
||||
}
|
||||
|
||||
function isChannelsDemoMode() {
|
||||
try {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
@@ -610,7 +617,7 @@ function mapMockGroups() {
|
||||
ownerBlockchainName: String(channel.ownerName || ''),
|
||||
channelName: String(channel.channelName || channel.title || channel.id),
|
||||
}),
|
||||
messagePreview: channel.lastMessage || 'Ждем ваших начинаний',
|
||||
messagePreview: cleanChannelMessagePreview(channel.lastMessage),
|
||||
isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal',
|
||||
isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal',
|
||||
notificationsEnabled: false,
|
||||
@@ -646,11 +653,13 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
|
||||
const ownerLogin = summary?.channel?.ownerLogin || 'неизвестно';
|
||||
const channelName = summary?.channel?.channelName || '(без названия)';
|
||||
const displayTitle = String(summary?.channel?.displayName || channelName).trim();
|
||||
const channelDescription = String(summary?.channel?.channelDescription || '').trim();
|
||||
const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1);
|
||||
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
||||
const isOwn = bucketKey === 'own';
|
||||
const title = `${ownerLogin} / ${channelName}`;
|
||||
const title = displayTitle || channelName;
|
||||
const technicalLabel = `${ownerLogin} / ${channelName}`;
|
||||
|
||||
return {
|
||||
id: rowId,
|
||||
@@ -659,13 +668,16 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
|
||||
channelRootBlockNumber: Number(summary?.channel?.channelRoot?.blockNumber),
|
||||
channelRootBlockHash: normalizeHash(summary?.channel?.channelRoot?.blockHash),
|
||||
avatar: avatarLetterFromName(channelName),
|
||||
avatar: avatarLetterFromName(displayTitle || channelName),
|
||||
avaAr: String(summary?.channel?.avaAr || '').trim(),
|
||||
title,
|
||||
technicalLabel,
|
||||
channelName,
|
||||
displayTitle,
|
||||
channelDescription,
|
||||
channelTypeCode,
|
||||
channelTypeVersion,
|
||||
messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний',
|
||||
messagePreview: cleanChannelMessagePreview(summary?.lastMessage?.text),
|
||||
messagesCount: Number(summary?.messagesCount || 0),
|
||||
unreadCount: Number(summary?.unreadCount || 0),
|
||||
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0),
|
||||
@@ -962,6 +974,10 @@ function renderChannelMain(channel) {
|
||||
title.className = 'channel-row-title';
|
||||
title.textContent = channel.title;
|
||||
|
||||
const technical = document.createElement('p');
|
||||
technical.className = 'channel-row-technical';
|
||||
technical.textContent = channel.technicalLabel || `${channel.ownerName || ''} / ${channel.channelName || ''}`;
|
||||
|
||||
if (channel.channelDescription) {
|
||||
const desc = document.createElement('p');
|
||||
desc.className = 'channel-row-description';
|
||||
@@ -977,7 +993,7 @@ function renderChannelMain(channel) {
|
||||
meta.className = 'channel-row-owner channel-counter-meta';
|
||||
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
|
||||
|
||||
main.prepend(title);
|
||||
main.prepend(title, technical);
|
||||
main.append(preview, meta);
|
||||
return main;
|
||||
}
|
||||
@@ -1006,7 +1022,14 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'avatar';
|
||||
avatar.textContent = channel.avatar;
|
||||
if (channel.avaAr) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: channel.avaAr });
|
||||
avatar.append(img);
|
||||
} else {
|
||||
avatar.textContent = channel.avatar;
|
||||
}
|
||||
|
||||
const main = renderChannelMain(channel);
|
||||
|
||||
@@ -1016,7 +1039,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'channel-row-time';
|
||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '—';
|
||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
||||
|
||||
const count = document.createElement('span');
|
||||
count.className = 'unread channel-row-count';
|
||||
|
||||
@@ -7,31 +7,13 @@ import {
|
||||
precheckLoginClassOnSolana,
|
||||
} from '../services/solana-register-service.js';
|
||||
import {
|
||||
composePasswordFromWords,
|
||||
emptyPasswordWords,
|
||||
normalizePasswordWords,
|
||||
PASSWORD_MAX_LENGTH,
|
||||
PASSWORD_WORDS_COUNT,
|
||||
} from '../services/password-words.js';
|
||||
import { sha256Text } from '../services/crypto-utils.js';
|
||||
import { openRegistrationFaq } from './registration-faq-view.js';
|
||||
|
||||
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||
// ВРЕМЕННАЯ UI-ЗАГЛУШКА:
|
||||
// пока полноценная логика продажи/выдачи коротких имён не внедрена on-chain,
|
||||
// UI пускает логины 5..7 символов только по временному коду.
|
||||
const TEMP_MIN_LOGIN_WITHOUT_PROMO = 8;
|
||||
const TEMP_ABSOLUTE_MIN_LOGIN_LEN = 5;
|
||||
const TEMP_PROMO_HASH_HEX = 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3';
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
return Array.from(bytes || [], (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function isTemporaryPromoAccepted(value) {
|
||||
const digest = await sha256Text(String(value || ''));
|
||||
return bytesToHex(digest) === TEMP_PROMO_HASH_HEX;
|
||||
}
|
||||
const MIN_REGISTRATION_LOGIN_LENGTH = 8;
|
||||
|
||||
function normalizeLoginForTemporaryUiGuard(login) {
|
||||
const source = String(login || '').trim();
|
||||
@@ -46,48 +28,6 @@ function normalizeLoginForTemporaryUiGuard(login) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function createWordsLayout({ words, onInput }) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'registration-words-block';
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'registration-words-grid';
|
||||
|
||||
const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'registration-word-row';
|
||||
|
||||
const number = document.createElement('span');
|
||||
number.className = 'registration-word-number';
|
||||
number.textContent = `${index + 1}.`;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'input registration-word-input';
|
||||
input.type = 'text';
|
||||
input.autocomplete = 'off';
|
||||
input.autocapitalize = 'off';
|
||||
input.spellcheck = false;
|
||||
input.maxLength = 32;
|
||||
input.value = words[index];
|
||||
input.addEventListener('input', () => onInput(index, input.value));
|
||||
|
||||
row.append(number, input);
|
||||
grid.append(row);
|
||||
return input;
|
||||
});
|
||||
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'meta-muted';
|
||||
hint.textContent =
|
||||
'Здесь можно ввести любые слова на любых языках. Мы не проверяем орфографию. Можно заполнить все 12 полей или только часть. В конце всё склеивается в один пароль длиной до 256 символов.';
|
||||
|
||||
const preview = document.createElement('p');
|
||||
preview.className = 'status-line';
|
||||
|
||||
section.append(grid, hint);
|
||||
return { section, inputs, preview };
|
||||
}
|
||||
|
||||
function makePasswordToggleIcons() {
|
||||
return {
|
||||
eye: `
|
||||
@@ -115,10 +55,11 @@ export function render({ navigate }) {
|
||||
const form = document.createElement('div');
|
||||
form.className = 'card stack registration-form';
|
||||
|
||||
let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
|
||||
let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords);
|
||||
let passwordWordsLinked = Boolean(state.registrationDraft.passwordWordsLinked);
|
||||
let usePromoCode = Boolean(state.registrationDraft.usePromoCode);
|
||||
state.registrationDraft.passwordMode = 'single';
|
||||
state.registrationDraft.passwordWords = emptyPasswordWords();
|
||||
state.registrationDraft.passwordWordsLinked = false;
|
||||
state.registrationDraft.usePromoCode = false;
|
||||
state.registrationDraft.promoCode = '';
|
||||
let loginCheckTimer = 0;
|
||||
let loginCheckRunId = 0;
|
||||
|
||||
@@ -155,67 +96,6 @@ export function render({ navigate }) {
|
||||
passwordInputRow.className = 'inline-input-row';
|
||||
passwordInputRow.append(passwordInput, passwordToggleButton);
|
||||
|
||||
const {
|
||||
section: wordsSection,
|
||||
inputs: wordInputs,
|
||||
preview: wordsPreview,
|
||||
} = createWordsLayout({
|
||||
words: passwordWords,
|
||||
onInput: (index, value) => {
|
||||
passwordWords[index] = value;
|
||||
passwordInput.value = composePasswordFromWords(passwordWords);
|
||||
passwordWordsLinked = true;
|
||||
syncDraftState();
|
||||
updateWordsPreview();
|
||||
},
|
||||
});
|
||||
|
||||
const passwordModeToggle = document.createElement('label');
|
||||
passwordModeToggle.className = 'registration-toggle';
|
||||
|
||||
const passwordModeCheckbox = document.createElement('input');
|
||||
passwordModeCheckbox.type = 'checkbox';
|
||||
passwordModeCheckbox.checked = passwordMode === 'words';
|
||||
|
||||
const passwordModeLabel = document.createElement('span');
|
||||
passwordModeLabel.textContent = 'Представить пароль в виде 12 слов';
|
||||
|
||||
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
|
||||
|
||||
const promoToggle = document.createElement('label');
|
||||
promoToggle.className = 'registration-toggle';
|
||||
|
||||
const promoCheckbox = document.createElement('input');
|
||||
promoCheckbox.type = 'checkbox';
|
||||
promoCheckbox.checked = usePromoCode;
|
||||
|
||||
const promoToggleLabel = document.createElement('span');
|
||||
promoToggleLabel.textContent = 'У меня есть промокод';
|
||||
|
||||
promoToggle.append(promoCheckbox, promoToggleLabel);
|
||||
|
||||
const promoField = document.createElement('label');
|
||||
promoField.className = 'stack';
|
||||
|
||||
const promoFieldLabel = document.createElement('span');
|
||||
promoFieldLabel.className = 'field-label';
|
||||
promoFieldLabel.textContent = 'Промокод';
|
||||
|
||||
const promoInput = document.createElement('input');
|
||||
promoInput.className = 'input';
|
||||
promoInput.type = 'text';
|
||||
promoInput.autocomplete = 'off';
|
||||
promoInput.autocapitalize = 'off';
|
||||
promoInput.spellcheck = false;
|
||||
promoInput.value = String(state.registrationDraft.promoCode || '');
|
||||
promoInput.placeholder = 'Вставьте промокод';
|
||||
|
||||
const promoHint = document.createElement('p');
|
||||
promoHint.className = 'meta-muted';
|
||||
promoHint.textContent = 'Временный режим: логины длиной 5-7 символов доступны только по специальному коду. Любое другое значение считается неверным промокодом.';
|
||||
|
||||
promoField.append(promoFieldLabel, promoInput, promoHint);
|
||||
|
||||
const statusText = document.createElement('p');
|
||||
statusText.className = 'status-line registration-login-status';
|
||||
statusText.style.display = 'none';
|
||||
@@ -239,7 +119,6 @@ export function render({ navigate }) {
|
||||
nextButton.type = 'button';
|
||||
nextButton.textContent = 'Далее';
|
||||
|
||||
let passwordField = null;
|
||||
const passwordLengthText = document.createElement('p');
|
||||
passwordLengthText.className = 'password-length-hint';
|
||||
let lastCheckedLogin = '';
|
||||
@@ -251,13 +130,8 @@ export function render({ navigate }) {
|
||||
return String(passwordInput.value || '');
|
||||
}
|
||||
|
||||
function updateWordsPreview() {
|
||||
const password = passwordMode === 'words'
|
||||
? composePasswordFromWords(passwordWords)
|
||||
: String(passwordInput.value || '');
|
||||
const text = `Итоговая длина пароля: ${password.length} символов.`;
|
||||
wordsPreview.textContent = text;
|
||||
passwordLengthText.textContent = text;
|
||||
function updatePasswordLength() {
|
||||
passwordLengthText.textContent = `Итоговая длина пароля: ${getCurrentPassword().length} символов.`;
|
||||
}
|
||||
|
||||
function setStatusMessage(message, kind = '') {
|
||||
@@ -295,32 +169,14 @@ export function render({ navigate }) {
|
||||
passwordToggleButton.setAttribute('title', reveal ? 'Скрыть пароль' : 'Показать пароль');
|
||||
}
|
||||
|
||||
function updatePasswordModeVisibility() {
|
||||
const wordsMode = passwordMode === 'words';
|
||||
wordsSection.style.display = wordsMode ? 'grid' : 'none';
|
||||
if (passwordField) passwordField.style.display = 'grid';
|
||||
passwordInputRow.style.display = 'grid';
|
||||
updateWordsPreview();
|
||||
}
|
||||
|
||||
function syncDraftState() {
|
||||
state.registrationDraft.login = String(loginInput.value.trim());
|
||||
state.registrationDraft.passwordMode = passwordMode;
|
||||
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
|
||||
state.registrationDraft.passwordWordsLinked = passwordWordsLinked;
|
||||
state.registrationDraft.password = getCurrentPassword();
|
||||
state.registrationDraft.usePromoCode = usePromoCode;
|
||||
state.registrationDraft.promoCode = String(promoInput.value || '').trim();
|
||||
}
|
||||
|
||||
function updatePromoVisibility() {
|
||||
promoField.style.display = usePromoCode ? 'grid' : 'none';
|
||||
}
|
||||
|
||||
async function runAvailabilityCheck({ automatic = false } = {}) {
|
||||
const runId = ++loginCheckRunId;
|
||||
const login = loginInput.value.trim();
|
||||
const promoCode = String(promoInput.value || '').trim();
|
||||
if (!login) {
|
||||
setStatusMessage(automatic ? '' : 'Введите логин');
|
||||
formError.style.display = 'none';
|
||||
@@ -334,28 +190,8 @@ export function render({ navigate }) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalizedLogin.length < TEMP_ABSOLUTE_MIN_LOGIN_LEN) {
|
||||
setStatusMessage(`Логин должен быть не короче ${TEMP_ABSOLUTE_MIN_LOGIN_LEN} символов ❌`, 'is-unavailable');
|
||||
formError.style.display = 'none';
|
||||
return false;
|
||||
}
|
||||
|
||||
const wantsShortLogin = normalizedLogin.length < TEMP_MIN_LOGIN_WITHOUT_PROMO;
|
||||
let promoAccepted = false;
|
||||
if (usePromoCode) {
|
||||
if (!promoCode) {
|
||||
setStatusMessage('Введите промокод ❌', 'is-unavailable');
|
||||
formError.style.display = 'none';
|
||||
return false;
|
||||
}
|
||||
promoAccepted = await isTemporaryPromoAccepted(promoCode);
|
||||
if (!promoAccepted) {
|
||||
setStatusMessage('Неверный промокод ❌', 'is-unavailable');
|
||||
formError.style.display = 'none';
|
||||
return false;
|
||||
}
|
||||
} else if (wantsShortLogin) {
|
||||
setStatusMessage(`Логины короче ${TEMP_MIN_LOGIN_WITHOUT_PROMO} символов временно доступны только по специальному коду ❌`, 'is-unavailable');
|
||||
if (normalizedLogin.length < MIN_REGISTRATION_LOGIN_LENGTH) {
|
||||
setStatusMessage(`Логин должен быть не короче ${MIN_REGISTRATION_LOGIN_LENGTH} символов ❌`, 'is-unavailable');
|
||||
formError.style.display = 'none';
|
||||
return false;
|
||||
}
|
||||
@@ -363,8 +199,6 @@ export function render({ navigate }) {
|
||||
if (login === lastCheckedLogin) {
|
||||
if (!lastCheckedFree) {
|
||||
setStatusMessage('Логин уже занят ❌', 'is-unavailable');
|
||||
} else if (lastCheckedClassName === 'promo') {
|
||||
setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available');
|
||||
} else if (lastCheckedClassName === 'free') {
|
||||
setStatusMessage('Логин свободен ✅', 'is-available');
|
||||
} else if (lastCheckedClassName === 'premium') {
|
||||
@@ -375,7 +209,7 @@ export function render({ navigate }) {
|
||||
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
|
||||
}
|
||||
formError.style.display = 'none';
|
||||
return lastCheckedFree && (lastCheckedClassName === 'free' || lastCheckedClassName === 'promo');
|
||||
return lastCheckedFree && lastCheckedClassName === 'free';
|
||||
}
|
||||
|
||||
setStatusMessage('Проверяем логин...');
|
||||
@@ -388,19 +222,15 @@ export function render({ navigate }) {
|
||||
const isFree = !check.exists;
|
||||
let className = '';
|
||||
if (isFree) {
|
||||
if (promoAccepted) {
|
||||
className = 'promo';
|
||||
} else {
|
||||
try {
|
||||
const precheck = await precheckLoginClassOnSolana({
|
||||
login,
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
});
|
||||
className = precheck.className;
|
||||
} catch (precheckError) {
|
||||
className = 'free';
|
||||
console.warn('Solana login precheck fallback to free', formatSolanaErrorDetails(precheckError));
|
||||
}
|
||||
try {
|
||||
const precheck = await precheckLoginClassOnSolana({
|
||||
login,
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
});
|
||||
className = precheck.className;
|
||||
} catch (precheckError) {
|
||||
className = 'free';
|
||||
console.warn('Solana login precheck fallback to free', formatSolanaErrorDetails(precheckError));
|
||||
}
|
||||
}
|
||||
lastCheckedLogin = login;
|
||||
@@ -408,8 +238,6 @@ export function render({ navigate }) {
|
||||
lastCheckedClassName = className;
|
||||
if (!isFree) {
|
||||
setStatusMessage('Логин уже занят ❌', 'is-unavailable');
|
||||
} else if (className === 'promo') {
|
||||
setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available');
|
||||
} else if (className === 'free') {
|
||||
setStatusMessage('Логин свободен ✅', 'is-available');
|
||||
} else if (className === 'premium') {
|
||||
@@ -420,7 +248,7 @@ export function render({ navigate }) {
|
||||
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
|
||||
}
|
||||
formError.style.display = 'none';
|
||||
return isFree && (className === 'free' || className === 'promo');
|
||||
return isFree && className === 'free';
|
||||
} catch (error) {
|
||||
if (runId !== loginCheckRunId) return false;
|
||||
const base = toUserMessage(error, 'Не удалось проверить логин');
|
||||
@@ -438,65 +266,12 @@ export function render({ navigate }) {
|
||||
});
|
||||
|
||||
passwordInput.addEventListener('input', () => {
|
||||
if (passwordWordsLinked && String(passwordInput.value || '') !== composePasswordFromWords(passwordWords)) {
|
||||
passwordWords = emptyPasswordWords();
|
||||
passwordWordsLinked = false;
|
||||
wordInputs.forEach((input) => {
|
||||
input.value = '';
|
||||
});
|
||||
}
|
||||
syncDraftState();
|
||||
updateWordsPreview();
|
||||
});
|
||||
|
||||
passwordModeCheckbox.addEventListener('change', () => {
|
||||
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
|
||||
if (nextMode === passwordMode) return;
|
||||
if (nextMode === 'words') {
|
||||
if (!passwordWordsLinked) {
|
||||
passwordWords = emptyPasswordWords();
|
||||
wordInputs.forEach((input) => {
|
||||
input.value = '';
|
||||
});
|
||||
} else {
|
||||
passwordInput.value = composePasswordFromWords(passwordWords);
|
||||
}
|
||||
} else if (passwordWordsLinked) {
|
||||
passwordInput.value = composePasswordFromWords(passwordWords);
|
||||
}
|
||||
passwordMode = nextMode;
|
||||
updatePasswordModeVisibility();
|
||||
updateWordsPreview();
|
||||
syncDraftState();
|
||||
});
|
||||
|
||||
promoCheckbox.addEventListener('change', () => {
|
||||
usePromoCode = promoCheckbox.checked;
|
||||
resetLoginCheckState();
|
||||
updatePromoVisibility();
|
||||
syncDraftState();
|
||||
if (usePromoCode) {
|
||||
setStatusMessage('Временный код включён: для логинов 5-7 символов будет локальная проверка', 'is-available');
|
||||
} else {
|
||||
setStatusMessage('');
|
||||
scheduleAvailabilityCheck();
|
||||
}
|
||||
});
|
||||
|
||||
promoInput.addEventListener('input', () => {
|
||||
resetLoginCheckState();
|
||||
syncDraftState();
|
||||
if (usePromoCode) scheduleAvailabilityCheck();
|
||||
updatePasswordLength();
|
||||
});
|
||||
|
||||
nextButton.addEventListener('click', async () => {
|
||||
formError.style.display = 'none';
|
||||
const promoCode = String(promoInput.value || '').trim();
|
||||
if (usePromoCode && !promoCode) {
|
||||
formError.textContent = 'Если включён временный код, поле должно быть заполнено.';
|
||||
formError.style.display = '';
|
||||
return;
|
||||
}
|
||||
const isFree = await runAvailabilityCheck();
|
||||
if (!isFree) return;
|
||||
|
||||
@@ -518,11 +293,6 @@ export function render({ navigate }) {
|
||||
|
||||
state.registrationDraft.login = nextLogin;
|
||||
state.registrationDraft.password = nextPassword;
|
||||
state.registrationDraft.passwordMode = passwordMode;
|
||||
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
|
||||
state.registrationDraft.passwordWordsLinked = passwordWordsLinked;
|
||||
state.registrationDraft.usePromoCode = usePromoCode;
|
||||
state.registrationDraft.promoCode = promoCode;
|
||||
if (credsChanged) {
|
||||
state.registrationDraft.preGeneratedKeyBundle = null;
|
||||
}
|
||||
@@ -539,14 +309,12 @@ export function render({ navigate }) {
|
||||
passwordLabel.className = 'stack registration-password-single';
|
||||
passwordLabel.innerHTML = '<span class="field-label">Пароль</span>';
|
||||
form.append(loginField, statusText, passwordLabel);
|
||||
passwordField = passwordLabel;
|
||||
loginField.append(loginInput);
|
||||
passwordField.append(passwordInputRow);
|
||||
form.append(passwordModeToggle, promoToggle, promoField, passwordLengthText, wordsSection, formError, faqButton);
|
||||
passwordLabel.append(passwordInputRow);
|
||||
form.append(passwordLengthText, formError, faqButton);
|
||||
actions.innerHTML = '';
|
||||
actions.append(nextButton);
|
||||
updatePasswordModeVisibility();
|
||||
updatePromoVisibility();
|
||||
updatePasswordLength();
|
||||
syncDraftState();
|
||||
}
|
||||
|
||||
|
||||
@@ -62,17 +62,7 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
paragraphs: [
|
||||
'Минимально разумный уровень сейчас начинается примерно от 8 символов.',
|
||||
'Хороший практический ориентир для большинства людей: 12 символов и больше. Пароль у нас может быть длиной до 256 символов.',
|
||||
'Если вам удобнее думать словами, можно использовать режим из 12 полей ниже: слова просто склеиваются в один длинный пароль, и система не проверяет орфографию.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'one-or-twelve',
|
||||
shortTitle: '1 или 12 слов',
|
||||
title: 'Чем отличается один пароль от режима 12 слов?',
|
||||
paragraphs: [
|
||||
'Технически ничем: это один и тот же пароль. Режим 12 слов нужен только для удобства запоминания и ввода.',
|
||||
'Можно заполнить все 12 полей, можно только первые 6, можно использовать слова от другого кошелька, разные языки и любые нестандартные символы.',
|
||||
'Главное помнить, что в конце всё равно получается одна строка длиной до 256 символов.',
|
||||
'Используйте уникальный пароль, который не применяется в других сервисах.',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import { renderHeader } from '../components/header.js';
|
||||
import { saveEntrySettings, state } from '../state.js';
|
||||
import { checkServerAvailabilityByKey, resolveAndCheckShineServerLogin } from '../services/server-health-service.js';
|
||||
|
||||
export const pageMeta = { id: 'server-settings-view', title: 'Настройки серверов' };
|
||||
export const pageMeta = { id: 'server-settings-view', title: 'Серверы блокчейнов' };
|
||||
|
||||
const SERVER_FIELDS = [
|
||||
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
|
||||
@@ -26,6 +26,16 @@ export function render({ navigate }) {
|
||||
|
||||
const timers = new Map();
|
||||
|
||||
const introCard = document.createElement('div');
|
||||
introCard.className = 'card stack';
|
||||
introCard.innerHTML = `
|
||||
<p class="field-label">Серверы блокчейнов и публичных данных</p>
|
||||
<p class="meta-muted">
|
||||
Здесь настраиваются Solana, SHiNE и Arweave для чтения публичных данных и доступа к блокчейну.
|
||||
Эти настройки не меняют список личных серверов доступа пользователя.
|
||||
</p>
|
||||
`;
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'card stack';
|
||||
|
||||
@@ -168,7 +178,7 @@ export function render({ navigate }) {
|
||||
await saveEntrySettings(draft);
|
||||
navigate('settings-view');
|
||||
} catch (error) {
|
||||
window.alert(error?.message || 'Не удалось сохранить настройки серверов.');
|
||||
window.alert(error?.message || 'Не удалось сохранить настройки серверов блокчейнов.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -186,9 +196,10 @@ export function render({ navigate }) {
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Настройки серверов',
|
||||
title: 'Серверы блокчейнов',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
introCard,
|
||||
body,
|
||||
actions,
|
||||
help,
|
||||
|
||||
@@ -42,14 +42,33 @@ export function render({ navigate }) {
|
||||
card.innerHTML = `
|
||||
<button class="text-btn" type="button" id="settings-device">Устройства</button>
|
||||
<button class="text-btn" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
|
||||
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
|
||||
<button class="text-btn" type="button" id="settings-access-servers">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Серверы доступа</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Личная переписка, звонки и зашифрованные данные</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="text-btn" type="button" id="settings-blockchain-servers">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Серверы блокчейнов</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="text-btn" type="button" id="settings-arweave-uploads">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Загрузить файлы в блокчейн</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Заранее загрузить файл и выбрать его потом из истории</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
|
||||
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
||||
`;
|
||||
|
||||
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
||||
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
|
||||
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
|
||||
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
|
||||
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
|
||||
card.querySelector('#settings-arweave-uploads').addEventListener('click', () => navigate('arweave-uploads-view'));
|
||||
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
|
||||
|
||||
const signOutBtn = card.querySelector('#settings-signout');
|
||||
|
||||
@@ -29,7 +29,9 @@ const PRETTY_PATHS = new Map([
|
||||
['network-view', 'network'],
|
||||
['notifications-view', 'notifications'],
|
||||
['settings-view', 'settings'],
|
||||
['access-servers-view', 'settings/access-servers'],
|
||||
['server-settings-view', 'settings/servers'],
|
||||
['arweave-uploads-view', 'settings/arweave-uploads'],
|
||||
['developer-settings-view', 'settings/developer'],
|
||||
['trusted-device-login-settings-view', 'settings/device-login'],
|
||||
['language-view', 'settings/language'],
|
||||
@@ -258,7 +260,9 @@ export function getRoute() {
|
||||
|
||||
if (pageId === 'settings') {
|
||||
const sub = decodePart(segments[1] || '').toLowerCase();
|
||||
if (sub === 'access-servers') return { pageId: 'access-servers-view', params: {} };
|
||||
if (sub === 'servers') return { pageId: 'server-settings-view', params: {} };
|
||||
if (sub === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} };
|
||||
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
|
||||
if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} };
|
||||
if (sub === 'language') return { pageId: 'language-view', params: {} };
|
||||
@@ -368,8 +372,10 @@ export function resolveToolbarActive(pageId) {
|
||||
pageId === 'profile-edit-view' ||
|
||||
pageId === 'wallet-view' ||
|
||||
pageId === 'settings-view' ||
|
||||
pageId === 'access-servers-view' ||
|
||||
pageId === 'developer-settings-view' ||
|
||||
pageId === 'server-settings-view' ||
|
||||
pageId === 'arweave-uploads-view' ||
|
||||
pageId === 'remote-addblock-session-view' ||
|
||||
pageId === 'device-view' ||
|
||||
pageId === 'connect-device-view' ||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const DEFAULT_ARWEAVE_GATEWAY = 'https://arweave.net';
|
||||
const WINSTON_PER_AR = 1_000_000_000_000n;
|
||||
const MAX_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES = 50 * 1024 * 1024;
|
||||
const MAX_AVATAR_SIDE_PX = 768;
|
||||
const AVATAR_QUALITY = 0.86;
|
||||
const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/;
|
||||
@@ -268,12 +269,42 @@ export async function prepareAvatarImageFile(file) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
|
||||
export async function loadArweaveFileMetadata({ gateway, txId, maxBytes = MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES } = {}) {
|
||||
const url = buildArweaveDataUrl({ gateway, txId });
|
||||
const limit = Math.max(1, Number(maxBytes || MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES));
|
||||
const response = await fetch(url, { method: 'GET', cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Не удалось скачать файл Arweave (${response.status} ${response.statusText})`);
|
||||
}
|
||||
|
||||
const lengthHeader = String(response.headers.get('content-length') || '').trim();
|
||||
const declaredSize = Number(lengthHeader);
|
||||
if (Number.isFinite(declaredSize) && declaredSize > limit) {
|
||||
throw new Error(`Файл слишком большой для локальной проверки. Максимум ${Math.round(limit / 1024 / 1024)} MB.`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength > limit) {
|
||||
throw new Error(`Файл слишком большой для локальной проверки. Максимум ${Math.round(limit / 1024 / 1024)} MB.`);
|
||||
}
|
||||
|
||||
const cleanType = String(response.headers.get('content-type') || '').split(';')[0].trim();
|
||||
const sha256Hex = await sha256HexFromArrayBuffer(buffer);
|
||||
return {
|
||||
txId: String(txId || '').trim(),
|
||||
sizeBytes: buffer.byteLength,
|
||||
sha256Hex,
|
||||
contentType: cleanType,
|
||||
gateway: normalizeGateway(gateway),
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadArweaveFile({ gateway, jwk, file, tags = [], shineType = 'avatar' }) {
|
||||
if (!jwk || typeof jwk !== 'object') {
|
||||
throw new Error('Arweave-кошелёк не инициализирован.');
|
||||
}
|
||||
if (!(file instanceof File)) {
|
||||
throw new Error('Выберите файл изображения.');
|
||||
throw new Error('Выберите файл.');
|
||||
}
|
||||
|
||||
const normalizedGateway = normalizeGateway(gateway);
|
||||
@@ -287,14 +318,13 @@ export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
|
||||
const tx = await arweave.createTransaction({ data }, jwk);
|
||||
tx.addTag('Content-Type', String(file.type || 'application/octet-stream'));
|
||||
tx.addTag('App-Name', 'SHiNE');
|
||||
tx.addTag('SHiNE-Type', 'avatar');
|
||||
tx.addTag('SHiNE-Type', String(shineType || 'file'));
|
||||
const extraTags = Array.isArray(tags) ? tags : [];
|
||||
const profileLoginTag = extraTags.find((item) => String(item?.name || '').trim() === 'SHiNE-Profile-Login');
|
||||
const profileLogin = String(profileLoginTag?.value || '').trim();
|
||||
if (!profileLogin) {
|
||||
throw new Error('Не указан логин профиля для тега SHiNE-Profile-Login');
|
||||
}
|
||||
tx.addTag('SHiNE-Profile-Login', profileLogin);
|
||||
extraTags.forEach((item) => {
|
||||
const name = String(item?.name || '').trim();
|
||||
const value = String(item?.value || '').trim();
|
||||
if (name && value) tx.addTag(name, value);
|
||||
});
|
||||
|
||||
await arweave.transactions.sign(tx, jwk);
|
||||
const postResult = await arweave.transactions.post(tx);
|
||||
|
||||
@@ -93,6 +93,53 @@ function pickCachedWallet(secrets) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExtraWallets(secrets) {
|
||||
const rows = Array.isArray(secrets?.arweaveWallets) ? secrets.arweaveWallets : [];
|
||||
return rows
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
id: String(item.id || item.address || '').trim(),
|
||||
label: String(item.label || '').trim(),
|
||||
address: String(item.address || '').trim(),
|
||||
owner: String(item.owner || '').trim(),
|
||||
jwk: item.jwk,
|
||||
createdAtMs: Number(item.createdAtMs || 0),
|
||||
}))
|
||||
.filter((item) => item.id && item.address && item.jwk && typeof item.jwk === 'object');
|
||||
}
|
||||
|
||||
function parseJwkSecretString(secret) {
|
||||
const raw = String(secret || '').trim();
|
||||
if (!raw) throw new Error('Введите секрет Arweave-кошелька.');
|
||||
|
||||
const tryParse = (value) => {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Некорректный JWK Arweave.');
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
if (raw.startsWith('{')) return tryParse(raw);
|
||||
|
||||
const normalized = raw
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/')
|
||||
.padEnd(Math.ceil(raw.length / 4) * 4, '=');
|
||||
try {
|
||||
return tryParse(atob(normalized));
|
||||
} catch {
|
||||
throw new Error('Секрет должен быть JSON JWK или base64url(JSON JWK).');
|
||||
}
|
||||
}
|
||||
|
||||
function validateArweaveJwk(jwk) {
|
||||
if (!jwk || typeof jwk !== 'object') throw new Error('Некорректный JWK Arweave.');
|
||||
['kty', 'e', 'n', 'd', 'p', 'q', 'dp', 'dq', 'qi'].forEach((key) => {
|
||||
if (!String(jwk[key] || '').trim()) throw new Error(`В JWK Arweave нет поля ${key}.`);
|
||||
});
|
||||
}
|
||||
|
||||
function safeStatus(onStatus, text) {
|
||||
if (typeof onStatus !== 'function') return;
|
||||
try {
|
||||
@@ -102,6 +149,63 @@ function safeStatus(onStatus, text) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getArweaveWalletChoices({ login, storagePwd, onStatus } = {}) {
|
||||
const derived = await getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus });
|
||||
const secrets = await loadEncryptedUserSecrets(String(login || '').trim(), String(storagePwd || '').trim());
|
||||
const extras = normalizeExtraWallets(secrets);
|
||||
return [
|
||||
{
|
||||
id: 'derived-client-key',
|
||||
label: 'Стандартный из client key',
|
||||
address: derived.address,
|
||||
owner: derived.owner,
|
||||
jwk: derived.jwk,
|
||||
isDefault: true,
|
||||
},
|
||||
...extras.map((item) => ({
|
||||
...item,
|
||||
label: item.label || `Arweave ${item.address.slice(0, 6)}...${item.address.slice(-6)}`,
|
||||
isDefault: false,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export async function addArweaveWalletSecret({ login, storagePwd, secret, label = '' } = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanPwd = String(storagePwd || '').trim();
|
||||
if (!cleanLogin || !cleanPwd) throw new Error('Нет активной сессии.');
|
||||
|
||||
const jwk = parseJwkSecretString(secret);
|
||||
validateArweaveJwk(jwk);
|
||||
|
||||
const moduleRef = await loadArweaveLib();
|
||||
const Arweave = moduleRef?.default || moduleRef;
|
||||
const arweave = Arweave.init({ host: 'arweave.net', port: 443, protocol: 'https' });
|
||||
const address = await arweave.wallets.jwkToAddress(jwk);
|
||||
const owner = String(jwk.n || '').trim();
|
||||
const id = `manual-${address}`;
|
||||
const createdAtMs = Date.now();
|
||||
const wallet = {
|
||||
id,
|
||||
label: String(label || '').trim() || `Arweave ${address.slice(0, 6)}...${address.slice(-6)}`,
|
||||
address,
|
||||
owner,
|
||||
jwk,
|
||||
createdAtMs,
|
||||
};
|
||||
|
||||
await updateEncryptedUserSecrets(cleanLogin, cleanPwd, (current) => {
|
||||
const wallets = normalizeExtraWallets(current).filter((item) => item.address !== address);
|
||||
wallets.push(wallet);
|
||||
return {
|
||||
...current,
|
||||
arweaveWallets: wallets,
|
||||
};
|
||||
});
|
||||
|
||||
return wallet;
|
||||
}
|
||||
|
||||
export async function getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus } = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanPwd = String(storagePwd || '').trim();
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js';
|
||||
|
||||
const ATTACH_PREFIX = '<SHiNE:attach;';
|
||||
const ATTACH_BLOCK_RE = /^<SHiNE:attach;([^>]*)>\n?/u;
|
||||
export const MAX_MESSAGE_ATTACHMENTS = 10;
|
||||
const RECENT_UNAVAILABLE_MS = 20 * 60 * 1000;
|
||||
const IMAGE_EXTENSIONS = new Set(['apng', 'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp']);
|
||||
const VIDEO_EXTENSIONS = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'ogg', 'ogv', 'webm']);
|
||||
|
||||
export function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function formatBytes(bytes) {
|
||||
const value = Number(bytes || 0);
|
||||
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function normalizeName(name) {
|
||||
const clean = String(name || 'file')
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||
.replace(/[\\/]/g, '_')
|
||||
.trim();
|
||||
return clean || 'file';
|
||||
}
|
||||
|
||||
function normalizePreview(input = {}) {
|
||||
const previewTxId = String(input.previewAr || input.ar || input.txId || '').trim();
|
||||
const previewSha256Hex = String(input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
||||
if (!previewTxId || !previewSha256Hex) return null;
|
||||
if (!validateArweaveTxId(previewTxId)) return null;
|
||||
if (!validateSha256Hex(previewSha256Hex)) return null;
|
||||
return {
|
||||
ar: previewTxId,
|
||||
sha256: previewSha256Hex,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAttachment(input = {}) {
|
||||
const txId = String(input.ar || input.txId || '').trim();
|
||||
const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
||||
const size = Number(input.size || input.sizeBytes || 0);
|
||||
const name = normalizeName(input.name || input.fileName || 'file');
|
||||
const preview = normalizePreview(input.preview || {
|
||||
previewAr: input.previewAr,
|
||||
previewSha256: input.previewSha256,
|
||||
});
|
||||
if (!validateArweaveTxId(txId)) throw new Error('Некорректный Transaction ID Arweave.');
|
||||
if (!validateSha256Hex(sha256Hex)) throw new Error('Некорректный SHA-256 файла.');
|
||||
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер файла.');
|
||||
const out = {
|
||||
v: 1,
|
||||
name,
|
||||
size,
|
||||
sha256: sha256Hex,
|
||||
ar: txId,
|
||||
uploadedAtMs: Number(input.uploadedAtMs || 0) || Date.now(),
|
||||
};
|
||||
if (preview) {
|
||||
out.v = 2;
|
||||
out.preview = preview;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildAttachmentBlock(attachment) {
|
||||
const item = normalizeAttachment(attachment);
|
||||
const encodedName = encodeURIComponent(item.name);
|
||||
const previewFields = item.preview
|
||||
? `;previewAr=${item.preview.ar};previewSha256=${item.preview.sha256}`
|
||||
: '';
|
||||
return `<SHiNE:attach;v=${item.preview ? 2 : 1};name=${encodedName};size=${item.size};sha256=${item.sha256};ar=${item.ar}${previewFields}>`;
|
||||
}
|
||||
|
||||
export function composeMessageWithAttachments(text, attachments = []) {
|
||||
const cleanText = String(text || '').trim();
|
||||
const rows = (Array.isArray(attachments) ? attachments : [])
|
||||
.slice(0, MAX_MESSAGE_ATTACHMENTS)
|
||||
.map(buildAttachmentBlock);
|
||||
if (!rows.length) return cleanText;
|
||||
return `${rows.join('\n')}${cleanText ? `\n${cleanText}` : ''}`;
|
||||
}
|
||||
|
||||
function parseFields(rawFields) {
|
||||
const out = {};
|
||||
String(rawFields || '').split(';').forEach((part) => {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq <= 0) return;
|
||||
const key = part.slice(0, eq).trim();
|
||||
const value = part.slice(eq + 1).trim();
|
||||
if (key) out[key] = value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseMessageAttachments(rawText) {
|
||||
let rest = String(rawText || '');
|
||||
const attachments = [];
|
||||
|
||||
while (rest.startsWith(ATTACH_PREFIX)) {
|
||||
const match = rest.match(ATTACH_BLOCK_RE);
|
||||
if (!match) break;
|
||||
const fields = parseFields(match[1]);
|
||||
try {
|
||||
attachments.push(normalizeAttachment({
|
||||
name: decodeURIComponent(String(fields.name || 'file')),
|
||||
size: fields.size,
|
||||
sha256: fields.sha256,
|
||||
ar: fields.ar,
|
||||
previewAr: fields.previewAr,
|
||||
previewSha256: fields.previewSha256,
|
||||
}));
|
||||
} catch {
|
||||
// Битый attach-блок скрываем из UI, но не ломаем отображение текста.
|
||||
}
|
||||
rest = rest.slice(match[0].length);
|
||||
}
|
||||
|
||||
return {
|
||||
attachments,
|
||||
text: rest.replace(/^\n+/u, ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAttachmentExtension(name) {
|
||||
const clean = String(name || '').trim().toLowerCase();
|
||||
const dot = clean.lastIndexOf('.');
|
||||
if (dot < 0 || dot === clean.length - 1) return '';
|
||||
return clean.slice(dot + 1).replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
export function getAttachmentKind(attachment = {}) {
|
||||
const ext = getAttachmentExtension(attachment.name);
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return 'image';
|
||||
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function isRecentMessage(messageTimestampMs) {
|
||||
const ts = Number(messageTimestampMs || 0);
|
||||
if (!Number.isFinite(ts) || ts <= 0) return false;
|
||||
return Date.now() - ts < RECENT_UNAVAILABLE_MS;
|
||||
}
|
||||
|
||||
function createUnavailableBlock(messageTimestampMs) {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'message-attachment-unavailable';
|
||||
block.textContent = isRecentMessage(messageTimestampMs)
|
||||
? 'Скорее всего файл недоступен, потому что сообщение было создано недавно и файл ещё не распространился в Arweave. Попробуйте повторить через несколько минут.'
|
||||
: 'Файл недоступен.';
|
||||
return block;
|
||||
}
|
||||
|
||||
function stopVideos(root) {
|
||||
root.querySelectorAll('video').forEach((video) => {
|
||||
try {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
} catch {
|
||||
// Если браузер не даёт управлять видео, просто продолжаем перелистывание.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createDownloadLink(url, label = 'Скачать') {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'message-attachment-download';
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
link.download = '';
|
||||
link.textContent = label;
|
||||
link.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
return link;
|
||||
}
|
||||
|
||||
function openAttachmentViewer({ item, url, kind }) {
|
||||
const root = document.getElementById('modal-root') || document.body;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal attachment-viewer-modal';
|
||||
overlay.innerHTML = `
|
||||
<div class="attachment-viewer-card" role="dialog" aria-modal="true">
|
||||
<div class="attachment-viewer-head">
|
||||
<div class="attachment-viewer-title">${escapeHtml(item.name)}</div>
|
||||
<button class="icon-btn attachment-viewer-close" type="button" aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
<div class="attachment-viewer-body"></div>
|
||||
<div class="attachment-viewer-actions"></div>
|
||||
</div>
|
||||
`;
|
||||
const close = () => {
|
||||
stopVideos(overlay);
|
||||
overlay.remove();
|
||||
};
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) close();
|
||||
});
|
||||
overlay.querySelector('.attachment-viewer-close')?.addEventListener('click', close);
|
||||
const body = overlay.querySelector('.attachment-viewer-body');
|
||||
if (kind === 'video') {
|
||||
const video = document.createElement('video');
|
||||
video.className = 'attachment-viewer-media';
|
||||
video.src = url;
|
||||
video.controls = true;
|
||||
video.autoplay = true;
|
||||
body.append(video);
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'attachment-viewer-media';
|
||||
img.src = url;
|
||||
img.alt = item.name;
|
||||
body.append(img);
|
||||
}
|
||||
overlay.querySelector('.attachment-viewer-actions')?.append(createDownloadLink(url));
|
||||
root.append(overlay);
|
||||
}
|
||||
|
||||
function replaceWithUnavailable(slide, messageTimestampMs) {
|
||||
if (!slide || slide.dataset.unavailable === '1') return;
|
||||
slide.dataset.unavailable = '1';
|
||||
slide.replaceChildren(createUnavailableBlock(messageTimestampMs));
|
||||
}
|
||||
|
||||
function createFileCard({ item, url, messageTimestampMs, slide }) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'message-attachment-file-card';
|
||||
card.role = 'button';
|
||||
card.tabIndex = 0;
|
||||
card.title = 'Скачать файл';
|
||||
card.innerHTML = `
|
||||
<div class="message-attachment-file-ext">Файл</div>
|
||||
<div class="message-attachment-file-main">
|
||||
<div class="message-attachment-name">${escapeHtml(item.name)}</div>
|
||||
<div class="message-attachment-meta">${escapeHtml(formatBytes(item.size))}</div>
|
||||
</div>
|
||||
`;
|
||||
const openFile = () => window.open(url, '_blank', 'noopener');
|
||||
card.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openFile();
|
||||
});
|
||||
card.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
openFile();
|
||||
});
|
||||
void fetch(url, { method: 'HEAD', cache: 'no-store' })
|
||||
.then((response) => {
|
||||
if (!response.ok) replaceWithUnavailable(slide, messageTimestampMs);
|
||||
})
|
||||
.catch(() => replaceWithUnavailable(slide, messageTimestampMs));
|
||||
return card;
|
||||
}
|
||||
|
||||
function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl = '' }) {
|
||||
const frame = document.createElement('div');
|
||||
frame.className = `message-attachment-media-frame message-attachment-media-frame--${kind}`;
|
||||
frame.role = 'button';
|
||||
frame.tabIndex = 0;
|
||||
frame.title = kind === 'video' ? 'Открыть видео' : 'Открыть изображение';
|
||||
frame.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openAttachmentViewer({ item, url, kind });
|
||||
});
|
||||
frame.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
openAttachmentViewer({ item, url, kind });
|
||||
});
|
||||
|
||||
if (kind === 'image') {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'message-attachment-media';
|
||||
img.src = url;
|
||||
img.alt = item.name;
|
||||
img.loading = 'lazy';
|
||||
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
|
||||
frame.append(img);
|
||||
} else if (previewUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'message-attachment-media';
|
||||
img.src = previewUrl;
|
||||
img.alt = `${item.name} preview`;
|
||||
img.loading = 'lazy';
|
||||
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
|
||||
const play = document.createElement('span');
|
||||
play.className = 'message-attachment-play';
|
||||
play.textContent = '▶';
|
||||
frame.append(img, play);
|
||||
} else {
|
||||
const video = document.createElement('video');
|
||||
video.className = 'message-attachment-media';
|
||||
video.src = url;
|
||||
video.preload = 'metadata';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
|
||||
const play = document.createElement('span');
|
||||
play.className = 'message-attachment-play';
|
||||
play.textContent = '▶';
|
||||
frame.append(video, play);
|
||||
}
|
||||
|
||||
frame.append(createDownloadLink(url));
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function createAttachmentCarouselElement(attachments = [], { gateway = '', messageTimestampMs = 0 } = {}) {
|
||||
const items = (Array.isArray(attachments) ? attachments : [])
|
||||
.slice(0, MAX_MESSAGE_ATTACHMENTS)
|
||||
.map((raw) => {
|
||||
try {
|
||||
return normalizeAttachment(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'message-attachments message-attachment-carousel';
|
||||
wrap.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
if (!items.length) return wrap;
|
||||
|
||||
let index = 0;
|
||||
let pointerStartX = 0;
|
||||
const viewport = document.createElement('div');
|
||||
viewport.className = 'message-attachment-carousel-viewport';
|
||||
const slide = document.createElement('div');
|
||||
slide.className = 'message-attachment-slide';
|
||||
const prev = document.createElement('button');
|
||||
prev.type = 'button';
|
||||
prev.className = 'message-attachment-arrow message-attachment-arrow--prev';
|
||||
prev.textContent = '‹';
|
||||
prev.setAttribute('aria-label', 'Предыдущее вложение');
|
||||
const next = document.createElement('button');
|
||||
next.type = 'button';
|
||||
next.className = 'message-attachment-arrow message-attachment-arrow--next';
|
||||
next.textContent = '›';
|
||||
next.setAttribute('aria-label', 'Следующее вложение');
|
||||
const counter = document.createElement('div');
|
||||
counter.className = 'message-attachment-counter';
|
||||
|
||||
const render = () => {
|
||||
stopVideos(wrap);
|
||||
const item = items[index];
|
||||
const kind = getAttachmentKind(item);
|
||||
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
|
||||
const previewUrl = item.preview?.ar ? buildArweaveDataUrl({ gateway, txId: item.preview.ar }) : '';
|
||||
slide.dataset.unavailable = '0';
|
||||
slide.replaceChildren();
|
||||
slide.append(kind === 'file'
|
||||
? createFileCard({ item, url, messageTimestampMs, slide })
|
||||
: createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl }));
|
||||
prev.hidden = items.length <= 1 || index <= 0;
|
||||
next.hidden = items.length <= 1 || index >= items.length - 1;
|
||||
counter.textContent = `${index + 1} из ${items.length}`;
|
||||
};
|
||||
|
||||
const move = (delta) => {
|
||||
const nextIndex = Math.max(0, Math.min(items.length - 1, index + delta));
|
||||
if (nextIndex === index) return;
|
||||
index = nextIndex;
|
||||
render();
|
||||
};
|
||||
|
||||
prev.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
move(-1);
|
||||
});
|
||||
next.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
move(1);
|
||||
});
|
||||
viewport.addEventListener('pointerdown', (event) => {
|
||||
pointerStartX = event.clientX;
|
||||
});
|
||||
viewport.addEventListener('pointerup', (event) => {
|
||||
const dx = event.clientX - pointerStartX;
|
||||
if (Math.abs(dx) < 40) return;
|
||||
move(dx < 0 ? 1 : -1);
|
||||
});
|
||||
|
||||
viewport.append(slide, prev, next);
|
||||
wrap.append(viewport, counter);
|
||||
render();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function createAttachmentListElement(attachments = [], options = {}) {
|
||||
return createAttachmentCarouselElement(attachments, options);
|
||||
}
|
||||
@@ -54,6 +54,7 @@ const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
|
||||
const MSG_SUBTYPE_TEXT_REPLY = 20;
|
||||
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
|
||||
const MSG_SUBTYPE_TEXT_REPOST = 30;
|
||||
const MSG_SUBTYPE_TEXT_CHANNEL_META = 70;
|
||||
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
||||
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
||||
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
||||
@@ -64,6 +65,7 @@ const CHANNEL_TYPE_PUBLIC = 1;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const CHANNEL_TYPE_GROUP = 200;
|
||||
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
|
||||
const CREATE_CHANNEL_DESCRIPTION_MAX_BYTES = 2048;
|
||||
const SESSION_TYPE_CLIENT = 1;
|
||||
const SESSION_TYPE_WALLET = 50;
|
||||
const SESSION_TYPE_HOMESERVER = 100;
|
||||
@@ -690,14 +692,47 @@ function makeConnectionBodyBytes({
|
||||
}
|
||||
|
||||
function normalizeChannelDescription(value) {
|
||||
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
|
||||
const text = String(value == null ? '' : value).trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const bytes = utf8Bytes(text);
|
||||
if (bytes.length > 200) {
|
||||
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
||||
if (bytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
|
||||
throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function validateChannelMetaTitle(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (Array.from(text).length > 50) throw new Error('Название канала слишком длинное: максимум 50 символов.');
|
||||
if (/[<>;\t\n\r\u0000]/u.test(text)) {
|
||||
throw new Error('В названии канала нельзя использовать < > ; табуляцию и перевод строки.');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeChannelMetaDescription(value) {
|
||||
const text = String(value == null ? '' : value).trim();
|
||||
if (Array.from(text).length > 250) throw new Error('Описание канала слишком длинное: максимум 250 символов.');
|
||||
return text;
|
||||
}
|
||||
|
||||
function composeChannelMetaText({ title = '', description = '', avatar = null } = {}) {
|
||||
const cleanTitle = validateChannelMetaTitle(title);
|
||||
const cleanDescription = normalizeChannelMetaDescription(description);
|
||||
const rows = [];
|
||||
if (cleanTitle) rows.push(`<SHiNE:title;v=1;${cleanTitle}>`);
|
||||
if (avatar?.ar) {
|
||||
const size = Number(avatar.size || 0);
|
||||
const sha256 = String(avatar.sha256 || '').trim().toLowerCase();
|
||||
const ar = String(avatar.ar || '').trim();
|
||||
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер аватара.');
|
||||
if (!/^[0-9a-f]{64}$/u.test(sha256)) throw new Error('Некорректный SHA-256 аватара.');
|
||||
if (!/^[A-Za-z0-9_-]{43}$/u.test(ar)) throw new Error('Некорректный Arweave txId аватара.');
|
||||
rows.push(`<SHiNE:avatar;v=1;size=${size};sha256=${sha256};ar=${ar}>`);
|
||||
}
|
||||
if (cleanDescription) rows.push(cleanDescription);
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
function validatePersonalChannelName(value) {
|
||||
const normalized = normalizeChannelDisplayName(value);
|
||||
if (!normalized) return { ok: false, error: 'Введите логин пользователя.' };
|
||||
@@ -737,8 +772,8 @@ function makeCreateChannelBodyBytes({
|
||||
}
|
||||
|
||||
const descriptionBytes = utf8Bytes(cleanDescription);
|
||||
if (descriptionBytes.length > 200) {
|
||||
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
||||
if (descriptionBytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
|
||||
throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
|
||||
}
|
||||
const typeVer = Number(channelTypeVersion);
|
||||
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
|
||||
@@ -762,31 +797,6 @@ function makeCreateChannelBodyBytes({
|
||||
);
|
||||
}
|
||||
|
||||
function makeCreateChannelBodyBytesLegacy({
|
||||
lineCode,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
channelName,
|
||||
}) {
|
||||
const check = validatePersonalChannelName(channelName);
|
||||
if (!check.ok) throw new Error(check.error);
|
||||
const cleanName = check.normalized;
|
||||
const nameBytes = utf8Bytes(cleanName);
|
||||
if (nameBytes.length < 1 || nameBytes.length > 255) {
|
||||
throw new Error('Channel name must be 1..255 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(lineCode),
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int8Byte(nameBytes.length),
|
||||
nameBytes,
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
|
||||
const message = String(text || '').trim();
|
||||
if (!message) throw new Error('Message text is required');
|
||||
@@ -806,6 +816,23 @@ function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, this
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextLineBodyBytesAllowEmpty({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
|
||||
const message = String(text || '').trim();
|
||||
const textBytes = utf8Bytes(message);
|
||||
if (textBytes.length > 65535) {
|
||||
throw new Error('Message text must be 0..65535 UTF-8 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(lineCode),
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int16Bytes(textBytes.length),
|
||||
textBytes
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMessageRefTarget(target, actionName = 'action') {
|
||||
const cleanBch = String(target?.blockchainName || '').trim();
|
||||
const cleanBlockNumber = Number(target?.blockNumber);
|
||||
@@ -2033,6 +2060,8 @@ export class AuthService {
|
||||
login,
|
||||
channelName,
|
||||
channelDescription = '',
|
||||
channelProfileTitle = '',
|
||||
channelAvatar = null,
|
||||
channelType = CHANNEL_TYPE_PUBLIC,
|
||||
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
|
||||
storagePwd,
|
||||
@@ -2048,7 +2077,13 @@ export class AuthService {
|
||||
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
|
||||
}
|
||||
const cleanChannelName = normalizeChannelDisplayName(nameCheck.normalized);
|
||||
const cleanChannelDescription = normalizeChannelDescription(channelDescription);
|
||||
const cleanChannelDescription = typeCode === CHANNEL_TYPE_PUBLIC
|
||||
? composeChannelMetaText({
|
||||
title: channelProfileTitle,
|
||||
description: channelDescription,
|
||||
avatar: channelAvatar,
|
||||
})
|
||||
: normalizeChannelDescription(channelDescription);
|
||||
const channelSlug = toCanonicalChannelSlug(cleanChannelName);
|
||||
const typeVersion = Number(channelTypeVersion);
|
||||
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
|
||||
@@ -2080,51 +2115,23 @@ export class AuthService {
|
||||
thisLineNumber = createdChannels.length + 1;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TECH,
|
||||
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
||||
msgVersion: CREATE_CHANNEL_BODY_VERSION,
|
||||
bodyBytes: makeCreateChannelBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
channelName: cleanChannelName,
|
||||
channelDescription: cleanChannelDescription,
|
||||
channelType: typeCode,
|
||||
channelTypeVersion: typeVersion,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
const rawCode = String(error?.code || '').toUpperCase();
|
||||
const rawText = String(error?.message || '').toLowerCase();
|
||||
const isLegacyFormatMismatch = (
|
||||
rawCode === 'BAD_BLOCK_FORMAT' ||
|
||||
rawText.includes('bad_block_format') ||
|
||||
rawText.includes('некорректный формат блока')
|
||||
);
|
||||
if (!isLegacyFormatMismatch) throw error;
|
||||
|
||||
// Совместимость со старыми серверами, где CreateChannel body без description/type.
|
||||
payload = await this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TECH,
|
||||
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
||||
msgVersion: 1,
|
||||
bodyBytes: makeCreateChannelBodyBytesLegacy({
|
||||
lineCode: 0,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
channelName: cleanChannelName,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const payload = await this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TECH,
|
||||
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
||||
msgVersion: CREATE_CHANNEL_BODY_VERSION,
|
||||
bodyBytes: makeCreateChannelBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
channelName: cleanChannelName,
|
||||
channelDescription: cleanChannelDescription,
|
||||
channelType: typeCode,
|
||||
channelTypeVersion: typeVersion,
|
||||
}),
|
||||
});
|
||||
|
||||
const selector = {
|
||||
ownerBlockchainName: blockchainName,
|
||||
@@ -2141,6 +2148,69 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async resolveChannelLineTail({ login, blockchainName, channel }) {
|
||||
const selector = channel || {};
|
||||
const ownerBlockchainName = String(selector?.ownerBlockchainName || '').trim();
|
||||
const lineCode = Number(selector?.channelRootBlockNumber);
|
||||
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
||||
throw new Error('Invalid channel selector');
|
||||
}
|
||||
if (ownerBlockchainName !== blockchainName) {
|
||||
throw new Error('Posting is allowed only to your own channels');
|
||||
}
|
||||
|
||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||||
if (rootHashHex === ZERO64) {
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(login, blockchainName);
|
||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
||||
if (!rootChannel) throw new Error('Channel root not found');
|
||||
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
}
|
||||
|
||||
let prevLineNumber = lineCode;
|
||||
let prevLineHashHex = rootHashHex;
|
||||
let thisLineNumber = 0;
|
||||
try {
|
||||
const latestPayload = await this.getChannelMessages({
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: lineCode,
|
||||
channelRootBlockHash: rootHashHex,
|
||||
}, 1, 'desc', login);
|
||||
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
||||
const latestMeta = (Array.isArray(latestPayload?.metaEvents) ? latestPayload.metaEvents : [])
|
||||
.slice()
|
||||
.sort((a, b) => Number(b?.messageRef?.blockNumber || -1) - Number(a?.messageRef?.blockNumber || -1))[0] || null;
|
||||
const latestMessageBlock = Number(latestMessage?.messageRef?.blockNumber);
|
||||
const latestMetaBlock = Number(latestMeta?.messageRef?.blockNumber);
|
||||
const latest = Number.isFinite(latestMetaBlock) && (!Number.isFinite(latestMessageBlock) || latestMetaBlock > latestMessageBlock)
|
||||
? latestMeta
|
||||
: latestMessage;
|
||||
const latestBlockNumber = Number(latest?.messageRef?.blockNumber);
|
||||
const latestBlockHash = normalizeHex32(latest?.messageRef?.blockHash, '');
|
||||
const latestLineStep = resolveLatestLineStep(latest);
|
||||
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
||||
prevLineNumber = latestBlockNumber;
|
||||
prevLineHashHex = latestBlockHash;
|
||||
thisLineNumber = latest?.kind === 'created'
|
||||
? 0
|
||||
: Number.isFinite(latestLineStep)
|
||||
? Math.max(0, latestLineStep + 1)
|
||||
: (latestBlockNumber === lineCode ? 0 : 1);
|
||||
}
|
||||
} catch {
|
||||
// fallback to root anchor
|
||||
}
|
||||
|
||||
return {
|
||||
ownerBlockchainName,
|
||||
lineCode,
|
||||
rootHashHex,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
async addBlockTextPost({ login, channel, text, storagePwd }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login');
|
||||
@@ -2154,53 +2224,13 @@ export class AuthService {
|
||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
|
||||
const ownerBlockchainName = owner;
|
||||
const lineCode = root;
|
||||
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
||||
throw new Error('Invalid channel selector');
|
||||
}
|
||||
if (ownerBlockchainName !== blockchainName) {
|
||||
throw new Error('Posting is allowed only to your own channels');
|
||||
}
|
||||
|
||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||||
if (rootHashHex === ZERO64) {
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
|
||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
||||
if (!rootChannel) throw new Error('Channel root not found');
|
||||
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
}
|
||||
|
||||
let prevLineNumber = lineCode;
|
||||
let prevLineHashHex = rootHashHex;
|
||||
let thisLineNumber = 0;
|
||||
try {
|
||||
const latestPayload = await this.getChannelMessages({
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: lineCode,
|
||||
channelRootBlockHash: rootHashHex,
|
||||
}, 1, 'desc', cleanLogin);
|
||||
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
||||
const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
|
||||
const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, '');
|
||||
const latestLineStep = resolveLatestLineStep(latestMessage);
|
||||
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
||||
prevLineNumber = latestBlockNumber;
|
||||
prevLineHashHex = latestBlockHash;
|
||||
// Для нового POST берём следующий шаг после последнего сообщения линии.
|
||||
thisLineNumber = Number.isFinite(latestLineStep)
|
||||
? Math.max(0, latestLineStep + 1)
|
||||
: 1;
|
||||
}
|
||||
} catch {
|
||||
// fallback to root anchor
|
||||
}
|
||||
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
|
||||
|
||||
const bodyBytes = makeTextPostBodyBytes({
|
||||
lineCode,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
lineCode: tail.lineCode,
|
||||
prevLineNumber: tail.prevLineNumber,
|
||||
prevLineHashHex: tail.prevLineHashHex,
|
||||
thisLineNumber: tail.thisLineNumber,
|
||||
text: cleanText,
|
||||
});
|
||||
|
||||
@@ -2216,9 +2246,50 @@ export class AuthService {
|
||||
return {
|
||||
...payload,
|
||||
channel: {
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: lineCode,
|
||||
channelRootBlockHash: rootHashHex,
|
||||
ownerBlockchainName: tail.ownerBlockchainName,
|
||||
channelRootBlockNumber: tail.lineCode,
|
||||
channelRootBlockHash: tail.rootHashHex,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockChannelMeta({ login, channel, title = '', description = '', avatar = null, storagePwd }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login');
|
||||
const selector = channel || {};
|
||||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
||||
const root = Number(selector?.channelRootBlockNumber);
|
||||
const metaText = composeChannelMetaText({ title, description, avatar });
|
||||
const key = `channel-meta:${cleanLogin}:${owner}:${root}:${metaText}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
|
||||
const bodyBytes = makeTextLineBodyBytesAllowEmpty({
|
||||
lineCode: tail.lineCode,
|
||||
prevLineNumber: tail.prevLineNumber,
|
||||
prevLineHashHex: tail.prevLineHashHex,
|
||||
thisLineNumber: tail.thisLineNumber,
|
||||
text: metaText,
|
||||
});
|
||||
|
||||
const payload = await this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TEXT,
|
||||
msgSubType: MSG_SUBTYPE_TEXT_CHANNEL_META,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
|
||||
return {
|
||||
...payload,
|
||||
channel: {
|
||||
ownerBlockchainName: tail.ownerBlockchainName,
|
||||
channelRootBlockNumber: tail.lineCode,
|
||||
channelRootBlockHash: tail.rootHashHex,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -2603,8 +2674,12 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async searchUsers(prefix) {
|
||||
const response = await this.ws.request('SearchUsers', { prefix });
|
||||
async searchUsers(prefix, options = {}) {
|
||||
const payload = { prefix };
|
||||
if (typeof options?.isServer === 'boolean') {
|
||||
payload.isServer = options.isServer;
|
||||
}
|
||||
const response = await this.ws.request('SearchUsers', payload);
|
||||
if (response.status !== 200) throw opError('SearchUsers', response);
|
||||
return response.payload?.logins || [];
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ function decodeRoutePart(value = '') {
|
||||
}
|
||||
}
|
||||
|
||||
const SHINE_ROUTE_PREFIX = 'SHiNE.';
|
||||
|
||||
export function normalizeLogin(value = '') {
|
||||
return String(value || '').trim().replace(/^@+/, '');
|
||||
}
|
||||
@@ -23,21 +25,21 @@ export function extractLoginFromBlockchainName(value = '') {
|
||||
|
||||
export function makeProfileRoute(login = '') {
|
||||
const clean = normalizeLogin(login);
|
||||
return clean ? `shine.${encodeRoutePart(clean)}` : 'profile';
|
||||
return clean ? `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}` : 'profile';
|
||||
}
|
||||
|
||||
export function makeProfileLinksRoute(login = '') {
|
||||
const clean = normalizeLogin(login);
|
||||
return clean ? `shine.${encodeRoutePart(clean)}/links` : 'network/keep-history';
|
||||
return clean ? `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}/links` : 'network/keep-history';
|
||||
}
|
||||
|
||||
export function makeProfileChannelsRoute(login = '', scope = 'all') {
|
||||
const clean = normalizeLogin(login);
|
||||
if (!clean) return 'channels/feed';
|
||||
const normalizedScope = String(scope || '').trim().toLowerCase();
|
||||
if (normalizedScope === 'owned') return `shine.${encodeRoutePart(clean)}/channels/owned`;
|
||||
if (normalizedScope === 'following') return `shine.${encodeRoutePart(clean)}/channels/following`;
|
||||
return `shine.${encodeRoutePart(clean)}/channels`;
|
||||
if (normalizedScope === 'owned') return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}/channels/owned`;
|
||||
if (normalizedScope === 'following') return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}/channels/following`;
|
||||
return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(clean)}/channels`;
|
||||
}
|
||||
|
||||
export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '', messageBlockNumber = '' }) {
|
||||
@@ -45,19 +47,19 @@ export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '
|
||||
const chName = String(channelName || '').trim();
|
||||
const msgNo = String(messageBlockNumber || '').trim();
|
||||
if (!ownerBch || !chName) return '';
|
||||
if (msgNo) return `shine.${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
|
||||
return `shine.${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
|
||||
if (msgNo) return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
|
||||
return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
|
||||
}
|
||||
|
||||
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
|
||||
const msgBch = String(messageBlockchainName || '').trim();
|
||||
const msgNo = String(messageBlockNumber || '').trim();
|
||||
if (!msgBch || !msgNo) return '';
|
||||
return `shine.${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
|
||||
return `${SHINE_ROUTE_PREFIX}${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
|
||||
}
|
||||
|
||||
export function parseShineRootSegment(segment = '') {
|
||||
const raw = String(segment || '').trim();
|
||||
if (!raw.toLowerCase().startsWith('shine.')) return '';
|
||||
return normalizeLogin(decodeRoutePart(raw.slice('shine.'.length)));
|
||||
if (!raw.toLowerCase().startsWith(SHINE_ROUTE_PREFIX.toLowerCase())) return '';
|
||||
return normalizeLogin(decodeRoutePart(raw.slice(SHINE_ROUTE_PREFIX.length)));
|
||||
}
|
||||
|
||||
@@ -17,6 +17,34 @@ const BASE58_MAP = (() => {
|
||||
return out;
|
||||
})();
|
||||
|
||||
function encodeBase58(bytesLike) {
|
||||
const bytes = bytesLike instanceof Uint8Array ? bytesLike : Uint8Array.from(bytesLike || []);
|
||||
if (!bytes.length) return '';
|
||||
|
||||
const digits = [0];
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
let carry = bytes[i];
|
||||
for (let j = 0; j < digits.length; j += 1) {
|
||||
const value = (digits[j] << 8) + carry;
|
||||
digits[j] = value % 58;
|
||||
carry = (value / 58) | 0;
|
||||
}
|
||||
while (carry > 0) {
|
||||
digits.push(carry % 58);
|
||||
carry = (carry / 58) | 0;
|
||||
}
|
||||
}
|
||||
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length && bytes[i] === 0; i += 1) {
|
||||
out += BASE58_ALPHABET[0];
|
||||
}
|
||||
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
||||
out += BASE58_ALPHABET[digits[i]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeEndpoint(url) {
|
||||
const raw = String(url || '').trim();
|
||||
if (!raw) return DEFAULT_SOLANA_ENDPOINT;
|
||||
@@ -65,13 +93,27 @@ function decodeBase58(input) {
|
||||
async function keypairFromPkcs8(pkcs8B64) {
|
||||
const solana = await loadSolanaLib();
|
||||
const seed32 = extractClientKey32FromStoredValue(pkcs8B64);
|
||||
return solana.Keypair.fromSeed(seed32);
|
||||
try {
|
||||
return solana.Keypair.fromSeed(seed32);
|
||||
} finally {
|
||||
seed32.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function keypairFromStoredSecret(storedSecret) {
|
||||
const solana = await loadSolanaLib();
|
||||
const seed32 = extractClientKey32FromStoredValue(storedSecret);
|
||||
try {
|
||||
return solana.Keypair.fromSeed(seed32);
|
||||
} finally {
|
||||
seed32.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRandomSolanaWallet() {
|
||||
const solana = await loadSolanaLib();
|
||||
const keypair = solana.Keypair.generate();
|
||||
const privateKey32Base58 = solana.bs58.encode(keypair.secretKey.slice(0, 32));
|
||||
const privateKey32Base58 = encodeBase58(keypair.secretKey.slice(0, 32));
|
||||
return {
|
||||
address: keypair.publicKey.toBase58(),
|
||||
privateKey32Base58,
|
||||
@@ -107,7 +149,7 @@ export async function getWalletFromStoredClientKey({ login, storagePwd }) {
|
||||
if (!clientPrivate) {
|
||||
throw new Error('На устройстве не найден client.key');
|
||||
}
|
||||
const keypair = await keypairFromPkcs8(clientPrivate);
|
||||
const keypair = await keypairFromStoredSecret(clientPrivate);
|
||||
return {
|
||||
address: keypair.publicKey.toBase58(),
|
||||
keypair,
|
||||
@@ -115,6 +157,66 @@ export async function getWalletFromStoredClientKey({ login, storagePwd }) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWalletFromStoredRootKey({ login, storagePwd }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanPwd = String(storagePwd || '').trim();
|
||||
if (!cleanLogin || !cleanPwd) {
|
||||
throw new Error('Нет активной сессии для доступа к root key');
|
||||
}
|
||||
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
|
||||
const rootPrivate = String(secrets?.rootKey || '').trim();
|
||||
if (!rootPrivate) {
|
||||
throw new Error('На устройстве не найден root key');
|
||||
}
|
||||
const keypair = await keypairFromStoredSecret(rootPrivate);
|
||||
return {
|
||||
address: keypair.publicKey.toBase58(),
|
||||
keypair,
|
||||
rootPrivatePkcs8B64: rootPrivate,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStoredSolanaWalletChoices({ login, storagePwd, includeRoot = true } = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanPwd = String(storagePwd || '').trim();
|
||||
if (!cleanLogin || !cleanPwd) {
|
||||
throw new Error('Нет активной сессии для доступа к ключам');
|
||||
}
|
||||
|
||||
const choices = [];
|
||||
const clientWallet = await getWalletFromStoredClientKey({ login: cleanLogin, storagePwd: cleanPwd });
|
||||
choices.push({
|
||||
id: 'client-key',
|
||||
keySource: 'client',
|
||||
label: 'client key',
|
||||
address: clientWallet.address,
|
||||
keypair: clientWallet.keypair,
|
||||
});
|
||||
|
||||
if (includeRoot) {
|
||||
try {
|
||||
const rootWallet = await getWalletFromStoredRootKey({ login: cleanLogin, storagePwd: cleanPwd });
|
||||
choices.push({
|
||||
id: 'root-key',
|
||||
keySource: 'root',
|
||||
label: 'root key',
|
||||
address: rootWallet.address,
|
||||
keypair: rootWallet.keypair,
|
||||
});
|
||||
} catch {
|
||||
// root key на устройстве может отсутствовать, это допустимо
|
||||
}
|
||||
}
|
||||
|
||||
return choices;
|
||||
}
|
||||
|
||||
export async function encodeSolanaSecretKeyBase58(secretKey) {
|
||||
const bytes = secretKey instanceof Uint8Array ? secretKey : Uint8Array.from(secretKey || []);
|
||||
if (!bytes.length) throw new Error('Пустой Solana secret key');
|
||||
return encodeBase58(bytes);
|
||||
}
|
||||
|
||||
export async function getBalanceSol({ endpoint, address }) {
|
||||
const solana = await loadSolanaLib();
|
||||
const rpc = normalizeEndpoint(endpoint);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { getBalanceSol, getStoredSolanaWalletChoices, encodeSolanaSecretKeyBase58, formatSol } from './solana-wallet-service.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
const FREE_TURBO_UPLOAD_BYTES = 100 * 1024;
|
||||
const WINC_PER_CREDIT = 1_000_000_000_000n;
|
||||
|
||||
let turboLibPromise = null;
|
||||
|
||||
function parseSolToLamports(amountSol) {
|
||||
const raw = String(amountSol ?? '').trim().replace(',', '.');
|
||||
const match = raw.match(/^(\d+)(?:\.(\d+))?$/);
|
||||
if (!match) {
|
||||
throw new Error('Сумма пополнения должна быть числом');
|
||||
}
|
||||
const intPart = BigInt(match[1] || '0');
|
||||
const frac = String(match[2] || '');
|
||||
if (frac.length > 9) {
|
||||
throw new Error('Слишком много знаков после запятой для SOL');
|
||||
}
|
||||
const fracPadded = `${frac}${'0'.repeat(9 - frac.length)}`;
|
||||
const lamports = (intPart * 1_000_000_000n) + BigInt(fracPadded || '0');
|
||||
if (lamports <= 0n) {
|
||||
throw new Error('Сумма пополнения должна быть больше 0');
|
||||
}
|
||||
return lamports.toString();
|
||||
}
|
||||
|
||||
function normalizeKeySource(value) {
|
||||
return String(value || '').trim().toLowerCase() === 'root' ? 'root' : 'client';
|
||||
}
|
||||
|
||||
async function loadTurboLib() {
|
||||
if (!turboLibPromise) {
|
||||
turboLibPromise = import('../vendor/turbo-sdk.web.bundle.js');
|
||||
}
|
||||
return turboLibPromise;
|
||||
}
|
||||
|
||||
async function getTurboWalletChoice({ login, storagePwd, keySource = 'client' } = {}) {
|
||||
const normalizedKeySource = normalizeKeySource(keySource);
|
||||
const choices = await getStoredSolanaWalletChoices({ login, storagePwd, includeRoot: true });
|
||||
const selected = choices.find((item) => item.keySource === normalizedKeySource) || choices[0] || null;
|
||||
if (!selected?.keypair) {
|
||||
throw new Error(normalizedKeySource === 'root'
|
||||
? 'На устройстве не найден root key для Turbo.'
|
||||
: 'На устройстве не найден client key для Turbo.');
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function createTurboClientFromWalletChoice(choice) {
|
||||
const moduleRef = await loadTurboLib();
|
||||
const TurboFactory = moduleRef?.TurboFactory;
|
||||
if (!TurboFactory?.authenticated) {
|
||||
throw new Error('Turbo SDK не удалось инициализировать.');
|
||||
}
|
||||
const privateKey = await encodeSolanaSecretKeyBase58(choice.keypair.secretKey);
|
||||
return TurboFactory.authenticated({
|
||||
privateKey,
|
||||
token: 'solana',
|
||||
});
|
||||
}
|
||||
|
||||
function creditsFromWinc(winc) {
|
||||
const value = BigInt(String(winc || '0'));
|
||||
return Number(value) / Number(WINC_PER_CREDIT);
|
||||
}
|
||||
|
||||
function shortAddress(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (raw.length <= 16) return raw;
|
||||
return `${raw.slice(0, 8)}...${raw.slice(-6)}`;
|
||||
}
|
||||
|
||||
export function isFreeTurboUploadSize(byteLength) {
|
||||
const bytes = Number(byteLength || 0);
|
||||
return Number.isFinite(bytes) && bytes > 0 && bytes <= FREE_TURBO_UPLOAD_BYTES;
|
||||
}
|
||||
|
||||
export function getFreeTurboUploadBytesLimit() {
|
||||
return FREE_TURBO_UPLOAD_BYTES;
|
||||
}
|
||||
|
||||
export function formatTurboCredits(value, digits = 6) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
return n.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTurboContextForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) {
|
||||
const choice = await getTurboWalletChoice({ login, storagePwd, keySource });
|
||||
const turbo = await createTurboClientFromWalletChoice(choice);
|
||||
return {
|
||||
turbo,
|
||||
address: choice.address,
|
||||
keySource: choice.keySource,
|
||||
label: choice.label,
|
||||
keypair: choice.keypair,
|
||||
shortAddress: shortAddress(choice.address),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTurboBalanceForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) {
|
||||
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
|
||||
const balance = await ctx.turbo.getBalance(ctx.address);
|
||||
const solanaBalance = await getBalanceSol({
|
||||
endpoint: state.registrationPayment?.endpoint || state.entrySettings?.solanaRpc || undefined,
|
||||
address: ctx.address,
|
||||
});
|
||||
return {
|
||||
...ctx,
|
||||
controlledWinc: String(balance.controlledWinc || '0'),
|
||||
spendableWinc: String(balance.winc || '0'),
|
||||
effectiveWinc: String(balance.effectiveBalance || '0'),
|
||||
controlledCredits: creditsFromWinc(balance.controlledWinc || '0'),
|
||||
spendableCredits: creditsFromWinc(balance.winc || '0'),
|
||||
effectiveCredits: creditsFromWinc(balance.effectiveBalance || '0'),
|
||||
solBalance: solanaBalance.sol,
|
||||
solLamports: solanaBalance.lamports,
|
||||
};
|
||||
}
|
||||
|
||||
export async function estimateTurboUploadPrice({ login, storagePwd, keySource = 'client', byteLength } = {}) {
|
||||
const bytes = Number(byteLength);
|
||||
if (!Number.isInteger(bytes) || bytes <= 0) {
|
||||
throw new Error('Некорректный размер файла для Turbo.');
|
||||
}
|
||||
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
|
||||
const rows = await ctx.turbo.getUploadCosts({ bytes: [bytes] });
|
||||
const price = Array.isArray(rows) ? rows[0] : null;
|
||||
if (!price) {
|
||||
throw new Error('Turbo не вернул цену загрузки.');
|
||||
}
|
||||
const winc = String(price.winc || '0');
|
||||
return {
|
||||
...ctx,
|
||||
byteLength: bytes,
|
||||
isFree: isFreeTurboUploadSize(bytes),
|
||||
winc,
|
||||
credits: creditsFromWinc(winc),
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadFileWithTurbo({
|
||||
login,
|
||||
storagePwd,
|
||||
keySource = 'client',
|
||||
file,
|
||||
tags = [],
|
||||
shineType = 'attachment',
|
||||
onProgress,
|
||||
} = {}) {
|
||||
if (!(file instanceof File)) {
|
||||
throw new Error('Выберите файл для загрузки через Turbo.');
|
||||
}
|
||||
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
|
||||
const result = await ctx.turbo.uploadFile({
|
||||
file,
|
||||
dataItemOpts: {
|
||||
tags: [
|
||||
{ name: 'Content-Type', value: String(file.type || 'application/octet-stream') },
|
||||
{ name: 'App-Name', value: 'SHiNE' },
|
||||
{ name: 'SHiNE-Type', value: String(shineType || 'attachment') },
|
||||
...((Array.isArray(tags) ? tags : []).filter((item) => item?.name && item?.value)),
|
||||
],
|
||||
},
|
||||
events: typeof onProgress === 'function'
|
||||
? {
|
||||
onProgress: ({ totalBytes, processedBytes, step }) => {
|
||||
onProgress({
|
||||
totalBytes,
|
||||
processedBytes,
|
||||
step,
|
||||
});
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
...ctx,
|
||||
id: String(result?.id || '').trim(),
|
||||
owner: String(result?.owner || '').trim(),
|
||||
winc: String(result?.winc || '0'),
|
||||
credits: creditsFromWinc(result?.winc || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function topUpTurboWithSolana({
|
||||
login,
|
||||
storagePwd,
|
||||
keySource = 'client',
|
||||
amountSol,
|
||||
turboCreditDestinationAddress = '',
|
||||
} = {}) {
|
||||
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
|
||||
const tokenAmount = parseSolToLamports(amountSol);
|
||||
const result = await ctx.turbo.topUpWithTokens({
|
||||
tokenAmount,
|
||||
turboCreditDestinationAddress: String(turboCreditDestinationAddress || '').trim() || undefined,
|
||||
});
|
||||
return {
|
||||
...ctx,
|
||||
amountSol: formatSol(amountSol, 9),
|
||||
lamports: tokenAmount,
|
||||
txId: String(result?.id || '').trim(),
|
||||
target: String(result?.target || '').trim(),
|
||||
rewardWinc: String(result?.reward || '0'),
|
||||
rewardCredits: creditsFromWinc(result?.reward || '0'),
|
||||
};
|
||||
}
|
||||
+423
File diff suppressed because one or more lines are too long
+916
-25
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
# Черновик `TEXT_CHANNEL_META` для каналов SHiNE
|
||||
|
||||
## Статус документа
|
||||
|
||||
Этот файл является рабочим черновиком и не считается финальной спецификацией.
|
||||
|
||||
Важно:
|
||||
|
||||
- это не окончательный формат;
|
||||
- точная версия должна быть зафиксирована отдельно при начале реализации;
|
||||
- до реализации допустимы изменения синтаксиса, ограничений и полей БД;
|
||||
- документ временно лежит в корне проекта как согласованный draft.
|
||||
|
||||
## Назначение
|
||||
|
||||
Нужен специальный скрытый тип текстового сообщения канала, который:
|
||||
|
||||
- технически хранится в блокчейне как `TEXT`-блок;
|
||||
- не показывается пользователям как обычное сообщение ленты;
|
||||
- может писать только владелец канала;
|
||||
- не редактируется;
|
||||
- задаёт полное текущее состояние метаданных канала.
|
||||
|
||||
Через этот блок должны обновляться:
|
||||
|
||||
- человекочитаемое заглавие канала;
|
||||
- аватар канала;
|
||||
- описание канала.
|
||||
|
||||
## Место в формате блокчейна
|
||||
|
||||
- `msg_type = 1`
|
||||
- `subType = 70`
|
||||
- имя подтипа: `TEXT_CHANNEL_META`
|
||||
|
||||
Этот подтип относится к текстовым блокам, но трактуется как скрытое техническое сообщение канала.
|
||||
|
||||
## Общая модель применения
|
||||
|
||||
`TEXT_CHANNEL_META`:
|
||||
|
||||
- пишется в линию конкретного канала;
|
||||
- задаёт полный снимок метаданных;
|
||||
- при чтении канала сервер берёт последний валидный `TEXT_CHANNEL_META` в линии;
|
||||
- более старые `TEXT_CHANNEL_META` остаются в истории блокчейна, но не участвуют в вычислении текущего состояния;
|
||||
- если meta-блок не найден, используются базовые данные канала без override-полей.
|
||||
|
||||
## Общий формат текста
|
||||
|
||||
Внутри `TEXT_CHANNEL_META` текст строится так:
|
||||
|
||||
1. в начале идут технические теги `<SHiNE:...>`;
|
||||
2. после тегов идёт обычный UTF-8 текст;
|
||||
3. этот хвостовой текст считается описанием канала.
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
<SHiNE:title;v=1;Моё имя канала>
|
||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
||||
Тут описание канала.
|
||||
```
|
||||
|
||||
## Поддерживаемые теги
|
||||
|
||||
### 1. Тег заглавия
|
||||
|
||||
Формат:
|
||||
|
||||
```text
|
||||
<SHiNE:title;v=1;Моё имя канала>
|
||||
```
|
||||
|
||||
Смысл:
|
||||
|
||||
- задаёт красивое отображаемое имя канала;
|
||||
- хранится как обычный UTF-8 текст;
|
||||
- это не технический slug и не системное имя.
|
||||
|
||||
### 2. Тег аватара
|
||||
|
||||
Формат:
|
||||
|
||||
```text
|
||||
<SHiNE:avatar;v=1;size=248193;sha256=3f2c8a...;ar=AbCdEf...>
|
||||
```
|
||||
|
||||
Смысл:
|
||||
|
||||
- задаёт аватар канала;
|
||||
- `ar` хранит только `txId`, без полного URL;
|
||||
- конкретный gateway/URL уже выбирает клиент или сервер.
|
||||
|
||||
## Правила порядка
|
||||
|
||||
- теги должны идти только в начале текста;
|
||||
- после начала обычного описания новые теги больше не допускаются;
|
||||
- рекомендуемый порядок:
|
||||
1. `title`
|
||||
2. `avatar`
|
||||
3. текст описания
|
||||
- каждый тег допустим не более одного раза.
|
||||
|
||||
## Правила отсутствующих полей
|
||||
|
||||
- если `title` отсутствует, используется обычное техническое имя канала;
|
||||
- если `avatar` отсутствует, аватар у канала отсутствует;
|
||||
- если текста после тегов нет, описание считается пустым;
|
||||
- если тегов нет вообще, но текст есть, это означает пустые `title` и `avatar`, а текст используется как описание.
|
||||
|
||||
## Полный снимок состояния
|
||||
|
||||
Каждый новый `TEXT_CHANNEL_META` задаёт полное состояние канала целиком.
|
||||
|
||||
Это значит:
|
||||
|
||||
- новый блок не дозаполняет старые поля;
|
||||
- новый блок не наследует значения из прошлого meta-блока;
|
||||
- если в новом блоке нет `title`, то красивое имя сбрасывается к обычному имени канала;
|
||||
- если в новом блоке нет `avatar`, аватар сбрасывается;
|
||||
- если в новом блоке нет текста описания, описание становится пустым.
|
||||
|
||||
## Ограничения для тега `title`
|
||||
|
||||
`title` хранится как UTF-8 строка, но для упрощения формата запрещаются следующие символы:
|
||||
|
||||
- `<`
|
||||
- `>`
|
||||
- `;`
|
||||
- табуляция
|
||||
- перевод строки
|
||||
- `\0`
|
||||
|
||||
Дополнительно:
|
||||
|
||||
- максимальная длина `title` — `50` символов;
|
||||
- допускаются обычные Unicode-символы, включая кириллицу, латиницу, эмодзи и декоративные символы, если они не нарушают запреты выше.
|
||||
|
||||
## Ограничения для описания
|
||||
|
||||
- описание хранится как обычный UTF-8 текст;
|
||||
- максимальная длина — `250` символов.
|
||||
|
||||
Если описание длиннее лимита, meta-блок должен считаться невалидным.
|
||||
|
||||
## Ограничения для тега `avatar`
|
||||
|
||||
Обязательные поля тега:
|
||||
|
||||
- `v`
|
||||
- `size`
|
||||
- `sha256`
|
||||
- `ar`
|
||||
|
||||
Правила:
|
||||
|
||||
- `size` — размер файла в байтах;
|
||||
- `sha256` — SHA-256 файла в hex;
|
||||
- `sha256` должен иметь длину ровно `64` hex-символа;
|
||||
- `ar` — только `txId` Arweave;
|
||||
- длина `ar` должна соответствовать длине идентификатора Arweave-транзакции;
|
||||
- имя файла в meta-теге аватара не хранится.
|
||||
|
||||
## Валидация блока
|
||||
|
||||
Блок `TEXT_CHANNEL_META` считается валидным только если одновременно соблюдены все условия:
|
||||
|
||||
- теги находятся строго в начале текста;
|
||||
- каждый тип тега встречается не более одного раза;
|
||||
- `title` удовлетворяет ограничениям;
|
||||
- `avatar`-тег имеет все обязательные поля и корректные значения;
|
||||
- длина описания не превышает лимит.
|
||||
|
||||
Если блок невалиден:
|
||||
|
||||
- весь meta-блок не применяется целиком;
|
||||
- сервер не должен частично брать из него только отдельные поля.
|
||||
|
||||
## Что именно хранится в текущем состоянии канала
|
||||
|
||||
Текущее вычисленное состояние канала должно включать:
|
||||
|
||||
- техническое имя канала;
|
||||
- красивое отображаемое имя;
|
||||
- описание;
|
||||
- `ava_ar`;
|
||||
- `ava_sha256`;
|
||||
- `ava_size`;
|
||||
- время последнего meta-обновления.
|
||||
|
||||
## База данных
|
||||
|
||||
Базовую таблицу каналов предлагается не менять концептуально, а расширить существующую модель хранения каналов.
|
||||
|
||||
Текущая основа уже есть в таблице:
|
||||
|
||||
- `channel_names_state`
|
||||
|
||||
В неё предлагается добавить поля:
|
||||
|
||||
- `ava_ar`
|
||||
- `ava_sha256`
|
||||
- `ava_size`
|
||||
- `meta_updated_at_ms`
|
||||
|
||||
При этом:
|
||||
|
||||
- техническое имя канала продолжает храниться отдельно;
|
||||
- красивое имя хранится в `display_name`;
|
||||
- описание хранится в `channel_description`.
|
||||
|
||||
## Поведение API и чтения каналов
|
||||
|
||||
Обычным пользователям `TEXT_CHANNEL_META` не должен приходить как обычное сообщение ленты.
|
||||
|
||||
Это означает:
|
||||
|
||||
- в `GetChannelMessages` такие блоки не показываются как сообщения;
|
||||
- в обычных списках каналов клиент получает уже вычисленное текущее состояние канала;
|
||||
- в debug/raw-чтении сам блок может оставаться доступным как часть блокчейна.
|
||||
|
||||
## UI-заметки
|
||||
|
||||
### 1. В обычной ленте
|
||||
|
||||
`TEXT_CHANNEL_META` не показывается как обычное текстовое сообщение.
|
||||
|
||||
### 2. Системная карточка
|
||||
|
||||
UI может показывать специальную системную карточку:
|
||||
|
||||
- для текущего состояния канала;
|
||||
- для стартового состояния при создании канала.
|
||||
|
||||
### 3. Что показывать по нажатию
|
||||
|
||||
По нажатию на системную карточку нужно показывать полную информацию:
|
||||
|
||||
- дата и время;
|
||||
- заглавие;
|
||||
- аватар;
|
||||
- описание.
|
||||
|
||||
### 4. История UI
|
||||
|
||||
На первом этапе UI показывает только текущую системную карточку состояния.
|
||||
|
||||
Полная история всех прошлых meta-обновлений в UI на этом этапе не требуется.
|
||||
|
||||
## Поведение при создании канала
|
||||
|
||||
Для UI полезно также иметь отдельный визуальный системный блок в начале канала:
|
||||
|
||||
- «канал создан»;
|
||||
- по нажатию можно показать стартовые данные канала;
|
||||
- если на момент создания уже есть заглавие/ава/описание, UI показывает именно их.
|
||||
|
||||
Это UI-заметка и не требует отдельного нового блокчейн-формата поверх `TEXT_CHANNEL_META`.
|
||||
|
||||
## Краткое резюме согласованной схемы
|
||||
|
||||
- `msg_type=1`
|
||||
- `subType=70`
|
||||
- имя: `TEXT_CHANNEL_META`
|
||||
- это скрытый технический текстовый блок канала
|
||||
- порядок: теги в начале, потом описание
|
||||
- теги:
|
||||
- `<SHiNE:title;v=1;...>`
|
||||
- `<SHiNE:avatar;v=1;size=...;sha256=...;ar=...>`
|
||||
- `title`:
|
||||
- UTF-8
|
||||
- максимум `50` символов
|
||||
- нельзя `<`, `>`, `;`, таб, перевод строки, `\0`
|
||||
- описание:
|
||||
- UTF-8
|
||||
- максимум `250` символов
|
||||
- `avatar`:
|
||||
- `ar` только `txId`
|
||||
- хранится без имени файла
|
||||
- новый meta-блок задаёт полный снимок состояния
|
||||
- сервер применяет только последний валидный meta-блок
|
||||
- невалидный meta-блок не применяется целиком
|
||||
- БД расширяется в той же модели каналов
|
||||
- в UI обычным сообщением блок не показывается
|
||||
|
||||
## Что ещё потребуется зафиксировать позже
|
||||
|
||||
При переводе черновика в официальную спецификацию нужно будет дополнительно утвердить:
|
||||
|
||||
- точный номер версии формата;
|
||||
- точный способ подсчёта длины `title` и описания:
|
||||
- по Unicode code points;
|
||||
- по Java `String.length()`;
|
||||
- или по UTF-8 байтам;
|
||||
- точную проверку длины `ar`;
|
||||
- список API-ответов, куда должны быть добавлены новые поля;
|
||||
- правила миграции старых каналов без meta-блоков.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Черновик формата вложений в сообщениях блокчейна SHiNE
|
||||
|
||||
## Статус документа
|
||||
|
||||
Этот файл пока является черновиком и рабочим описанием идеи.
|
||||
|
||||
Важно:
|
||||
|
||||
- это не финальная спецификация;
|
||||
- точная версия формата будет зафиксирована после начала реальной реализации;
|
||||
- до момента реализации допустимы изменения полей, синтаксиса и правил отображения;
|
||||
- документ специально лежит в корне проекта как временная рабочая заметка.
|
||||
|
||||
## Цель
|
||||
|
||||
Нужен минимальный формат, позволяющий прикладывать файлы к текстовым сообщениям в каналах и тредах, не превращая само сообщение в отдельный файловый контейнер.
|
||||
|
||||
Основная идея:
|
||||
|
||||
- само сообщение остаётся обычным текстом;
|
||||
- вложения описываются специальными техническими вставками в начале текста;
|
||||
- сами файлы хранятся отдельно в архиве/Arweave;
|
||||
- в сообщении хранятся только метаданные, достаточные для отображения и скачивания.
|
||||
|
||||
## Общий принцип
|
||||
|
||||
Если в начале текста сообщения стоит один или несколько блоков:
|
||||
|
||||
```text
|
||||
<SHiNE:attach;...>
|
||||
```
|
||||
|
||||
клиент трактует их как описания вложений.
|
||||
|
||||
После этих блоков идёт обычный пользовательский текст сообщения.
|
||||
|
||||
Если технических блоков нет, сообщение считается обычным текстовым сообщением без вложений.
|
||||
|
||||
## Формат одного блока вложения
|
||||
|
||||
Общий вид:
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=abcdef...;ar=tx_or_url>
|
||||
```
|
||||
|
||||
Где:
|
||||
|
||||
- `attach` — тип технической вставки;
|
||||
- `v=1` — текущая рабочая версия чернового формата;
|
||||
- `name` — имя файла;
|
||||
- `size` — размер файла в байтах;
|
||||
- `sha256` — SHA-256 файла в hex;
|
||||
- `ar` — идентификатор файла в архиве или адрес файла в Arweave.
|
||||
|
||||
## Несколько вложений
|
||||
|
||||
Если к одному сообщению приложено несколько файлов, блоки просто идут подряд в начале текста:
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaa...;ar=...>
|
||||
<SHiNE:attach;v=1;name=video.mp4;size=5820193;sha256=bbb...;ar=...>
|
||||
Текст сообщения
|
||||
```
|
||||
|
||||
## Обязательные поля
|
||||
|
||||
Для чернового варианта обязательными считаются:
|
||||
|
||||
- `v`
|
||||
- `name`
|
||||
- `size`
|
||||
- `sha256`
|
||||
- `ar`
|
||||
|
||||
Если какого-то из этих полей нет, клиент может:
|
||||
|
||||
- игнорировать конкретный битый блок;
|
||||
- не считать его валидным вложением;
|
||||
- при этом продолжать обрабатывать остальные корректные блоки.
|
||||
|
||||
## Почему без `mime` и без `kind`
|
||||
|
||||
В минимальном варианте тип файла отдельно не хранится.
|
||||
|
||||
Причины:
|
||||
|
||||
- это экономит место;
|
||||
- тип всё равно обычно определяется клиентом;
|
||||
- для первого варианта достаточно имени файла, адреса, хэша и размера;
|
||||
- превью и способ отображения клиент может решать сам по расширению имени файла или по попытке открыть файл.
|
||||
|
||||
То есть в сообщении не хранится:
|
||||
|
||||
- `mime`;
|
||||
- `kind`;
|
||||
- `width`;
|
||||
- `height`;
|
||||
- `duration`;
|
||||
- `thumbnail`.
|
||||
|
||||
Все эти поля можно добавить позже отдельной версией, если они реально понадобятся.
|
||||
|
||||
## Логика клиента
|
||||
|
||||
Клиент получает:
|
||||
|
||||
- имя файла;
|
||||
- размер;
|
||||
- хэш;
|
||||
- адрес файла.
|
||||
|
||||
После этого клиент сам решает, как рендерить вложение:
|
||||
|
||||
- если расширение похоже на изображение, можно пробовать показать preview;
|
||||
- если расширение похоже на видео, можно пробовать встроенный видеоплеер;
|
||||
- если расширение похоже на аудио, можно пробовать аудиоплеер;
|
||||
- иначе показывать обычную кнопку скачивания.
|
||||
|
||||
Если браузер или клиент не смог показать preview, должен быть fallback на скачивание файла.
|
||||
|
||||
## Правила совместимости
|
||||
|
||||
- Сообщение без `<SHiNE:attach;...>` остаётся обычным текстом.
|
||||
- Старые клиенты, которые не умеют распознавать этот формат, могут показывать сообщение как обычный текст целиком.
|
||||
- Новые клиенты могут скрывать технические блоки и показывать вместо них UI вложений.
|
||||
|
||||
## Ограничения чернового варианта
|
||||
|
||||
В текущем виде значения полей не предполагают сложного экранирования.
|
||||
|
||||
Значит нужно отдельно договориться, как безопасно хранить `name`, если там встретятся символы:
|
||||
|
||||
- `;`
|
||||
- `=`
|
||||
- `>`
|
||||
|
||||
На этапе реальной реализации нужно выбрать один из вариантов:
|
||||
|
||||
- жёстко ограничить допустимые символы в `name`;
|
||||
- хранить имя файла в безопасно закодированном виде;
|
||||
- добавить отдельное правило escape/encoding.
|
||||
|
||||
Пока этот вопрос считается открытым.
|
||||
|
||||
## Примеры
|
||||
|
||||
### Одно вложение
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=3f2c8a...;ar=6sYk...>
|
||||
Привет, вот фото
|
||||
```
|
||||
|
||||
### Два вложения
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=3f2c8a...;ar=6sYk...>
|
||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=9ab01e...;ar=K9Lp...>
|
||||
Смотри, прикрепил фото и документ
|
||||
```
|
||||
|
||||
## Что надо зафиксировать позже
|
||||
|
||||
Перед переводом этого черновика в официальную спецификацию нужно отдельно утвердить:
|
||||
|
||||
- точное место этого формата в общей документации блокчейна;
|
||||
- где именно используются такие вложения: каналы, треды, комментарии или иные текстовые блоки;
|
||||
- финальные правила кодирования `name`;
|
||||
- допустим ли полный URL в `ar` или только короткий `txId`;
|
||||
- нужно ли ограничение на количество вложений в одном сообщении;
|
||||
- нужна ли отдельная серверная или клиентская проверка расширения файла;
|
||||
- как именно это будет отображаться в web UI и других клиентах.
|
||||
Reference in New Issue
Block a user