SHA256
Каналы: добавить профиль через TEXT_CHANNEL_META
This commit is contained in:
@@ -54,6 +54,7 @@ const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
|
||||
const MSG_SUBTYPE_TEXT_REPLY = 20;
|
||||
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
|
||||
const MSG_SUBTYPE_TEXT_REPOST = 30;
|
||||
const MSG_SUBTYPE_TEXT_CHANNEL_META = 70;
|
||||
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
||||
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
||||
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
||||
@@ -698,6 +699,39 @@ function normalizeChannelDescription(value) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function validateChannelMetaTitle(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (Array.from(text).length > 50) throw new Error('Название канала слишком длинное: максимум 50 символов.');
|
||||
if (/[<>;\t\n\r\u0000]/u.test(text)) {
|
||||
throw new Error('В названии канала нельзя использовать < > ; табуляцию и перевод строки.');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeChannelMetaDescription(value) {
|
||||
const text = String(value == null ? '' : value).trim();
|
||||
if (Array.from(text).length > 250) throw new Error('Описание канала слишком длинное: максимум 250 символов.');
|
||||
return text;
|
||||
}
|
||||
|
||||
function composeChannelMetaText({ title = '', description = '', avatar = null } = {}) {
|
||||
const cleanTitle = validateChannelMetaTitle(title);
|
||||
const cleanDescription = normalizeChannelMetaDescription(description);
|
||||
const rows = [];
|
||||
if (cleanTitle) rows.push(`<SHiNE:title;v=1;${cleanTitle}>`);
|
||||
if (avatar?.ar) {
|
||||
const size = Number(avatar.size || 0);
|
||||
const sha256 = String(avatar.sha256 || '').trim().toLowerCase();
|
||||
const ar = String(avatar.ar || '').trim();
|
||||
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер аватара.');
|
||||
if (!/^[0-9a-f]{64}$/u.test(sha256)) throw new Error('Некорректный SHA-256 аватара.');
|
||||
if (!/^[A-Za-z0-9_-]{43}$/u.test(ar)) throw new Error('Некорректный Arweave txId аватара.');
|
||||
rows.push(`<SHiNE:avatar;v=1;size=${size};sha256=${sha256};ar=${ar}>`);
|
||||
}
|
||||
if (cleanDescription) rows.push(cleanDescription);
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
function validatePersonalChannelName(value) {
|
||||
const normalized = normalizeChannelDisplayName(value);
|
||||
if (!normalized) return { ok: false, error: 'Введите логин пользователя.' };
|
||||
@@ -806,6 +840,23 @@ function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, this
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextLineBodyBytesAllowEmpty({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
|
||||
const message = String(text || '').trim();
|
||||
const textBytes = utf8Bytes(message);
|
||||
if (textBytes.length > 65535) {
|
||||
throw new Error('Message text must be 0..65535 UTF-8 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(lineCode),
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int16Bytes(textBytes.length),
|
||||
textBytes
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMessageRefTarget(target, actionName = 'action') {
|
||||
const cleanBch = String(target?.blockchainName || '').trim();
|
||||
const cleanBlockNumber = Number(target?.blockNumber);
|
||||
@@ -2141,6 +2192,67 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async resolveChannelLineTail({ login, blockchainName, channel }) {
|
||||
const selector = channel || {};
|
||||
const ownerBlockchainName = String(selector?.ownerBlockchainName || '').trim();
|
||||
const lineCode = Number(selector?.channelRootBlockNumber);
|
||||
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
||||
throw new Error('Invalid channel selector');
|
||||
}
|
||||
if (ownerBlockchainName !== blockchainName) {
|
||||
throw new Error('Posting is allowed only to your own channels');
|
||||
}
|
||||
|
||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||||
if (rootHashHex === ZERO64) {
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(login, blockchainName);
|
||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
||||
if (!rootChannel) throw new Error('Channel root not found');
|
||||
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
}
|
||||
|
||||
let prevLineNumber = lineCode;
|
||||
let prevLineHashHex = rootHashHex;
|
||||
let thisLineNumber = 0;
|
||||
try {
|
||||
const latestPayload = await this.getChannelMessages({
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: lineCode,
|
||||
channelRootBlockHash: rootHashHex,
|
||||
}, 1, 'desc', login);
|
||||
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
||||
const latestMeta = (Array.isArray(latestPayload?.metaEvents) ? latestPayload.metaEvents : [])
|
||||
.slice()
|
||||
.sort((a, b) => Number(b?.messageRef?.blockNumber || -1) - Number(a?.messageRef?.blockNumber || -1))[0] || null;
|
||||
const latestMessageBlock = Number(latestMessage?.messageRef?.blockNumber);
|
||||
const latestMetaBlock = Number(latestMeta?.messageRef?.blockNumber);
|
||||
const latest = Number.isFinite(latestMetaBlock) && (!Number.isFinite(latestMessageBlock) || latestMetaBlock > latestMessageBlock)
|
||||
? latestMeta
|
||||
: latestMessage;
|
||||
const latestBlockNumber = Number(latest?.messageRef?.blockNumber);
|
||||
const latestBlockHash = normalizeHex32(latest?.messageRef?.blockHash, '');
|
||||
const latestLineStep = resolveLatestLineStep(latest);
|
||||
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
||||
prevLineNumber = latestBlockNumber;
|
||||
prevLineHashHex = latestBlockHash;
|
||||
thisLineNumber = Number.isFinite(latestLineStep)
|
||||
? Math.max(0, latestLineStep + 1)
|
||||
: Math.max(1, latestBlockNumber === lineCode ? 1 : 0);
|
||||
}
|
||||
} catch {
|
||||
// fallback to root anchor
|
||||
}
|
||||
|
||||
return {
|
||||
ownerBlockchainName,
|
||||
lineCode,
|
||||
rootHashHex,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
async addBlockTextPost({ login, channel, text, storagePwd }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login');
|
||||
@@ -2154,53 +2266,13 @@ export class AuthService {
|
||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
|
||||
const ownerBlockchainName = owner;
|
||||
const lineCode = root;
|
||||
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
||||
throw new Error('Invalid channel selector');
|
||||
}
|
||||
if (ownerBlockchainName !== blockchainName) {
|
||||
throw new Error('Posting is allowed only to your own channels');
|
||||
}
|
||||
|
||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||||
if (rootHashHex === ZERO64) {
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
|
||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
||||
if (!rootChannel) throw new Error('Channel root not found');
|
||||
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
}
|
||||
|
||||
let prevLineNumber = lineCode;
|
||||
let prevLineHashHex = rootHashHex;
|
||||
let thisLineNumber = 0;
|
||||
try {
|
||||
const latestPayload = await this.getChannelMessages({
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: lineCode,
|
||||
channelRootBlockHash: rootHashHex,
|
||||
}, 1, 'desc', cleanLogin);
|
||||
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
||||
const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
|
||||
const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, '');
|
||||
const latestLineStep = resolveLatestLineStep(latestMessage);
|
||||
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
||||
prevLineNumber = latestBlockNumber;
|
||||
prevLineHashHex = latestBlockHash;
|
||||
// Для нового POST берём следующий шаг после последнего сообщения линии.
|
||||
thisLineNumber = Number.isFinite(latestLineStep)
|
||||
? Math.max(0, latestLineStep + 1)
|
||||
: 1;
|
||||
}
|
||||
} catch {
|
||||
// fallback to root anchor
|
||||
}
|
||||
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
|
||||
|
||||
const bodyBytes = makeTextPostBodyBytes({
|
||||
lineCode,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
lineCode: tail.lineCode,
|
||||
prevLineNumber: tail.prevLineNumber,
|
||||
prevLineHashHex: tail.prevLineHashHex,
|
||||
thisLineNumber: tail.thisLineNumber,
|
||||
text: cleanText,
|
||||
});
|
||||
|
||||
@@ -2216,9 +2288,50 @@ export class AuthService {
|
||||
return {
|
||||
...payload,
|
||||
channel: {
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: lineCode,
|
||||
channelRootBlockHash: rootHashHex,
|
||||
ownerBlockchainName: tail.ownerBlockchainName,
|
||||
channelRootBlockNumber: tail.lineCode,
|
||||
channelRootBlockHash: tail.rootHashHex,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockChannelMeta({ login, channel, title = '', description = '', avatar = null, storagePwd }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login');
|
||||
const selector = channel || {};
|
||||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
||||
const root = Number(selector?.channelRootBlockNumber);
|
||||
const metaText = composeChannelMetaText({ title, description, avatar });
|
||||
const key = `channel-meta:${cleanLogin}:${owner}:${root}:${metaText}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const tail = await this.resolveChannelLineTail({ login: cleanLogin, blockchainName, channel: selector });
|
||||
const bodyBytes = makeTextLineBodyBytesAllowEmpty({
|
||||
lineCode: tail.lineCode,
|
||||
prevLineNumber: tail.prevLineNumber,
|
||||
prevLineHashHex: tail.prevLineHashHex,
|
||||
thisLineNumber: tail.thisLineNumber,
|
||||
text: metaText,
|
||||
});
|
||||
|
||||
const payload = await this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TEXT,
|
||||
msgSubType: MSG_SUBTYPE_TEXT_CHANNEL_META,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
|
||||
return {
|
||||
...payload,
|
||||
channel: {
|
||||
ownerBlockchainName: tail.ownerBlockchainName,
|
||||
channelRootBlockNumber: tail.lineCode,
|
||||
channelRootBlockHash: tail.rootHashHex,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user