SHA256
feat: finalize channels fixes and runtime stability
This commit is contained in:
@@ -12,6 +12,12 @@ import {
|
||||
signBase64,
|
||||
utf8Bytes,
|
||||
} from './crypto-utils.js';
|
||||
import {
|
||||
channelNameErrorText,
|
||||
normalizeChannelDisplayName,
|
||||
toCanonicalChannelSlug,
|
||||
validateChannelDisplayName,
|
||||
} from './channel-name-rules.js';
|
||||
import {
|
||||
loadEncryptedUserSecrets,
|
||||
loadSessionMaterial,
|
||||
@@ -20,20 +26,50 @@ import {
|
||||
} from './key-vault.js';
|
||||
|
||||
const BCH_SUFFIX = '001';
|
||||
const ZERO64 = '0'.repeat(64);
|
||||
|
||||
const MSG_TYPE_TECH = 0;
|
||||
const MSG_TYPE_TEXT = 1;
|
||||
const MSG_TYPE_REACTION = 2;
|
||||
const MSG_TYPE_CONNECTION = 3;
|
||||
|
||||
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
|
||||
const MSG_SUBTYPE_TEXT_POST = 10;
|
||||
const MSG_SUBTYPE_TEXT_REPLY = 20;
|
||||
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
||||
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
||||
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
||||
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
|
||||
|
||||
function normalizeServerUrl(url) {
|
||||
const value = (url || '').trim();
|
||||
if (!value) return 'wss://shineup.me/ws';
|
||||
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
|
||||
if (value.startsWith('ws://') || value.startsWith('wss://')) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
if (value.startsWith('https://') || value.startsWith('http://')) {
|
||||
return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function opError(op, response) {
|
||||
const message = response?.payload?.message || response?.message || 'Неизвестная ошибка сервера';
|
||||
const code = response?.payload?.code || response?.code || 'UNKNOWN';
|
||||
const payload = response?.payload || {};
|
||||
const message = payload?.message || response?.message || payload?.error || response?.error || 'Unknown server error';
|
||||
const code = String(payload?.code || response?.code || payload?.error || response?.error || 'UNKNOWN').toUpperCase();
|
||||
const error = new Error(`${op}: ${message} (${code})`);
|
||||
error.op = op;
|
||||
error.code = code;
|
||||
@@ -51,11 +87,21 @@ function hexToBytes(hex) {
|
||||
if (!clean || clean.length % 2 !== 0) throw new Error('Некорректный hex');
|
||||
const out = new Uint8Array(clean.length / 2);
|
||||
for (let i = 0; i < out.length; i += 1) {
|
||||
out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
||||
const byte = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
||||
if (Number.isNaN(byte)) throw new Error('Некорректный hex');
|
||||
out[i] = byte;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeHex32(value, fallback = ZERO64) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) return fallback;
|
||||
if (/^0+$/.test(raw)) return ZERO64;
|
||||
if (!/^[0-9a-f]{64}$/.test(raw)) throw new Error('Bad hash32 format');
|
||||
return raw;
|
||||
}
|
||||
|
||||
function concatBytes(...chunks) {
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
@@ -81,6 +127,12 @@ function int16Bytes(value) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function int8Byte(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error('Bad uint8 value');
|
||||
return new Uint8Array([n & 0xff]);
|
||||
}
|
||||
|
||||
function int64Bytes(value) {
|
||||
const bytes = new Uint8Array(8);
|
||||
const view = new DataView(bytes.buffer);
|
||||
@@ -107,10 +159,179 @@ function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thi
|
||||
);
|
||||
}
|
||||
|
||||
function makeReactionLikeBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex }) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for like');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
throw new Error('Invalid toBlockNumber for like');
|
||||
}
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex))
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text }) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for reply');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
throw new Error('Invalid toBlockNumber for reply');
|
||||
}
|
||||
|
||||
const message = String(text || '').trim();
|
||||
if (!message) throw new Error('Reply text is required');
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
}
|
||||
|
||||
const textBytes = utf8Bytes(message);
|
||||
if (textBytes.length < 1 || textBytes.length > 65535) {
|
||||
throw new Error('Reply text must be 1..65535 UTF-8 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
textBytes
|
||||
);
|
||||
}
|
||||
|
||||
function makeConnectionBodyBytes({
|
||||
lineCode = 0,
|
||||
prevLineNumber = -1,
|
||||
prevLineHashHex = ZERO64,
|
||||
thisLineNumber = -1,
|
||||
toBlockchainName,
|
||||
toBlockNumber,
|
||||
toBlockHashHex,
|
||||
}) {
|
||||
const cleanBch = String(toBlockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('toBlockchainName is required for connection');
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
throw new Error('Invalid toBlockNumber for connection');
|
||||
}
|
||||
|
||||
const bchBytes = utf8Bytes(cleanBch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(lineCode),
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex))
|
||||
);
|
||||
}
|
||||
|
||||
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 makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
|
||||
const message = String(text || '').trim();
|
||||
if (!message) throw new Error('Message text is required');
|
||||
|
||||
const textBytes = utf8Bytes(message);
|
||||
if (textBytes.length < 1 || textBytes.length > 65535) {
|
||||
throw new Error('Message text must be 1..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);
|
||||
const cleanBlockHash = String(target?.blockHash || '').trim().toLowerCase();
|
||||
|
||||
if (!cleanBch) {
|
||||
throw new Error(`Missing message target blockchain for ${actionName}`);
|
||||
}
|
||||
if (!Number.isFinite(cleanBlockNumber) || cleanBlockNumber < 0) {
|
||||
throw new Error(`Invalid message target block number for ${actionName}`);
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(cleanBlockHash) || /^0+$/.test(cleanBlockHash)) {
|
||||
throw new Error(`Invalid message target hash for ${actionName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
blockchainName: cleanBch,
|
||||
blockNumber: cleanBlockNumber,
|
||||
blockHash: cleanBlockHash,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBlockPreimage({ prevBlockHashHex, blockNumber, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
||||
const prevHashBytes = hexToBytes(normalizeHex32(prevBlockHashHex));
|
||||
const body = bodyBytes || new Uint8Array(0);
|
||||
const blockSize = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + body.length;
|
||||
|
||||
return concatBytes(
|
||||
int16Bytes(0),
|
||||
prevHashBytes,
|
||||
int32Bytes(blockSize),
|
||||
int32Bytes(blockNumber),
|
||||
int64Bytes(Math.floor(Date.now() / 1000)),
|
||||
int16Bytes(msgType),
|
||||
int16Bytes(msgSubType),
|
||||
int16Bytes(msgVersion),
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
constructor(serverUrl) {
|
||||
this.serverUrl = normalizeServerUrl(serverUrl);
|
||||
this.ws = new WsJsonClient(this.serverUrl);
|
||||
this.headerHashCache = new Map();
|
||||
this.writeLocks = new Map();
|
||||
}
|
||||
|
||||
async reconnect(serverUrl) {
|
||||
@@ -119,6 +340,20 @@ export class AuthService {
|
||||
this.ws.close();
|
||||
this.serverUrl = normalized;
|
||||
this.ws = new WsJsonClient(this.serverUrl);
|
||||
this.headerHashCache = new Map();
|
||||
this.writeLocks.clear();
|
||||
}
|
||||
|
||||
runWriteLocked(lockKey, runAction) {
|
||||
const key = String(lockKey || '').trim() || 'write';
|
||||
if (this.writeLocks.has(key)) return this.writeLocks.get(key);
|
||||
|
||||
const task = (async () => runAction())().finally(() => {
|
||||
this.writeLocks.delete(key);
|
||||
});
|
||||
|
||||
this.writeLocks.set(key, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async getUser(login) {
|
||||
@@ -293,18 +528,440 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getChannelMessages(channel, limit = 200, sort = 'asc') {
|
||||
const response = await this.ws.request('GetChannelMessages', { channel, limit, sort });
|
||||
async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
|
||||
const payload = { channel, limit, sort };
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (cleanLogin) payload.login = cleanLogin;
|
||||
const response = await this.ws.request('GetChannelMessages', payload);
|
||||
if (response.status !== 200) throw opError('GetChannelMessages', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50) {
|
||||
const response = await this.ws.request('GetMessageThread', { message, depthUp, depthDown, limitChildrenPerNode });
|
||||
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
|
||||
const payload = { message, depthUp, depthDown, limitChildrenPerNode };
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (cleanLogin) payload.login = cleanLogin;
|
||||
const response = await this.ws.request('GetMessageThread', payload);
|
||||
if (response.status !== 200) throw opError('GetMessageThread', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
||||
if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing');
|
||||
|
||||
const user = await this.getUser(cleanLogin);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const freshNum = Number(user?.serverLastGlobalNumber);
|
||||
const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
|
||||
const freshCursor = {
|
||||
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
||||
serverLastGlobalHash: freshHash,
|
||||
};
|
||||
|
||||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
|
||||
if (!blockchainPrivatePkcs8) {
|
||||
throw new Error('Missing saved blockchain private key on device');
|
||||
}
|
||||
|
||||
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
||||
|
||||
const tryAdd = async (cursor) => {
|
||||
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
||||
const prevBlockHash = normalizeHex32(cursor?.serverLastGlobalHash, ZERO64);
|
||||
const preimage = buildBlockPreimage({
|
||||
prevBlockHashHex: prevBlockHash,
|
||||
blockNumber,
|
||||
msgType,
|
||||
msgSubType,
|
||||
msgVersion,
|
||||
bodyBytes,
|
||||
});
|
||||
|
||||
const hash32 = await sha256Bytes(preimage);
|
||||
const signatureBytes = await signBytes(privateKey, hash32);
|
||||
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
|
||||
|
||||
return this.ws.request('AddBlock', {
|
||||
blockchainName,
|
||||
blockNumber,
|
||||
prevBlockHash,
|
||||
blockBytesB64: bytesToBase64(fullBlock),
|
||||
});
|
||||
};
|
||||
|
||||
let cursor = freshCursor;
|
||||
let response = await tryAdd(cursor);
|
||||
if (response.status !== 200) {
|
||||
const knownNum = Number(response?.payload?.serverLastGlobalNumber);
|
||||
const knownHash = String(response?.payload?.serverLastGlobalHash || '');
|
||||
if (Number.isFinite(knownNum) && /^[0-9a-fA-F]{64}$/.test(knownHash)) {
|
||||
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash.toLowerCase() };
|
||||
response = await tryAdd(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status !== 200) throw opError('AddBlock', response);
|
||||
|
||||
const payload = response.payload || {};
|
||||
const acceptedNum = Number(payload?.serverLastGlobalNumber);
|
||||
const acceptedHash = normalizeHex32(payload?.serverLastGlobalHash, ZERO64);
|
||||
if (Number.isFinite(acceptedNum) && acceptedNum === 0 && acceptedHash !== ZERO64) {
|
||||
this.headerHashCache.set(blockchainName, acceptedHash);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async ensureChainInitializedForLineOps(login, storagePwd) {
|
||||
const current = await this.getUser(login);
|
||||
const lastNum = Number(current?.serverLastGlobalNumber);
|
||||
if (Number.isFinite(lastNum) && lastNum >= 0) return current;
|
||||
if (!(Number.isFinite(lastNum) && lastNum === -1)) return current;
|
||||
|
||||
// Bootstrap an empty chain with a minimal USER_PARAM block so line-based
|
||||
// channel operations have a valid anchor at block #0.
|
||||
await this.addBlockUserParam({
|
||||
login,
|
||||
storagePwd,
|
||||
param: 'shine',
|
||||
value: 'yes',
|
||||
});
|
||||
|
||||
return this.getUser(login);
|
||||
}
|
||||
|
||||
async addBlockLike({ login, message, storagePwd }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const target = normalizeMessageRefTarget(message, 'like');
|
||||
const key = `like:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeReactionLikeBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
});
|
||||
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_REACTION,
|
||||
msgSubType: MSG_SUBTYPE_REACTION_LIKE,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockUnlike({ login, message, storagePwd }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const target = normalizeMessageRefTarget(message, 'unlike');
|
||||
const key = `unlike:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeReactionLikeBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
});
|
||||
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_REACTION,
|
||||
msgSubType: MSG_SUBTYPE_REACTION_UNLIKE,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockReply({ login, message, text, storagePwd }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanText = String(text || '').trim();
|
||||
const target = normalizeMessageRefTarget(message, 'reply');
|
||||
const key = `reply:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const bodyBytes = makeTextReplyBodyBytes({
|
||||
toBlockchainName: target.blockchainName,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
});
|
||||
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TEXT,
|
||||
msgSubType: MSG_SUBTYPE_TEXT_REPLY,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockFollowUser({ login, targetLogin, storagePwd, unfollow = false }) {
|
||||
const cleanTargetLogin = String(targetLogin || '').trim().replace(/^@+/, '');
|
||||
if (!cleanTargetLogin) throw new Error('Target login is required');
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const key = `${unfollow ? 'unfollow-user' : 'follow-user'}:${cleanLogin}:${cleanTargetLogin.toLowerCase()}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const targetUser = await this.getUser(cleanTargetLogin);
|
||||
if (!targetUser?.exists) throw new Error('Target user not found');
|
||||
const targetHeaderHash = await this.resolveHeaderHashForBlockchain(targetUser.blockchainName);
|
||||
|
||||
return this.addBlockFollowChannel({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
targetBlockchainName: targetUser.blockchainName,
|
||||
targetBlockNumber: 0,
|
||||
targetBlockHashHex: targetHeaderHash,
|
||||
unfollow,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockFollowChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
targetBlockchainName,
|
||||
targetBlockNumber,
|
||||
targetBlockHashHex,
|
||||
unfollow = false,
|
||||
}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanTargetBch = String(targetBlockchainName || '').trim();
|
||||
const cleanTargetBlockNumber = Number(targetBlockNumber);
|
||||
if (!cleanTargetBch) throw new Error('Target blockchain is required');
|
||||
if (!Number.isFinite(cleanTargetBlockNumber) || cleanTargetBlockNumber < 0) {
|
||||
throw new Error('Invalid target block number');
|
||||
}
|
||||
const seedHash = normalizeHex32(targetBlockHashHex, ZERO64);
|
||||
const key = `${unfollow ? 'unfollow-channel' : 'follow-channel'}:${cleanLogin}:${cleanTargetBch}:${cleanTargetBlockNumber}:${seedHash}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
let targetHashHex = seedHash;
|
||||
if (targetHashHex === ZERO64) {
|
||||
targetHashHex = cleanTargetBlockNumber === 0
|
||||
? await this.resolveHeaderHashForBlockchain(cleanTargetBch)
|
||||
: await this.getBlockHashByNumber(cleanTargetBch, cleanTargetBlockNumber);
|
||||
}
|
||||
|
||||
const bodyBytes = makeConnectionBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO64,
|
||||
thisLineNumber: -1,
|
||||
toBlockchainName: cleanTargetBch,
|
||||
toBlockNumber: cleanTargetBlockNumber,
|
||||
toBlockHashHex: targetHashHex,
|
||||
});
|
||||
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_CONNECTION,
|
||||
msgSubType: unfollow ? MSG_SUBTYPE_CONNECTION_UNFOLLOW : MSG_SUBTYPE_CONNECTION_FOLLOW,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getBlockHashByNumber(blockchainName, blockNumber) {
|
||||
const cleanBlockNumber = Number(blockNumber);
|
||||
try {
|
||||
const payload = await this.getMessageThread(
|
||||
{
|
||||
blockchainName: String(blockchainName || '').trim(),
|
||||
blockNumber: cleanBlockNumber,
|
||||
blockHash: ZERO64,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
1
|
||||
);
|
||||
const hash = payload?.focus?.messageRef?.blockHash;
|
||||
return normalizeHex32(hash, ZERO64);
|
||||
} catch (error) {
|
||||
if (cleanBlockNumber === 0 && Number(error?.status) === 404) {
|
||||
return ZERO64;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resolveHeaderHashForBlockchain(blockchainName) {
|
||||
const cleanBch = String(blockchainName || '').trim();
|
||||
if (!cleanBch) throw new Error('Missing blockchainName');
|
||||
|
||||
if (this.headerHashCache.has(cleanBch)) {
|
||||
const cached = normalizeHex32(this.headerHashCache.get(cleanBch), ZERO64);
|
||||
if (cached !== ZERO64) return cached;
|
||||
this.headerHashCache.delete(cleanBch);
|
||||
}
|
||||
|
||||
const headerHash = await this.getBlockHashByNumber(cleanBch, 0);
|
||||
if (headerHash !== ZERO64) {
|
||||
this.headerHashCache.set(cleanBch, headerHash);
|
||||
} else {
|
||||
this.headerHashCache.delete(cleanBch);
|
||||
}
|
||||
return headerHash;
|
||||
}
|
||||
|
||||
async listOwnChannelsForBlockchain(login, blockchainName) {
|
||||
const feed = await this.listSubscriptionsFeed(login, 500);
|
||||
const own = feed?.ownedChannels || [];
|
||||
return own
|
||||
.filter((item) => String(item?.channel?.ownerBlockchainName || '') === blockchainName)
|
||||
.map((item) => ({
|
||||
rootBlockNumber: Number(item?.channel?.channelRoot?.blockNumber),
|
||||
rootBlockHash: normalizeHex32(item?.channel?.channelRoot?.blockHash, ZERO64),
|
||||
channelName: String(item?.channel?.channelName || ''),
|
||||
}))
|
||||
.filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
|
||||
}
|
||||
|
||||
async addBlockCreateChannel({ login, channelName, storagePwd }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login');
|
||||
|
||||
const check = validateChannelDisplayName(channelName);
|
||||
if (!check.ok) throw new Error(channelNameErrorText(check.code));
|
||||
const cleanChannelName = normalizeChannelDisplayName(check.normalized);
|
||||
const channelSlug = toCanonicalChannelSlug(cleanChannelName);
|
||||
|
||||
const key = `create-channel:${cleanLogin}:${channelSlug || cleanChannelName.toLowerCase()}`;
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const userLastGlobalNumber = Number(user?.serverLastGlobalNumber);
|
||||
const userLastGlobalHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
|
||||
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
|
||||
const createdChannels = ownChannels
|
||||
.filter((item) => item.rootBlockNumber > 0)
|
||||
.sort((a, b) => a.rootBlockNumber - b.rootBlockNumber);
|
||||
|
||||
let prevLineNumber = 0;
|
||||
let prevLineHashHex = (
|
||||
Number.isFinite(userLastGlobalNumber) &&
|
||||
userLastGlobalNumber === 0 &&
|
||||
userLastGlobalHash !== ZERO64
|
||||
)
|
||||
? userLastGlobalHash
|
||||
: await this.resolveHeaderHashForBlockchain(blockchainName);
|
||||
let thisLineNumber = 1;
|
||||
|
||||
if (createdChannels.length > 0) {
|
||||
const last = createdChannels[createdChannels.length - 1];
|
||||
prevLineNumber = last.rootBlockNumber;
|
||||
prevLineHashHex = normalizeHex32(last.rootBlockHash, ZERO64);
|
||||
thisLineNumber = createdChannels.length + 1;
|
||||
}
|
||||
|
||||
const bodyBytes = makeCreateChannelBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
channelName: cleanChannelName,
|
||||
});
|
||||
|
||||
const payload = await this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TECH,
|
||||
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
|
||||
return {
|
||||
...payload,
|
||||
channel: {
|
||||
ownerBlockchainName: blockchainName,
|
||||
channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
|
||||
channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockTextPost({ login, channel, text, storagePwd }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login');
|
||||
const cleanText = String(text || '').trim();
|
||||
const selector = channel || {};
|
||||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
||||
const root = Number(selector?.channelRootBlockNumber);
|
||||
const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanText}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const userLastGlobalNumber = Number(user?.serverLastGlobalNumber);
|
||||
const userLastGlobalHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
|
||||
|
||||
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 (lineCode === 0) {
|
||||
rootHashHex = (
|
||||
Number.isFinite(userLastGlobalNumber) &&
|
||||
userLastGlobalNumber === 0 &&
|
||||
userLastGlobalHash !== ZERO64
|
||||
)
|
||||
? userLastGlobalHash
|
||||
: await this.resolveHeaderHashForBlockchain(blockchainName);
|
||||
} else 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);
|
||||
}
|
||||
|
||||
const bodyBytes = makeTextPostBodyBytes({
|
||||
lineCode,
|
||||
prevLineNumber: lineCode,
|
||||
prevLineHashHex: rootHashHex,
|
||||
thisLineNumber: 0,
|
||||
text: cleanText,
|
||||
});
|
||||
|
||||
const payload = await this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TEXT,
|
||||
msgSubType: MSG_SUBTYPE_TEXT_POST,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
|
||||
return {
|
||||
...payload,
|
||||
channel: {
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: lineCode,
|
||||
channelRootBlockHash: rootHashHex,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
onEvent(op, handler) {
|
||||
return this.ws.onEvent(op, handler);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
const MIN_LEN = 3;
|
||||
const MAX_LEN = 32;
|
||||
const ALLOWED_CHARS_RE = /^[\p{Script=Latin}\p{Script=Cyrillic}0-9 _-]+$/u;
|
||||
|
||||
export function normalizeChannelDisplayName(value) {
|
||||
if (value == null) return '';
|
||||
return String(value).trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
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, '');
|
||||
}
|
||||
|
||||
export function validateChannelDisplayName(value) {
|
||||
const normalized = normalizeChannelDisplayName(value);
|
||||
if (!normalized) {
|
||||
return { ok: false, code: 'blank', normalized: '', slug: '' };
|
||||
}
|
||||
|
||||
const length = Array.from(normalized).length;
|
||||
if (length < MIN_LEN) {
|
||||
return { ok: false, code: 'too_short', normalized, slug: '' };
|
||||
}
|
||||
if (length > MAX_LEN) {
|
||||
return { ok: false, code: 'too_long', normalized, slug: '' };
|
||||
}
|
||||
if (!ALLOWED_CHARS_RE.test(normalized)) {
|
||||
return { ok: false, code: 'bad_chars', normalized, slug: '' };
|
||||
}
|
||||
if (normalized === '0') {
|
||||
return { ok: false, code: 'reserved', normalized, slug: '' };
|
||||
}
|
||||
|
||||
const slug = toCanonicalChannelSlug(normalized);
|
||||
if (!slug) {
|
||||
return { ok: false, code: 'bad_chars', normalized, slug: '' };
|
||||
}
|
||||
|
||||
return { ok: true, code: '', normalized, slug };
|
||||
}
|
||||
|
||||
export function channelNameErrorText(code) {
|
||||
switch (String(code || '').trim()) {
|
||||
case 'blank':
|
||||
return 'Введите название канала.';
|
||||
case 'too_short':
|
||||
return 'Название слишком короткое: минимум 3 символа.';
|
||||
case 'too_long':
|
||||
return 'Название слишком длинное: максимум 32 символа.';
|
||||
case 'bad_chars':
|
||||
return 'Разрешены кириллица, латиница, цифры, пробел, _ и -.';
|
||||
case 'reserved':
|
||||
return 'Название "0" зарезервировано.';
|
||||
default:
|
||||
return 'Некорректное название канала.';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
const encoder = new TextEncoder();
|
||||
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
|
||||
|
||||
function getCryptoApi() {
|
||||
const api = globalThis.crypto;
|
||||
if (!api || typeof api.getRandomValues !== 'function') {
|
||||
throw new Error(WEB_CRYPTO_REQUIRED_MESSAGE);
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
function getSubtleApi() {
|
||||
const api = getCryptoApi();
|
||||
if (!api.subtle) {
|
||||
throw new Error(WEB_CRYPTO_REQUIRED_MESSAGE);
|
||||
}
|
||||
return api.subtle;
|
||||
}
|
||||
|
||||
|
||||
function base64UrlToBase64(value) {
|
||||
@@ -8,7 +25,7 @@ function base64UrlToBase64(value) {
|
||||
}
|
||||
|
||||
export function randomBase64(byteLen = 32) {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(byteLen));
|
||||
const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen));
|
||||
return bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
@@ -35,7 +52,7 @@ export function utf8Bytes(value) {
|
||||
}
|
||||
|
||||
export async function sha256Bytes(bytes) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
const digest = await getSubtleApi().digest('SHA-256', bytes);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
@@ -65,8 +82,9 @@ function ed25519Pkcs8FromSeed(seed32) {
|
||||
export async function deriveEd25519FromPassword(password, suffix) {
|
||||
const seed = await derivePasswordSeed(password, suffix);
|
||||
const pkcs8 = ed25519Pkcs8FromSeed(seed);
|
||||
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
|
||||
const jwk = await crypto.subtle.exportKey('jwk', privateKey);
|
||||
const subtle = getSubtleApi();
|
||||
const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
|
||||
const jwk = await subtle.exportKey('jwk', privateKey);
|
||||
if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519');
|
||||
|
||||
return {
|
||||
@@ -77,7 +95,8 @@ export async function deriveEd25519FromPassword(password, suffix) {
|
||||
}
|
||||
|
||||
export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
const subtle = getSubtleApi();
|
||||
const baseKey = await subtle.importKey(
|
||||
'raw',
|
||||
utf8Bytes(storagePwd),
|
||||
{ name: 'PBKDF2' },
|
||||
@@ -85,7 +104,7 @@ export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
|
||||
['deriveKey'],
|
||||
);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
return subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: saltBytes,
|
||||
@@ -103,11 +122,13 @@ export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) {
|
||||
}
|
||||
|
||||
export async function encryptJsonWithStoragePwd(value, storagePwd) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const cryptoApi = getCryptoApi();
|
||||
const subtle = getSubtleApi();
|
||||
const salt = cryptoApi.getRandomValues(new Uint8Array(16));
|
||||
const iv = cryptoApi.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
|
||||
const plainBytes = utf8Bytes(JSON.stringify(value));
|
||||
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes);
|
||||
const cipher = await subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes);
|
||||
|
||||
return {
|
||||
saltB64: bytesToBase64(salt),
|
||||
@@ -121,35 +142,35 @@ export async function decryptJsonWithStoragePwd(envelope, storagePwd) {
|
||||
const iv = base64ToBytes(envelope.ivB64);
|
||||
const cipher = base64ToBytes(envelope.cipherB64);
|
||||
const key = await deriveAesKeyFromStoragePwd(storagePwd, salt);
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher);
|
||||
const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv }, key, cipher);
|
||||
const text = new TextDecoder().decode(plain);
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
export async function generateEd25519Pair() {
|
||||
return crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
||||
return getSubtleApi().generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
||||
}
|
||||
|
||||
export async function exportEd25519PublicKeyB64(publicKey) {
|
||||
const raw = await crypto.subtle.exportKey('raw', publicKey);
|
||||
const raw = await getSubtleApi().exportKey('raw', publicKey);
|
||||
return bytesToBase64(new Uint8Array(raw));
|
||||
}
|
||||
|
||||
export async function exportPkcs8B64(privateKey) {
|
||||
const raw = await crypto.subtle.exportKey('pkcs8', privateKey);
|
||||
const raw = await getSubtleApi().exportKey('pkcs8', privateKey);
|
||||
return bytesToBase64(new Uint8Array(raw));
|
||||
}
|
||||
|
||||
export async function importPkcs8Ed25519(pkcs8B64) {
|
||||
return crypto.subtle.importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
|
||||
return getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']);
|
||||
}
|
||||
|
||||
export async function signBase64(privateKey, text) {
|
||||
const signature = await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
|
||||
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text));
|
||||
return bytesToBase64(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
export async function signBytes(privateKey, bytes) {
|
||||
const signature = await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, bytes);
|
||||
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, bytes);
|
||||
return new Uint8Array(signature);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
function extractCode(message = '') {
|
||||
const match = String(message || '').match(/\(([A-Z0-9_]+)\)\s*$/i);
|
||||
return match ? String(match[1]).toUpperCase() : '';
|
||||
}
|
||||
|
||||
function normalizeText(error) {
|
||||
return String(error?.message || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function toUserMessage(error, fallback = 'Действие не выполнено. Попробуйте еще раз.') {
|
||||
const raw = String(error?.message || '').trim();
|
||||
const text = normalizeText(error);
|
||||
const code = String(error?.code || extractCode(raw) || '').toUpperCase();
|
||||
|
||||
if (
|
||||
text.includes('webcrypto') ||
|
||||
text.includes('crypto.subtle') ||
|
||||
text.includes("reading 'digest'") ||
|
||||
text.includes('not supported on insecure origins')
|
||||
) {
|
||||
return 'Криптография браузера недоступна. Откройте приложение через HTTPS или localhost.';
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes('mixed content') ||
|
||||
text.includes('insecure websocket connection') ||
|
||||
(text.includes('https') && text.includes('ws://'))
|
||||
) {
|
||||
return 'Подключение заблокировано: страница открыта по HTTPS, а сервер указан как ws://. Используйте wss://.';
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes('не удалось подключиться') ||
|
||||
text.includes('failed to connect websocket') ||
|
||||
text.includes('websocket закрыто') ||
|
||||
text.includes('соединение websocket закрыто')
|
||||
) {
|
||||
return 'Сервер недоступен. Проверьте, что backend запущен, и повторите попытку.';
|
||||
}
|
||||
|
||||
if (text.includes('таймаут') || text.includes('timeout waiting')) {
|
||||
return 'Сервер отвечает слишком долго. Повторите попытку через несколько секунд.';
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'USER_NOT_FOUND' ||
|
||||
text.includes('user not found') ||
|
||||
text.includes('пользователь не найден')
|
||||
) {
|
||||
return 'Пользователь не найден. Проверьте логин.';
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'BAD_CHANNEL_NAME' ||
|
||||
text.includes('channel name must match') ||
|
||||
text.includes('channelname contains unsupported') ||
|
||||
text.includes('channelname length must be 3..32') ||
|
||||
text.includes('bad_channel_name')
|
||||
) {
|
||||
return 'Некорректное название канала. Разрешены кириллица, латиница, цифры, пробел, _ и - (3..32 символа).';
|
||||
}
|
||||
|
||||
if (text.includes('channel name is required') || text.includes('введите имя канала')) {
|
||||
return 'Введите имя канала.';
|
||||
}
|
||||
|
||||
if (code === 'CHANNEL_NAME_ALREADY_EXISTS' || text.includes('channel_name_already_exists')) {
|
||||
return 'Такое название уже занято. Попробуйте немного изменить его.';
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'PREV_LINE_BLOCK_NOT_FOUND' ||
|
||||
code === 'LINE_ERR_NO_PREV' ||
|
||||
text.includes('prev_line_block_not_found') ||
|
||||
text.includes('line_err_no_prev')
|
||||
) {
|
||||
return 'Базовый блок линии не найден. Обновите страницу и повторите действие.';
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'BAD_PREV_LINE_HASH' ||
|
||||
code === 'LINE_ERR_PREV_HASH_MISMATCH' ||
|
||||
text.includes('bad_prev_line_hash') ||
|
||||
text.includes('line_err_prev_hash_mismatch') ||
|
||||
text.includes('prevlinehash')
|
||||
) {
|
||||
return 'Конфликт состояния канала. Обновите страницу и повторите действие.';
|
||||
}
|
||||
|
||||
if (code === 'LINE_ERR_PARTIAL_FIELDS' || text.includes('line_err_partial_fields')) {
|
||||
return 'Некорректные данные канала. Обновите страницу и повторите действие.';
|
||||
}
|
||||
|
||||
if (code === 'NOT_AUTHENTICATED' || text.includes('session is not ready for signing')) {
|
||||
return 'Сессия недействительна. Выполните вход заново.';
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'SESSION_NOT_FOUND' ||
|
||||
code === 'SESSION_KEY_NOT_ACTUAL' ||
|
||||
code === 'SESSION_OF_ANOTHER_USER'
|
||||
) {
|
||||
return 'Сессия устарела. Войдите заново и повторите действие.';
|
||||
}
|
||||
|
||||
if (code === 'UNSUPPORTED_KEY_ALGORITHM' || text.includes('unsupported key algorithm')) {
|
||||
return 'Ключ устройства не поддерживается сервером. Очистите локальные ключи и войдите заново.';
|
||||
}
|
||||
|
||||
if (!raw) return fallback;
|
||||
return raw;
|
||||
}
|
||||
@@ -5,12 +5,52 @@ const DEFAULT_TIMEOUT_MS = 12000;
|
||||
function buildWsUrl(raw) {
|
||||
const value = (raw || '').trim();
|
||||
if (!value) return 'wss://shineup.me/ws';
|
||||
if (value.startsWith('ws://') || value.startsWith('wss://')) return value;
|
||||
if (value.startsWith('http://')) return `ws://${value.slice('http://'.length)}`;
|
||||
if (value.startsWith('https://')) return `wss://${value.slice('https://'.length)}`;
|
||||
if (value.startsWith('/')) {
|
||||
const secure = window.location.protocol === 'https:';
|
||||
const scheme = secure ? 'wss' : 'ws';
|
||||
return `${scheme}://${window.location.host}${value}`;
|
||||
}
|
||||
if (value.startsWith('ws://') || value.startsWith('wss://')) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return value.startsWith('https://')
|
||||
? `wss://${value.slice('https://'.length)}`
|
||||
: `ws://${value.slice('http://'.length)}`;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname = '') {
|
||||
const host = String(hostname || '').toLowerCase();
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
|
||||
}
|
||||
|
||||
function isMixedContentWs(url) {
|
||||
try {
|
||||
const pageIsHttps = window.location.protocol === 'https:';
|
||||
if (!pageIsHttps) return false;
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'ws:') return false;
|
||||
return !isLoopbackHost(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createRequestId(op) {
|
||||
return `${op}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
@@ -27,6 +67,15 @@ export class WsJsonClient {
|
||||
async open() {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
||||
if (this.openPromise) return this.openPromise;
|
||||
if (isMixedContentWs(this.url)) {
|
||||
const error = new Error('Страница открыта по HTTPS, а сервер указан как ws://. Используйте wss:// адрес для Shine сервера.');
|
||||
captureClientError({
|
||||
kind: 'ws_mixed_content_blocked',
|
||||
message: error.message,
|
||||
context: { url: this.url, pageProtocol: window.location.protocol },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.openPromise = new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.url);
|
||||
@@ -54,7 +103,6 @@ export class WsJsonClient {
|
||||
});
|
||||
}).finally(() => {
|
||||
this.openPromise = null;
|
||||
this.eventListeners = new Map();
|
||||
});
|
||||
|
||||
return this.openPromise;
|
||||
|
||||
Reference in New Issue
Block a user