Ужесточение имен каналов и удаление legacy USER_PARAM для описания
This commit is contained in:
parent
acdd6c928b
commit
4956ba7352
@ -1,2 +1,2 @@
|
||||
client.version=1.2.42
|
||||
server.version=1.2.36
|
||||
client.version=1.2.43
|
||||
server.version=1.2.37
|
||||
|
||||
@ -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, 'Не удалось создать канал.');
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user