diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java index c01390c1..21103a16 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java @@ -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); } diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java index 3464d6f6..f5b72745 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java @@ -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). */ diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java index 748a7467..2e392f12 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java @@ -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 diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 5d00f285..7bccfda8 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -20,9 +20,11 @@ public final class DatabaseInitializer { 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() {} @@ -31,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; @@ -88,6 +91,10 @@ public final class DatabaseInitializer { } 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); } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java index 07c727cf..6952cd20 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java @@ -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). */ diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java index 61ee579a..762a4292 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java @@ -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(); } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java index ec7fb49d..b9812543 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java @@ -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; + } } diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v4.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v4.sql new file mode 100644 index 00000000..88523668 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v4.sql @@ -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; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 364d5d4c..e514899d 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -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 diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 2ffc971d..c84c8572 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -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); @@ -329,6 +331,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 +520,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 +579,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(""" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java index a5f22c1f..7c2171b9 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java @@ -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) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelMetaTextParser.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelMetaTextParser.java new file mode 100644 index 00000000..df48579e --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelMetaTextParser.java @@ -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("', offset); + if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag"); + String body = text.substring(offset + " 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 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 parseFields(String raw) { + Map 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) {} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index 1b7ca2df..04f6329b 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -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,20 @@ 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); } 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 +132,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 @@ -270,8 +296,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 +312,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 +330,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 +385,90 @@ final class ChannelsReadSupport { return meta.channelTypeVersion; } - static String detectLatestDescriptionCommand(Connection c, String ownerBch, int lineCode) throws SQLException { - String sql = """ - SELECT block_bytes + static List channelMetaEvents(Connection c, String ownerBch, int lineCode) throws SQLException { + List 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 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 = (Integer) rs.getObject("this_line_number"); + 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; + } + } 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 = ""; + event.avaAr = ""; + event.avaSha256 = ""; + event.avaSize = 0L; + 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 +593,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 { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index a4301417..9a882ea4 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -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 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 posts = new ArrayList<>( ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc) ); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java index 23ac1763..2874e982 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java @@ -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); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java index eeb95c25..9c1a80ff 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java @@ -8,6 +8,7 @@ import java.util.List; public class Net_GetChannelMessages_Response extends Net_Response { private Channel channel; private List messages = new ArrayList<>(); + private List 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 getMessages() { return messages; } public void setMessages(List messages) { this.messages = messages; } + public List getMetaEvents() { return metaEvents; } + public void setMetaEvents(List 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; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java index 0d30472c..2c1651ac 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java @@ -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; } diff --git a/VERSION.properties b/VERSION.properties index c50c7117..550b0ab3 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.4.10 -server.version=1.4.0 +client.version=1.4.11 +server.version=1.4.1 diff --git a/docs/API/04_Add_Block_to_Blockchain_API.md b/docs/API/04_Add_Block_to_Blockchain_API.md index b0ec501e..18975a49 100644 --- a/docs/API/04_Add_Block_to_Blockchain_API.md +++ b/docs/API/04_Add_Block_to_Blockchain_API.md @@ -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)` diff --git a/docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md index 6008cc59..ad6c30d3 100644 --- a/docs/API/06_Channels_Read_API.md +++ b/docs/API/06_Channels_Read_API.md @@ -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": "..." }, diff --git a/docs/Blockchain/02_Channel_Commands.md b/docs/Blockchain/02_Channel_Commands.md index 2fe6c3be..417040f9 100644 --- a/docs/Blockchain/02_Channel_Commands.md +++ b/docs/Blockchain/02_Channel_Commands.md @@ -1,33 +1,19 @@ # Командные сообщения каналов -## 1. Общий префикс -Командные сообщения распознаются по префиксу: +Команды в обычных `TEXT_POST` сообщениях каналов больше не используются для изменения профиля канала. -`/.` +## Удалённая команда `/.desc` -Пример: -- `/.desc Новый комментарий канала` +Команда `/.desc ` больше не поддерживается и не применяется сервером. -## 2. Поддерживаемые команды +Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок: -### Для всех типов каналов (`0`, `1`, `100`, `200`) -- `/.desc ` — смена описания канала. +- `msg_type=1` +- `subType=70` +- `TEXT_CHANNEL_META` -Примечание: -- Описание канала в чтении определяется последней командой `/.desc` в линии канала. -- Если `/.desc` не было, используется описание из `CreateChannel`. +Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md). -### Дополнительно для `type=200` -- `/.add ` -- `/.remove ` +## Зарезервированные команды 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 текущего этапа они не используются. diff --git a/docs/Blockchain/11_TEXT_Blocks.md b/docs/Blockchain/11_TEXT_Blocks.md index 564451cd..6c4dca05 100644 --- a/docs/Blockchain/11_TEXT_Blocks.md +++ b/docs/Blockchain/11_TEXT_Blocks.md @@ -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. diff --git a/docs/Blockchain/16_TEXT_Channel_Meta.md b/docs/Blockchain/16_TEXT_Channel_Meta.md new file mode 100644 index 00000000..c5566831 --- /dev/null +++ b/docs/Blockchain/16_TEXT_Channel_Meta.md @@ -0,0 +1,59 @@ +# TEXT_CHANNEL_META: профиль канала + +`TEXT_CHANNEL_META` — скрытый технический `TEXT`-блок для полного снимка профиля канала. + +## Блок + +- `msg_type = 1` +- `subType = 70` +- `msgVersion = 1` +- body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст. + +Блок пишется только в линию канала владельца. В обычной ленте сообщений он не отображается как пользовательский пост. + +## Текстовый формат + +В начале текста могут идти технические теги, после них обычный текст описания: + +```text + + +Описание канала. +``` + +Поддерживаемые теги: + +- `` — человекочитаемое имя канала. +- `` — аватар канала в 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` записать первый `TEXT_CHANNEL_META` с человекочитаемым именем, аватаром и описанием. +- При создании канала UI показывает системную карточку `Создан канал ...`. +- При записи `TEXT_CHANNEL_META` UI показывает системную карточку `Изменён профиль канала`. +- У системных карточек нет лайка, ответа, репоста, редактирования и других действий обычного сообщения. +- По клику на системную карточку показываются аватар, человекочитаемое имя и описание на момент этого события. + +## Замена старых команд + +Команда `/.desc` больше не используется и не применяется сервером. Описание канала меняется только через `TEXT_CHANNEL_META`. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index ff8fdd64..2b3ab6d7 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -113,6 +113,14 @@ - В `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)` для скрытого технического снимка профиля канала. +- Зафиксирован формат тегов `` и ``; описание хранится хвостовым UTF-8 текстом. +- UI создания канала может сразу после `TECH_CREATE_CHANNEL` записать первый `TEXT_CHANNEL_META` с человекочитаемым именем, описанием и аватаром. +- Команда `/.desc` удалена из актуальной модели изменения описания канала; профиль канала меняется только через `TEXT_CHANNEL_META`. +- API чтения каналов расширен полями `displayName`, `avaAr`, `avaSha256`, `avaSize`, `metaUpdatedAtMs` и списком `metaEvents`. + ## 2026-07-31 17:40:31 +0400 - Базовый коммит-ориентир: `d640dd6`. - Формат блокчейна не менялся. diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index 3e4ef7de..bce27443 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -19,11 +19,13 @@ Параметры пользователя (`msg_type=4`). 8. [15_TEXT_Attachments.md](./15_TEXT_Attachments.md) Вложения в TEXT-сообщениях через `SHiNE:attach v=1`. -9. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md) +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`. -10. [02_Channel_Commands.md](./02_Channel_Commands.md) +11. [02_Channel_Commands.md](./02_Channel_Commands.md) Команды в текстовых сообщениях каналов. -11. [CHANGELOG.md](./CHANGELOG.md) +12. [CHANGELOG.md](./CHANGELOG.md) Журнал изменений документации. ## Смежная документация diff --git a/shine-UI/js/components/arweave-attachment-manager.js b/shine-UI/js/components/arweave-attachment-manager.js index e9d44581..70a4ba42 100644 --- a/shine-UI/js/components/arweave-attachment-manager.js +++ b/shine-UI/js/components/arweave-attachment-manager.js @@ -16,8 +16,10 @@ const RECENT_UPLOAD_MS = 20 * 60 * 1000; function normalizeHistoryItem(input = {}) { const item = normalizeAttachment(input); const placedInShineAtMs = Number(input.placedInShineAtMs || 0) || 0; + const purpose = String(input.purpose || '').trim() === 'avatar' ? 'avatar' : 'attachment'; return { ...item, + purpose, pendingPlacement: input.pendingPlacement === true && !placedInShineAtMs, placedInShineAtMs, }; @@ -133,6 +135,36 @@ function shortAddress(value) { return `${raw.slice(0, 8)}...${raw.slice(-6)}`; } +async function resizeImageFileToAvatar(file) { + const source = file instanceof File ? file : null; + if (!source) throw new Error('Выберите изображение.'); + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(new Error('Не удалось прочитать изображение.')); + reader.readAsDataURL(source); + }); + const image = await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Не удалось открыть изображение.')); + img.src = dataUrl; + }); + const side = Math.min(image.naturalWidth || image.width, image.naturalHeight || image.height); + if (!side || side <= 0) throw new Error('Некорректный размер изображения.'); + const canvas = document.createElement('canvas'); + canvas.width = 512; + canvas.height = 512; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas недоступен.'); + const sx = Math.max(0, ((image.naturalWidth || image.width) - side) / 2); + const sy = Math.max(0, ((image.naturalHeight || image.height) - side) / 2); + ctx.drawImage(image, sx, sy, side, side, 0, 0, 512, 512); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.9)); + if (!blob) throw new Error('Не удалось сжать аватар.'); + return new File([blob], 'avatar.jpg', { type: 'image/jpeg', lastModified: Date.now() }); +} + export function openArweaveAttachmentManager({ login, storagePwd, @@ -140,6 +172,8 @@ export function openArweaveAttachmentManager({ onSelect, selectedTxIds = [], historyOnly = false, + mode = 'attachment', + historyPurpose = '', } = {}) { const cleanLogin = String(login || '').trim(); const cleanStoragePwd = String(storagePwd || '').trim(); @@ -158,6 +192,8 @@ export function openArweaveAttachmentManager({ let priceInfo = null; let balanceInfo = null; let autoOpenedFileDialog = false; + const isAvatarMode = String(mode || '') === 'avatar'; + const purposeFilter = isAvatarMode || String(historyPurpose || '').trim() === 'avatar' ? 'avatar' : ''; const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean)); function selectedWallet() { @@ -207,18 +243,18 @@ export function openArweaveAttachmentManager({ root.innerHTML = `