Ужесточение имен каналов и удаление legacy USER_PARAM для описания

This commit is contained in:
AidarKC 2026-05-08 19:06:58 +03:00
parent acdd6c928b
commit 4956ba7352
6 changed files with 34 additions and 344 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.42
server.version=1.2.36
client.version=1.2.43
server.version=1.2.37

View File

@ -44,11 +44,11 @@ export function render({ navigate }) {
form.className = 'card stack';
form.innerHTML = `
<strong class="channel-head-title">Создание канала</strong>
<p class="channel-head-meta">Можно использовать кириллицу, латиницу, цифры, пробел, _ и -.</p>
<p class="channel-head-meta">Можно использовать только латиницу, цифры, _ и -.</p>
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название уникально во всей системе.</p>
<label for="channel-name">Название канала</label>
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Поток силы" required />
<input id="channel-name" class="input" maxlength="32" placeholder="Например: my_channel-1" required />
<div id="channel-name-error" class="meta-muted inline-error"></div>
<label for="channel-description">Описание канала (необязательно)</label>
@ -124,18 +124,14 @@ export function render({ navigate }) {
errorEl.textContent = '';
try {
const created = await authService.addBlockCreateChannel({
await authService.addBlockCreateChannel({
login,
storagePwd,
channelName: normalizeChannelDisplayName(check.name),
channelDescription: normalizeChannelDescription(check.description),
});
const baseMessage = `Канал "${normalizeChannelDisplayName(check.name)}" создан.`;
const successMessage = created?.usedLegacyDescriptionFallback && created?.savedDescriptionViaUserParam
? `${baseMessage} Описание сохранено через блок параметра.`
: baseMessage;
persistCreateSuccessFlash(successMessage);
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
navigate('channels-list');
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.');

View File

@ -123,41 +123,6 @@ function buildAbsoluteRouteUrl(routePath = '') {
return url.toString();
}
function channelDescriptionParamKey(selector) {
const owner = String(selector?.ownerBlockchainName || '').trim();
const rootNo = Number(selector?.channelRootBlockNumber);
const rootHash = normalizeRouteHash(selector?.channelRootBlockHash);
if (!owner || !Number.isFinite(rootNo)) return '';
return `channel_desc:${owner}:${rootNo}:${rootHash}`;
}
function parseDescriptionOverride(payload) {
if (!payload || typeof payload !== 'object') {
return { hasOverride: false, description: '' };
}
const rawValue = String(payload?.value ?? payload?.param_value ?? '').trim();
if (!rawValue && !Number(payload?.time_ms || payload?.timeMs || 0)) {
return { hasOverride: false, description: '' };
}
if (!rawValue) {
return { hasOverride: true, description: '' };
}
try {
const parsed = JSON.parse(rawValue);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const value = typeof parsed.v === 'string' ? parsed.v : '';
return { hasOverride: true, description: value.trim() };
}
} catch {
// legacy raw string value
}
return { hasOverride: true, description: rawValue };
}
function buildSelectorFromRoute(route, channelId) {
const params = route?.params || {};
@ -410,88 +375,6 @@ function openAddMessageModal({ channelName, onSubmit }) {
if (textEl) textEl.focus();
}
function openEditDescriptionModal({ initialValue = '', onSubmit }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-edit-description-modal">
<div class="modal-card stack">
<h3 class="modal-title">Описание канала</h3>
<textarea id="channel-description-text" class="input" rows="5" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
<div class="meta-muted inline-error" id="channel-description-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-description-cancel" type="button">Отмена</button>
<button class="primary-btn" id="channel-description-submit" type="button">Сохранить</button>
</div>
</div>
</div>
`;
const textEl = root.querySelector('#channel-description-text');
const counterEl = root.querySelector('#channel-description-counter');
const errorEl = root.querySelector('#channel-description-error');
const submitEl = root.querySelector('#channel-description-submit');
const cancelEl = root.querySelector('#channel-description-cancel');
let inFlight = false;
const compute = () => {
const value = String(textEl?.value || '').replace(/\s+/g, ' ').trim();
const bytes = new TextEncoder().encode(value).length;
const ok = bytes <= 200;
return {
value,
bytes,
ok,
error: ok ? '' : 'Описание слишком длинное: максимум 200 байт UTF-8.',
};
};
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
cancelEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
submitEl.textContent = inFlight ? 'Сохраняем...' : 'Сохранить';
};
const close = () => {
root.innerHTML = '';
};
const updateValidation = () => {
const check = compute();
counterEl.textContent = `${check.bytes} / 200 байт`;
errorEl.textContent = check.error;
submitEl.disabled = inFlight || !check.ok;
return check;
};
cancelEl?.addEventListener('click', close);
textEl?.addEventListener('input', updateValidation);
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const check = updateValidation();
if (!check.ok) return;
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(check.value);
close();
} catch (error) {
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить описание.');
}
});
if (textEl) {
textEl.value = String(initialValue || '');
textEl.focus();
}
updateValidation();
}
function mapApiMessageToPost(message, selector, localNumber) {
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
@ -552,27 +435,11 @@ async function loadFromApi(route, channelId) {
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
const readDescription = async () => {
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
const paramKey = channelDescriptionParamKey(selector);
if (!ownerLogin || !paramKey) return sourceDescription;
try {
const paramPayload = await authService.getUserParam(ownerLogin, paramKey);
const override = parseDescriptionOverride(paramPayload);
return override.hasOverride ? override.description : sourceDescription;
} catch {
return sourceDescription;
}
};
const resolvedDescription = await readDescription();
return {
channel: {
name: payload.channel?.channelName || 'неизвестный канал',
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
description: resolvedDescription,
description: String(payload.channel?.channelDescription || '').trim(),
ownerName: ownerLogin || 'неизвестно',
},
posts,
@ -818,22 +685,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
});
headActions.append(aboutButton);
if (channelData.isOwnChannel) {
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'secondary-btn small-btn';
editButton.textContent = '✎';
editButton.title = 'Редактировать описание';
editButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
openEditDescriptionModal({
initialValue: channelData.channel.description || '',
onSubmit: async (nextValue) => handlers.onEditDescription(nextValue),
});
});
headActions.append(editButton);
}
head.append(title);
head.append(owner, headActions);
@ -1024,25 +875,6 @@ export function render({ navigate, route }) {
rerender();
};
const onEditDescription = async (descriptionText) => {
const { login, storagePwd } = requireSigningSession();
const selector = routeSelector;
const param = channelDescriptionParamKey(selector);
if (!param) throw new Error('Идентификатор канала не готов для обновления описания.');
const value = JSON.stringify({ v: String(descriptionText || '').trim() });
await authService.addBlockUserParam({
login,
storagePwd,
param,
value,
});
softHaptic(10);
showToast('Описание канала сохранено');
rerender();
};
screen.append(
renderHeader({
title: '',
@ -1085,14 +917,6 @@ export function render({ navigate, route }) {
}
},
onShare: onShare,
onEditDescription: async (descriptionText) => {
try {
await onEditDescription(descriptionText);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось сохранить описание.'));
}
},
onSubscribeChannel: async (event) => {
animatePress(event?.currentTarget);
try {

View File

@ -92,27 +92,6 @@ function opError(op, response) {
return error;
}
function isLegacyCreateChannelFormatError(error) {
const code = String(error?.code || '').trim().toUpperCase();
const text = String(error?.message || '').toLowerCase();
if (code === 'BAD_BLOCK_FORMAT') return true;
return (
text.includes('unknown body type/version') ||
text.includes('unknown tech body type/version/subtype') ||
text.includes('bad_block_format')
);
}
function channelDescriptionParamKeyFromSelector(selector) {
const owner = String(selector?.ownerBlockchainName || '').trim();
const rootNo = Number(selector?.channelRootBlockNumber);
const rootHash = String(selector?.channelRootBlockHash || '').trim().toLowerCase();
if (!owner || !Number.isFinite(rootNo) || rootNo < 0 || !/^[0-9a-f]{64}$/.test(rootHash)) {
return '';
}
return `channel_desc:${owner}:${rootNo}:${rootHash}`;
}
function makeClientInfo() {
const ua = navigator.userAgent || 'unknown';
return ua.slice(0, 50);
@ -416,26 +395,6 @@ function makeConnectionBodyBytes({
);
}
function makeCreateChannelBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, channelName }) {
const check = validateChannelDisplayName(channelName);
if (!check.ok) throw new Error(channelNameErrorText(check.code));
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 normalizeChannelDescription(value) {
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
const bytes = utf8Bytes(text);
@ -1088,44 +1047,21 @@ export class AuthService {
thisLineNumber = createdChannels.length + 1;
}
const submitCreate = async (useV2) => {
const bodyBytes = useV2
? makeCreateChannelBodyV2Bytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
channelDescription: cleanChannelDescription,
})
: makeCreateChannelBodyBytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: useV2 ? CREATE_CHANNEL_BODY_VERSION : 1,
bodyBytes,
});
};
let payload;
let usedLegacyDescriptionFallback = false;
let savedDescriptionViaUserParam = false;
try {
payload = await submitCreate(true);
} catch (error) {
if (!isLegacyCreateChannelFormatError(error)) throw error;
payload = await submitCreate(false);
usedLegacyDescriptionFallback = true;
}
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: CREATE_CHANNEL_BODY_VERSION,
bodyBytes: makeCreateChannelBodyV2Bytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
channelDescription: cleanChannelDescription,
}),
});
const selector = {
ownerBlockchainName: blockchainName,
@ -1133,24 +1069,8 @@ export class AuthService {
channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64),
};
if (usedLegacyDescriptionFallback && cleanChannelDescription) {
const param = channelDescriptionParamKeyFromSelector(selector);
if (!param) {
throw new Error('Не удалось сохранить описание канала: некорректный идентификатор канала.');
}
await this.addBlockUserParam({
login: cleanLogin,
storagePwd,
param,
value: JSON.stringify({ v: cleanChannelDescription }),
});
savedDescriptionViaUserParam = true;
}
return {
...payload,
usedLegacyDescriptionFallback,
savedDescriptionViaUserParam,
channel: {
...selector,
},

View File

@ -1,10 +1,10 @@
const MIN_LEN = 3;
const MAX_LEN = 32;
const ALLOWED_CHARS_RE = /^[\p{Script=Latin}\p{Script=Cyrillic}0-9 _-]+$/u;
const ALLOWED_CHARS_RE = /^[A-Za-z0-9_-]+$/;
export function normalizeChannelDisplayName(value) {
if (value == null) return '';
return String(value).trim().replace(/\s+/g, ' ');
return String(value).trim();
}
export function normalizeChannelDescription(value) {
@ -16,24 +16,9 @@ export function toCanonicalChannelSlug(value) {
const normalized = normalizeChannelDisplayName(value);
if (!normalized) return '';
const lowered = normalized.toLowerCase().replace(/\u0451/g, '\u0435');
let out = '';
let pendingSeparator = false;
for (const ch of lowered) {
if (ch === ' ' || ch === '_' || ch === '-') {
pendingSeparator = out.length > 0;
continue;
}
if (!/[\p{Script=Latin}\p{Script=Cyrillic}0-9]/u.test(ch)) {
return '';
}
if (pendingSeparator && out.length > 0) out += '-';
out += ch;
pendingSeparator = false;
}
return out.replace(/-+$/g, '');
const lowered = normalized.toLowerCase();
if (!ALLOWED_CHARS_RE.test(lowered)) return '';
return lowered;
}
export function validateChannelDisplayName(value) {
@ -73,7 +58,7 @@ export function channelNameErrorText(code) {
case 'too_long':
return 'Название слишком длинное: максимум 32 символа.';
case 'bad_chars':
return 'Разрешены кириллица, латиница, цифры, пробел, _ и -.';
return 'Разрешены только латиница, цифры, _ и -.';
case 'reserved':
return 'Название "0" зарезервировано.';
default:

View File

@ -7,13 +7,13 @@ public final class ChannelNameRules {
private static final int MIN_DISPLAY_NAME_LENGTH = 3;
private static final int MAX_DISPLAY_NAME_LENGTH = 32;
private static final Pattern DISPLAY_ALLOWED_PATTERN =
Pattern.compile("^[\\p{IsLatin}\\p{IsCyrillic}0-9 _-]+$");
Pattern.compile("^[A-Za-z0-9_-]+$");
private ChannelNameRules() {}
public static String normalizeDisplayName(String value) {
if (value == null) return "";
return value.trim().replaceAll("\\s+", " ");
return value.trim();
}
public static String requireValidDisplayNameForCreate(String rawName) {
@ -40,45 +40,10 @@ public final class ChannelNameRules {
throw new IllegalArgumentException("channelName is blank");
}
String lowered = normalized.toLowerCase(Locale.ROOT).replace('\u0451', '\u0435');
StringBuilder slug = new StringBuilder(lowered.length());
boolean pendingSeparator = false;
for (int i = 0; i < lowered.length(); ) {
int cp = lowered.codePointAt(i);
i += Character.charCount(cp);
if (cp == ' ' || cp == '_' || cp == '-') {
pendingSeparator = slug.length() > 0;
continue;
}
if (!isLatinOrCyrillicOrDigit(cp)) {
throw new IllegalArgumentException("channelName contains unsupported characters");
}
if (pendingSeparator && slug.length() > 0) {
slug.append('-');
}
pendingSeparator = false;
slug.appendCodePoint(cp);
String lowered = normalized.toLowerCase(Locale.ROOT);
if (!DISPLAY_ALLOWED_PATTERN.matcher(lowered).matches()) {
throw new IllegalArgumentException("channelName contains unsupported characters");
}
int len = slug.length();
if (len > 0 && slug.charAt(len - 1) == '-') {
slug.deleteCharAt(len - 1);
}
if (slug.length() == 0) {
throw new IllegalArgumentException("channelName canonical slug is empty");
}
return slug.toString();
}
private static boolean isLatinOrCyrillicOrDigit(int cp) {
if (Character.isDigit(cp)) return true;
Character.UnicodeScript script = Character.UnicodeScript.of(cp);
return script == Character.UnicodeScript.LATIN || script == Character.UnicodeScript.CYRILLIC;
return lowered;
}
}