SHA256
Каналы: создавать профиль одним блоком
This commit is contained in:
+6
-7
@@ -19,7 +19,7 @@ import java.util.Objects;
|
|||||||
* [1] channelNameLen
|
* [1] channelNameLen
|
||||||
* [N] channelName UTF-8
|
* [N] channelName UTF-8
|
||||||
* [2] channelDescriptionLen
|
* [2] channelDescriptionLen
|
||||||
* [M] channelDescription UTF-8 (0..200 bytes)
|
* [M] channelDescription UTF-8 (0..2048 bytes)
|
||||||
* [2] channelTypeCode (uint16)
|
* [2] channelTypeCode (uint16)
|
||||||
* [2] channelTypeVersion (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 byte[] ZERO32 = new byte[32];
|
||||||
private static final int MAX_NAME_LENGTH = 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 subType;
|
||||||
public final short version;
|
public final short version;
|
||||||
@@ -88,7 +88,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
|
|
||||||
int descriptionLen = Short.toUnsignedInt(bb.getShort());
|
int descriptionLen = Short.toUnsignedInt(bb.getShort());
|
||||||
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
|
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) {
|
if (bb.remaining() != descriptionLen + 4) {
|
||||||
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
|
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);
|
String normalizedDescription = normalizeDescription(channelDescription);
|
||||||
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
|
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
|
||||||
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
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);
|
int typeCode = Short.toUnsignedInt(channelTypeCode);
|
||||||
@@ -166,7 +166,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
|
|
||||||
private static String normalizeDescription(String value) {
|
private static String normalizeDescription(String value) {
|
||||||
if (value == null) return "";
|
if (value == null) return "";
|
||||||
return value.trim().replaceAll("\\s+", " ");
|
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -177,7 +177,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
}
|
}
|
||||||
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
|
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
|
||||||
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
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;
|
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
|
@Override
|
||||||
public int lineSeq() { return thisLineNumber; }
|
public int lineSeq() { return thisLineNumber; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-6
@@ -304,14 +304,36 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
|
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 = new ChannelNameStateEntry();
|
||||||
channelNameStateEntry.setSlug(slug);
|
channelNameStateEntry.setSlug(slug);
|
||||||
channelNameStateEntry.setDisplayName(normalizedName);
|
channelNameStateEntry.setDisplayName(displayName);
|
||||||
channelNameStateEntry.setChannelDescription(
|
channelNameStateEntry.setChannelDescription(channelDescription);
|
||||||
createChannelBody.channelDescription == null
|
channelNameStateEntry.setAvaAr(avaAr);
|
||||||
? ""
|
channelNameStateEntry.setAvaSha256(avaSha256);
|
||||||
: ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription)
|
channelNameStateEntry.setAvaSize(avaSize);
|
||||||
);
|
channelNameStateEntry.setMetaUpdatedAtMs(metaUpdatedAtMs);
|
||||||
channelNameStateEntry.setOwnerLogin(login);
|
channelNameStateEntry.setOwnerLogin(login);
|
||||||
channelNameStateEntry.setOwnerBlockchainName(blockchainName);
|
channelNameStateEntry.setOwnerBlockchainName(blockchainName);
|
||||||
channelNameStateEntry.setChannelTypeCode(channelTypeCode);
|
channelNameStateEntry.setChannelTypeCode(channelTypeCode);
|
||||||
|
|||||||
+73
-2
@@ -1,7 +1,9 @@
|
|||||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||||
|
|
||||||
import blockchain.BchBlockEntry;
|
import blockchain.BchBlockEntry;
|
||||||
|
import blockchain.MsgSubType;
|
||||||
import blockchain.body.CreateChannelBody;
|
import blockchain.body.CreateChannelBody;
|
||||||
|
import blockchain.body.TextLineBody;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import shine.db.DbController;
|
import shine.db.DbController;
|
||||||
@@ -20,6 +22,7 @@ import java.util.Map;
|
|||||||
public final class ChannelNamesStateBootstrapper {
|
public final class ChannelNamesStateBootstrapper {
|
||||||
private static final Logger log = LoggerFactory.getLogger(ChannelNamesStateBootstrapper.class);
|
private static final Logger log = LoggerFactory.getLogger(ChannelNamesStateBootstrapper.class);
|
||||||
private static final int MSG_TYPE_TECH = 0;
|
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 final int MSG_SUB_TYPE_CREATE_CHANNEL = 1;
|
||||||
private static volatile boolean bootstrapped;
|
private static volatile boolean bootstrapped;
|
||||||
|
|
||||||
@@ -74,17 +77,30 @@ public final class ChannelNamesStateBootstrapper {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final String displayName;
|
String displayName;
|
||||||
final String slug;
|
final String slug;
|
||||||
final String channelDescription;
|
String channelDescription;
|
||||||
final int channelTypeCode;
|
final int channelTypeCode;
|
||||||
final int channelTypeVersion;
|
final int channelTypeVersion;
|
||||||
|
String avaAr = "";
|
||||||
|
String avaSha256 = "";
|
||||||
|
long avaSize = 0L;
|
||||||
|
long metaUpdatedAtMs = 0L;
|
||||||
try {
|
try {
|
||||||
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
|
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
|
||||||
slug = ChannelNameRules.toCanonicalSlug(displayName);
|
slug = ChannelNameRules.toCanonicalSlug(displayName);
|
||||||
channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
|
channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
|
||||||
channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
|
channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
|
||||||
channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion);
|
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) {
|
} catch (Exception badName) {
|
||||||
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
|
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
|
||||||
continue;
|
continue;
|
||||||
@@ -102,6 +118,10 @@ public final class ChannelNamesStateBootstrapper {
|
|||||||
entry.setSlug(slug);
|
entry.setSlug(slug);
|
||||||
entry.setDisplayName(displayName);
|
entry.setDisplayName(displayName);
|
||||||
entry.setChannelDescription(channelDescription == null ? "" : channelDescription);
|
entry.setChannelDescription(channelDescription == null ? "" : channelDescription);
|
||||||
|
entry.setAvaAr(avaAr);
|
||||||
|
entry.setAvaSha256(avaSha256);
|
||||||
|
entry.setAvaSize(avaSize);
|
||||||
|
entry.setMetaUpdatedAtMs(metaUpdatedAtMs);
|
||||||
entry.setOwnerLogin(ownerLogin);
|
entry.setOwnerLogin(ownerLogin);
|
||||||
entry.setOwnerBlockchainName(ownerBch);
|
entry.setOwnerBlockchainName(ownerBch);
|
||||||
entry.setChannelTypeCode(channelTypeCode);
|
entry.setChannelTypeCode(channelTypeCode);
|
||||||
@@ -116,6 +136,8 @@ public final class ChannelNamesStateBootstrapper {
|
|||||||
|
|
||||||
dao.clearAll(c);
|
dao.clearAll(c);
|
||||||
dao.insertAll(c, entries);
|
dao.insertAll(c, entries);
|
||||||
|
|
||||||
|
applyMetaUpdates(c, dao, entries, skipped);
|
||||||
c.commit();
|
c.commit();
|
||||||
log.info("channel_names_state bootstrapped: {}", entries.size());
|
log.info("channel_names_state bootstrapped: {}", entries.size());
|
||||||
if (!conflicts.isEmpty()) {
|
if (!conflicts.isEmpty()) {
|
||||||
@@ -148,4 +170,53 @@ public final class ChannelNamesStateBootstrapper {
|
|||||||
throw new RuntimeException("Failed to bootstrap channel_names_state", e);
|
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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -99,6 +99,15 @@ final class ChannelsReadSupport {
|
|||||||
meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription;
|
meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||||
meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode);
|
meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode);
|
||||||
meta.channelTypeVersion = Short.toUnsignedInt(ccb.channelTypeVersion);
|
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 {
|
} else {
|
||||||
meta.channelName = null;
|
meta.channelName = null;
|
||||||
meta.displayName = null;
|
meta.displayName = null;
|
||||||
@@ -415,6 +424,14 @@ final class ChannelsReadSupport {
|
|||||||
if (entry.body instanceof CreateChannelBody ccb) {
|
if (entry.body instanceof CreateChannelBody ccb) {
|
||||||
event.title = ccb.channelName;
|
event.title = ccb.channelName;
|
||||||
event.description = ccb.channelDescription == null ? "" : ccb.channelDescription;
|
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) {
|
} catch (Exception ignored) {
|
||||||
event.createdAtMs = 0L;
|
event.createdAtMs = 0L;
|
||||||
@@ -423,9 +440,8 @@ final class ChannelsReadSupport {
|
|||||||
event.title = base.displayName == null || base.displayName.isBlank() ? base.channelName : base.displayName;
|
event.title = base.displayName == null || base.displayName.isBlank() ? base.channelName : base.displayName;
|
||||||
}
|
}
|
||||||
if (event.description == null) event.description = "";
|
if (event.description == null) event.description = "";
|
||||||
event.avaAr = "";
|
if (event.avaAr == null) event.avaAr = "";
|
||||||
event.avaSha256 = "";
|
if (event.avaSha256 == null) event.avaSha256 = "";
|
||||||
event.avaSize = 0L;
|
|
||||||
out.add(event);
|
out.add(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.4.11
|
client.version=1.4.12
|
||||||
server.version=1.4.2
|
server.version=1.4.3
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ Payload включает:
|
|||||||
4. `channelType` (`uint16`, 2 байта);
|
4. `channelType` (`uint16`, 2 байта);
|
||||||
5. `channelTypeVersion` (`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. Типы каналов
|
## 2. Типы каналов
|
||||||
- `0` — `stories` (root-канал пользователя).
|
- `0` — `stories` (root-канал пользователя).
|
||||||
- `1` — публичный канал.
|
- `1` — публичный канал.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# TEXT_CHANNEL_META: профиль канала
|
# TEXT_CHANNEL_META: профиль канала
|
||||||
|
|
||||||
`TEXT_CHANNEL_META` — скрытый технический `TEXT`-блок для полного снимка профиля канала.
|
`TEXT_CHANNEL_META` — скрытый технический `TEXT`-блок для полного снимка профиля канала после создания.
|
||||||
|
|
||||||
## Блок
|
## Блок
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
Блок пишется только в линию канала владельца. В обычной ленте сообщений он не отображается как пользовательский пост.
|
Блок пишется только в линию канала владельца. В обычной ленте сообщений он не отображается как пользовательский пост.
|
||||||
|
|
||||||
|
Первый снимок профиля публичного канала хранится прямо в `TECH_CREATE_CHANNEL.channelDescription` тем же текстовым форматом. Отдельный `TEXT_CHANNEL_META` при создании нового публичного канала UI не пишет.
|
||||||
|
|
||||||
## Текстовый формат
|
## Текстовый формат
|
||||||
|
|
||||||
В начале текста могут идти технические теги, после них обычный текст описания:
|
В начале текста могут идти технические теги, после них обычный текст описания:
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
|
|
||||||
## UI
|
## UI
|
||||||
|
|
||||||
- При создании канала UI может сразу после `TECH_CREATE_CHANNEL` записать первый `TEXT_CHANNEL_META` с человекочитаемым именем, аватаром и описанием.
|
- При создании публичного канала UI записывает человекочитаемое имя, аватар и описание в `TECH_CREATE_CHANNEL.channelDescription`.
|
||||||
- При создании канала UI показывает системную карточку `Создан канал ...`.
|
- При создании канала UI показывает системную карточку `Создан канал ...`.
|
||||||
- При записи `TEXT_CHANNEL_META` UI показывает системную карточку `Изменён профиль канала`.
|
- При записи `TEXT_CHANNEL_META` UI показывает системную карточку `Изменён профиль канала`.
|
||||||
- У системных карточек нет лайка, ответа, репоста, редактирования и других действий обычного сообщения.
|
- У системных карточек нет лайка, ответа, репоста, редактирования и других действий обычного сообщения.
|
||||||
|
|||||||
@@ -121,6 +121,13 @@
|
|||||||
- Команда `/.desc` удалена из актуальной модели изменения описания канала; профиль канала меняется только через `TEXT_CHANNEL_META`.
|
- Команда `/.desc` удалена из актуальной модели изменения описания канала; профиль канала меняется только через `TEXT_CHANNEL_META`.
|
||||||
- API чтения каналов расширен полями `displayName`, `avaAr`, `avaSha256`, `avaSize`, `metaUpdatedAtMs` и списком `metaEvents`.
|
- 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
|
## 2026-07-31 17:40:31 +0400
|
||||||
- Базовый коммит-ориентир: `d640dd6`.
|
- Базовый коммит-ориентир: `d640dd6`.
|
||||||
- Формат блокчейна не менялся.
|
- Формат блокчейна не менялся.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function renderAvatarPreview(slot, avatar, title) {
|
|||||||
slot.innerHTML = '';
|
slot.innerHTML = '';
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'channel-profile-avatar';
|
wrap.className = 'channel-profile-avatar';
|
||||||
wrap.style.setProperty('--channel-avatar-size', '82px');
|
wrap.style.setProperty('--channel-avatar-size', '104px');
|
||||||
if (avatar?.ar) {
|
if (avatar?.ar) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.alt = 'Аватар канала';
|
img.alt = 'Аватар канала';
|
||||||
@@ -180,25 +180,17 @@ export function render({ navigate }) {
|
|||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const createResult = await authService.addBlockCreateChannel({
|
await authService.addBlockCreateChannel({
|
||||||
login,
|
login,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
channelName: normalizeChannelDisplayName(check.name),
|
channelName: normalizeChannelDisplayName(check.name),
|
||||||
channelDescription: '',
|
channelDescription: check.description,
|
||||||
|
channelProfileTitle: check.title,
|
||||||
|
channelAvatar: selectedAvatar,
|
||||||
channelType: CHANNEL_TYPE_PUBLIC,
|
channelType: CHANNEL_TYPE_PUBLIC,
|
||||||
channelTypeVersion: 1,
|
channelTypeVersion: 1,
|
||||||
});
|
});
|
||||||
if (check.title || check.description || selectedAvatar?.ar) {
|
if (selectedAvatar?.ar) markArweaveAttachmentPlaced(login, selectedAvatar);
|
||||||
await authService.addBlockChannelMeta({
|
|
||||||
login,
|
|
||||||
storagePwd,
|
|
||||||
channel: createResult.channel,
|
|
||||||
title: check.title,
|
|
||||||
description: check.description,
|
|
||||||
avatar: selectedAvatar,
|
|
||||||
});
|
|
||||||
if (selectedAvatar?.ar) markArweaveAttachmentPlaced(login, selectedAvatar);
|
|
||||||
}
|
|
||||||
|
|
||||||
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
|
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
|
||||||
navigate('channels-list');
|
navigate('channels-list');
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const CHANNEL_TYPE_PUBLIC = 1;
|
|||||||
const CHANNEL_TYPE_PERSONAL = 100;
|
const CHANNEL_TYPE_PERSONAL = 100;
|
||||||
const CHANNEL_TYPE_GROUP = 200;
|
const CHANNEL_TYPE_GROUP = 200;
|
||||||
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
|
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
|
||||||
|
const CREATE_CHANNEL_DESCRIPTION_MAX_BYTES = 2048;
|
||||||
const SESSION_TYPE_CLIENT = 1;
|
const SESSION_TYPE_CLIENT = 1;
|
||||||
const SESSION_TYPE_WALLET = 50;
|
const SESSION_TYPE_WALLET = 50;
|
||||||
const SESSION_TYPE_HOMESERVER = 100;
|
const SESSION_TYPE_HOMESERVER = 100;
|
||||||
@@ -691,10 +692,10 @@ function makeConnectionBodyBytes({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeChannelDescription(value) {
|
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);
|
const bytes = utf8Bytes(text);
|
||||||
if (bytes.length > 200) {
|
if (bytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
|
||||||
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
@@ -771,8 +772,8 @@ function makeCreateChannelBodyBytes({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const descriptionBytes = utf8Bytes(cleanDescription);
|
const descriptionBytes = utf8Bytes(cleanDescription);
|
||||||
if (descriptionBytes.length > 200) {
|
if (descriptionBytes.length > CREATE_CHANNEL_DESCRIPTION_MAX_BYTES) {
|
||||||
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
throw new Error('Описание канала слишком длинное: максимум 2048 байт.');
|
||||||
}
|
}
|
||||||
const typeVer = Number(channelTypeVersion);
|
const typeVer = Number(channelTypeVersion);
|
||||||
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
|
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
|
||||||
@@ -796,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 }) {
|
function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
|
||||||
const message = String(text || '').trim();
|
const message = String(text || '').trim();
|
||||||
if (!message) throw new Error('Message text is required');
|
if (!message) throw new Error('Message text is required');
|
||||||
@@ -2084,6 +2060,8 @@ export class AuthService {
|
|||||||
login,
|
login,
|
||||||
channelName,
|
channelName,
|
||||||
channelDescription = '',
|
channelDescription = '',
|
||||||
|
channelProfileTitle = '',
|
||||||
|
channelAvatar = null,
|
||||||
channelType = CHANNEL_TYPE_PUBLIC,
|
channelType = CHANNEL_TYPE_PUBLIC,
|
||||||
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
|
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
@@ -2099,7 +2077,13 @@ export class AuthService {
|
|||||||
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
|
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
|
||||||
}
|
}
|
||||||
const cleanChannelName = normalizeChannelDisplayName(nameCheck.normalized);
|
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 channelSlug = toCanonicalChannelSlug(cleanChannelName);
|
||||||
const typeVersion = Number(channelTypeVersion);
|
const typeVersion = Number(channelTypeVersion);
|
||||||
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
|
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
|
||||||
@@ -2131,51 +2115,23 @@ export class AuthService {
|
|||||||
thisLineNumber = createdChannels.length + 1;
|
thisLineNumber = createdChannels.length + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload;
|
const payload = await this.addBlockSigned({
|
||||||
try {
|
login: cleanLogin,
|
||||||
payload = await this.addBlockSigned({
|
storagePwd,
|
||||||
login: cleanLogin,
|
msgType: MSG_TYPE_TECH,
|
||||||
storagePwd,
|
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
||||||
msgType: MSG_TYPE_TECH,
|
msgVersion: CREATE_CHANNEL_BODY_VERSION,
|
||||||
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
bodyBytes: makeCreateChannelBodyBytes({
|
||||||
msgVersion: CREATE_CHANNEL_BODY_VERSION,
|
lineCode: 0,
|
||||||
bodyBytes: makeCreateChannelBodyBytes({
|
prevLineNumber,
|
||||||
lineCode: 0,
|
prevLineHashHex,
|
||||||
prevLineNumber,
|
thisLineNumber,
|
||||||
prevLineHashHex,
|
channelName: cleanChannelName,
|
||||||
thisLineNumber,
|
channelDescription: cleanChannelDescription,
|
||||||
channelName: cleanChannelName,
|
channelType: typeCode,
|
||||||
channelDescription: cleanChannelDescription,
|
channelTypeVersion: typeVersion,
|
||||||
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 selector = {
|
const selector = {
|
||||||
ownerBlockchainName: blockchainName,
|
ownerBlockchainName: blockchainName,
|
||||||
|
|||||||
@@ -3649,10 +3649,11 @@ textarea.input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.channel-row .avatar {
|
.channel-row .avatar {
|
||||||
width: 42px;
|
width: 58px;
|
||||||
height: 42px;
|
height: 58px;
|
||||||
min-width: 42px;
|
min-width: 58px;
|
||||||
min-height: 42px;
|
min-height: 58px;
|
||||||
|
border-radius: 50%;
|
||||||
background: linear-gradient(138deg, #efd193, #c18c42);
|
background: linear-gradient(138deg, #efd193, #c18c42);
|
||||||
color: #17233f;
|
color: #17233f;
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 244, 208, 0.58);
|
box-shadow: inset 0 1px 0 rgba(255, 244, 208, 0.58);
|
||||||
@@ -3920,14 +3921,14 @@ textarea.input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.channel-profile-avatar {
|
.channel-profile-avatar {
|
||||||
--channel-avatar-size: 72px;
|
--channel-avatar-size: 96px;
|
||||||
width: var(--channel-avatar-size);
|
width: var(--channel-avatar-size);
|
||||||
height: var(--channel-avatar-size);
|
height: var(--channel-avatar-size);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
border-radius: 22px;
|
border-radius: 50%;
|
||||||
background: linear-gradient(145deg, rgba(255, 214, 122, 0.22), rgba(56, 41, 22, 0.65));
|
background: linear-gradient(145deg, rgba(255, 214, 122, 0.22), rgba(56, 41, 22, 0.65));
|
||||||
border: 1px solid rgba(255, 226, 155, 0.24);
|
border: 1px solid rgba(255, 226, 155, 0.24);
|
||||||
color: rgba(255, 236, 194, 0.95);
|
color: rgba(255, 236, 194, 0.95);
|
||||||
|
|||||||
Reference in New Issue
Block a user