2577 lines
93 KiB
JavaScript
2577 lines
93 KiB
JavaScript
import { WsJsonClient } from './ws-client.js';
|
||
import {
|
||
base64ToBytes,
|
||
bytesToBase64,
|
||
deriveEd25519FromMasterSecret,
|
||
deriveMasterSecretFromPassword,
|
||
exportEd25519PublicKeyB64,
|
||
exportPkcs8B64,
|
||
generateEd25519Pair,
|
||
importPkcs8Ed25519,
|
||
publicKeyB64FromPkcs8Ed25519,
|
||
randomBase64,
|
||
sha256Bytes,
|
||
signBytes,
|
||
signBase64,
|
||
utf8Bytes,
|
||
} from './crypto-utils.js';
|
||
import {
|
||
channelNameErrorText,
|
||
normalizeChannelDisplayName,
|
||
toCanonicalChannelSlug,
|
||
validateChannelDisplayName,
|
||
} from './channel-name-rules.js';
|
||
import {
|
||
loadEncryptedUserSecrets,
|
||
loadSessionMaterial,
|
||
saveEncryptedUserSecrets,
|
||
saveSessionMaterial,
|
||
} from './key-vault.js';
|
||
import { defaultServerWs } from '../deploy-config.js';
|
||
|
||
const BCH_SUFFIX = '001';
|
||
const ZERO64 = '0'.repeat(64);
|
||
const ZERO_HASH_HEX = ZERO64;
|
||
|
||
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_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_REACTION_LIKE = 1;
|
||
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
||
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
||
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
|
||
const CREATE_CHANNEL_BODY_VERSION = 1;
|
||
const CHANNEL_TYPE_STORIES = 0;
|
||
const CHANNEL_TYPE_PUBLIC = 1;
|
||
const CHANNEL_TYPE_PERSONAL = 100;
|
||
const CHANNEL_TYPE_GROUP = 200;
|
||
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
|
||
const SESSION_TYPE_CLIENT = 1;
|
||
const SESSION_TYPE_WALLET = 50;
|
||
const SESSION_TYPE_HOMESERVER = 100;
|
||
const SIGNAL_TARGET_SINGLE = 'single_session';
|
||
const SIGNAL_TARGET_ALL = 'all_sessions';
|
||
const SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST = 'remote_addblock_request';
|
||
const SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT = 'remote_addblock_result';
|
||
|
||
const CONNECTION_SUBTYPES = Object.freeze({
|
||
// Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
|
||
close_friend: { on: 10, off: 11 },
|
||
friend: { on: 10, off: 11 },
|
||
contact: { on: 20, off: 21 },
|
||
follow: { on: 30, off: 31 },
|
||
spouse: { on: 40, off: 41 },
|
||
parent: { on: 50, off: 51 },
|
||
child: { on: 52, off: 53 },
|
||
sibling: { on: 54, off: 55 },
|
||
known_person: { on: 60, off: 61 },
|
||
shine_confirmed: { on: 70, off: 71 },
|
||
shine_seen: { on: 74, off: 75 },
|
||
});
|
||
|
||
function normalizeServerUrl(url) {
|
||
const value = (url || '').trim();
|
||
if (!value) return defaultServerWs;
|
||
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://')) {
|
||
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 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;
|
||
error.status = response?.status || 0;
|
||
return error;
|
||
}
|
||
|
||
function createSignalRequestId(prefix = 'signal') {
|
||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
}
|
||
|
||
function makeClientInfo() {
|
||
const ua = navigator.userAgent || 'unknown';
|
||
return ua.slice(0, 50);
|
||
}
|
||
|
||
function makeClientPlatform() {
|
||
return 'Web';
|
||
}
|
||
|
||
function hexToBytes(hex) {
|
||
const clean = String(hex || '').trim().toLowerCase();
|
||
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) {
|
||
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;
|
||
}
|
||
|
||
async function sha256Base64FromText(text) {
|
||
const digest = await sha256Bytes(utf8Bytes(String(text || '')));
|
||
return bytesToBase64(digest);
|
||
}
|
||
|
||
function concatBytes(...chunks) {
|
||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||
const out = new Uint8Array(total);
|
||
let offset = 0;
|
||
chunks.forEach((chunk) => {
|
||
out.set(chunk, offset);
|
||
offset += chunk.length;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function int32Bytes(value) {
|
||
const bytes = new Uint8Array(4);
|
||
const view = new DataView(bytes.buffer);
|
||
view.setInt32(0, Number(value), false);
|
||
return bytes;
|
||
}
|
||
|
||
function int16Bytes(value) {
|
||
const bytes = new Uint8Array(2);
|
||
const view = new DataView(bytes.buffer);
|
||
view.setUint16(0, Number(value), false);
|
||
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);
|
||
view.setBigInt64(0, BigInt(value), false);
|
||
return bytes;
|
||
}
|
||
|
||
function uint16Bytes(value) {
|
||
const bytes = new Uint8Array(2);
|
||
const view = new DataView(bytes.buffer);
|
||
view.setUint16(0, Number(value), false);
|
||
return bytes;
|
||
}
|
||
|
||
function uint32Bytes(value) {
|
||
const bytes = new Uint8Array(4);
|
||
const view = new DataView(bytes.buffer);
|
||
view.setUint32(0, Number(value), false);
|
||
return bytes;
|
||
}
|
||
|
||
function uint64Bytes(value) {
|
||
const bytes = new Uint8Array(8);
|
||
const view = new DataView(bytes.buffer);
|
||
view.setBigUint64(0, BigInt(value), false);
|
||
return bytes;
|
||
}
|
||
|
||
function uint8Bytes(value) {
|
||
return new Uint8Array([Number(value) & 0xff]);
|
||
}
|
||
|
||
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
|
||
const DM2_PREFIX = utf8Bytes('SHiNE_dm2');
|
||
const DM2_TYPE_INCOMING = 1;
|
||
const DM2_TYPE_OUTGOING_COPY = 2;
|
||
const DM2_TYPE_READ_INCOMING = 3;
|
||
const DM2_TYPE_READ_OUTGOING_COPY = 4;
|
||
const DM_FORMAT_VERSION_MAJOR = 1;
|
||
const DM_FORMAT_VERSION_MINOR = 0;
|
||
const DM_MAX_ENCRYPTED_BODY_BYTES = 16384;
|
||
|
||
function ensureAsciiBytes(value, field, min = 1, max = 60) {
|
||
const text = String(value || '').trim();
|
||
const bytes = utf8Bytes(text);
|
||
if (bytes.length < min || bytes.length > max) {
|
||
throw new Error(`${field} должен быть ${min}..${max} ASCII-символов`);
|
||
}
|
||
for (let i = 0; i < bytes.length; i += 1) {
|
||
const code = bytes[i];
|
||
if (code < 0x20 || code > 0x7e) throw new Error(`${field} должен быть ASCII`);
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
function dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }) {
|
||
return `${fromLogin}|${toLogin}|${Number(timeMs)}|${Number(nonce)}`;
|
||
}
|
||
|
||
function dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) {
|
||
return `${dm2BaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`;
|
||
}
|
||
|
||
function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType }) {
|
||
const toBytes = ensureAsciiBytes(refToLogin, 'receipt.refToLogin');
|
||
const fromBytes = ensureAsciiBytes(refFromLogin, 'receipt.refFromLogin');
|
||
return concatBytes(
|
||
uint8Bytes(toBytes.length), toBytes,
|
||
uint8Bytes(fromBytes.length), fromBytes,
|
||
uint64Bytes(refTimeMs),
|
||
uint32Bytes(refNonce),
|
||
uint16Bytes(refType),
|
||
);
|
||
}
|
||
|
||
function parseSignedMessageBlockBytes(bytes) {
|
||
if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
|
||
let o = 0;
|
||
const read = (n) => {
|
||
if (o + n > bytes.length) throw new Error('BAD_LEN');
|
||
const out = bytes.slice(o, o + n);
|
||
o += n;
|
||
return out;
|
||
};
|
||
const readU8 = () => read(1)[0];
|
||
const readU16 = () => {
|
||
const part = read(2);
|
||
const view = new DataView(part.buffer, part.byteOffset, 2);
|
||
return view.getUint16(0, false);
|
||
};
|
||
const readU32 = () => {
|
||
const part = read(4);
|
||
const view = new DataView(part.buffer, part.byteOffset, 4);
|
||
return view.getUint32(0, false);
|
||
};
|
||
const readU64 = () => {
|
||
const part = read(8);
|
||
const view = new DataView(part.buffer, part.byteOffset, 8);
|
||
return Number(view.getBigUint64(0, false));
|
||
};
|
||
const readAscii = () => {
|
||
const len = readU8();
|
||
const part = read(len);
|
||
const text = new TextDecoder().decode(part);
|
||
for (let i = 0; i < part.length; i += 1) {
|
||
const c = part[i];
|
||
if (c < 0x20 || c > 0x7e) throw new Error('BAD_ASCII');
|
||
}
|
||
return text;
|
||
};
|
||
|
||
const startsWith = (prefix) => {
|
||
if (bytes.length < prefix.length) return false;
|
||
for (let i = 0; i < prefix.length; i += 1) {
|
||
if (bytes[i] !== prefix[i]) return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
if (startsWith(DM_PREFIX_V1)) {
|
||
read(DM_PREFIX_V1.length);
|
||
const formatVersionMajor = readU8();
|
||
const formatVersionMinor = readU8();
|
||
const toLogin = readAscii();
|
||
const fromLogin = readAscii();
|
||
const timeMs = readU64();
|
||
const nonce = readU32();
|
||
const messageType = readU16();
|
||
const revisionTimeMs = readU64();
|
||
const attachmentsCount = readU8();
|
||
if (attachmentsCount !== 0) throw new Error('ATTACHMENTS_DISABLED');
|
||
const encryptedBodyLen = readU32();
|
||
const encryptedBodyBytes = read(encryptedBodyLen);
|
||
const signatureBytes = read(64);
|
||
if (o !== bytes.length) throw new Error('BAD_LEN');
|
||
const signedBody = bytes.slice(0, bytes.length - 64);
|
||
const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce });
|
||
const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
|
||
const bodyText = new TextDecoder().decode(encryptedBodyBytes);
|
||
return {
|
||
toLogin,
|
||
fromLogin,
|
||
timeMs,
|
||
nonce,
|
||
messageType,
|
||
revisionTimeMs,
|
||
formatVersionMajor,
|
||
formatVersionMinor,
|
||
encryptedBodyBytes,
|
||
encryptedBodyText: bodyText,
|
||
text: bodyText,
|
||
bodyAttachments: [],
|
||
payloadBytes: encryptedBodyBytes,
|
||
signatureBytes,
|
||
signedBody,
|
||
rawBytes: bytes,
|
||
baseKey,
|
||
messageKey,
|
||
legacyFormat: false,
|
||
deleted: attachmentsCount === 0 && encryptedBodyLen === 0,
|
||
};
|
||
}
|
||
|
||
const prefix = read(DM2_PREFIX.length);
|
||
for (let i = 0; i < DM2_PREFIX.length; i += 1) {
|
||
if (prefix[i] !== DM2_PREFIX[i]) throw new Error('BAD_PREFIX');
|
||
}
|
||
|
||
const toLogin = readAscii();
|
||
const fromLogin = readAscii();
|
||
const timeMs = readU64();
|
||
const nonce = readU32();
|
||
const messageType = readU16();
|
||
const payloadLen = readU16();
|
||
const payloadBytes = read(payloadLen);
|
||
const signatureBytes = read(64);
|
||
if (o !== bytes.length) throw new Error('BAD_LEN');
|
||
|
||
const signedBody = bytes.slice(0, bytes.length - 64);
|
||
const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce });
|
||
const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
|
||
return {
|
||
toLogin,
|
||
fromLogin,
|
||
timeMs,
|
||
nonce,
|
||
messageType,
|
||
revisionTimeMs: 0,
|
||
encryptedBodyBytes: payloadBytes,
|
||
encryptedBodyText: new TextDecoder().decode(payloadBytes),
|
||
text: new TextDecoder().decode(payloadBytes),
|
||
bodyAttachments: [],
|
||
payloadBytes,
|
||
signatureBytes,
|
||
signedBody,
|
||
rawBytes: bytes,
|
||
baseKey,
|
||
messageKey,
|
||
legacyFormat: true,
|
||
deleted: false,
|
||
};
|
||
}
|
||
|
||
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
|
||
const keyBytes = utf8Bytes(String(key || ''));
|
||
const valueBytes = utf8Bytes(String(value || ''));
|
||
const prevHashBytes = hexToBytes(prevLineHashHex);
|
||
if (!keyBytes.length || !valueBytes.length) throw new Error('Пустые key/value для блока параметра');
|
||
if (prevHashBytes.length !== 32) throw new Error('prevLineHash должен быть 32 байта');
|
||
|
||
return concatBytes(
|
||
int32Bytes(lineCode),
|
||
int32Bytes(prevLineNumber),
|
||
prevHashBytes,
|
||
int32Bytes(thisLineNumber),
|
||
int16Bytes(keyBytes.length),
|
||
keyBytes,
|
||
int16Bytes(valueBytes.length),
|
||
valueBytes,
|
||
);
|
||
}
|
||
|
||
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 makeTextRepostBodyBytes({
|
||
lineCode,
|
||
prevLineNumber,
|
||
prevLineHashHex,
|
||
thisLineNumber,
|
||
toBlockchainName,
|
||
toBlockNumber,
|
||
toBlockHashHex,
|
||
text,
|
||
}) {
|
||
const message = String(text || '').trim();
|
||
if (!message) throw new Error('Комментарий к репосту обязателен');
|
||
|
||
const bch = String(toBlockchainName || '').trim();
|
||
if (!bch) throw new Error('toBlockchainName is required for repost');
|
||
const bchBytes = utf8Bytes(bch);
|
||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||
}
|
||
|
||
const blockNumber = Number(toBlockNumber);
|
||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||
throw new Error('Invalid toBlockNumber for repost');
|
||
}
|
||
|
||
const textBytes = utf8Bytes(message);
|
||
if (textBytes.length < 1 || textBytes.length > 65535) {
|
||
throw new Error('Repost comment must be 1..65535 UTF-8 bytes');
|
||
}
|
||
|
||
return concatBytes(
|
||
int32Bytes(lineCode),
|
||
int32Bytes(prevLineNumber),
|
||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||
int32Bytes(thisLineNumber),
|
||
int8Byte(bchBytes.length),
|
||
bchBytes,
|
||
int32Bytes(blockNumber),
|
||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||
int16Bytes(textBytes.length),
|
||
textBytes,
|
||
);
|
||
}
|
||
|
||
function makeTextEditPostBodyBytes({
|
||
lineCode,
|
||
prevLineNumber,
|
||
prevLineHashHex,
|
||
thisLineNumber,
|
||
toBlockNumber,
|
||
toBlockHashHex,
|
||
text,
|
||
}) {
|
||
const message = String(text || '').trim();
|
||
const targetBlockNumber = Number(toBlockNumber);
|
||
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
|
||
throw new Error('Invalid target block number for edit post');
|
||
}
|
||
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),
|
||
int32Bytes(targetBlockNumber),
|
||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||
int16Bytes(textBytes.length),
|
||
textBytes,
|
||
);
|
||
}
|
||
|
||
function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) {
|
||
const message = String(text || '').trim();
|
||
const targetBlockNumber = Number(toBlockNumber);
|
||
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
|
||
throw new Error('Invalid target block number for edit reply');
|
||
}
|
||
const textBytes = utf8Bytes(message);
|
||
if (textBytes.length > 65535) {
|
||
throw new Error('Message text must be 0..65535 UTF-8 bytes');
|
||
}
|
||
|
||
return concatBytes(
|
||
int32Bytes(targetBlockNumber),
|
||
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 normalizeChannelDescription(value) {
|
||
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
|
||
const bytes = utf8Bytes(text);
|
||
if (bytes.length > 200) {
|
||
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
||
}
|
||
return text;
|
||
}
|
||
|
||
function validatePersonalChannelName(value) {
|
||
const normalized = normalizeChannelDisplayName(value);
|
||
if (!normalized) return { ok: false, error: 'Введите логин пользователя.' };
|
||
const length = Array.from(normalized).length;
|
||
if (length < 1 || length > 20) {
|
||
return { ok: false, error: 'Логин: 1-20 символов.' };
|
||
}
|
||
if (!/^[A-Za-z0-9_]+$/.test(normalized)) {
|
||
return { ok: false, error: 'Логин: разрешены только латиница, цифры и _.' };
|
||
}
|
||
return { ok: true, normalized };
|
||
}
|
||
|
||
function makeCreateChannelBodyBytes({
|
||
lineCode,
|
||
prevLineNumber,
|
||
prevLineHashHex,
|
||
thisLineNumber,
|
||
channelName,
|
||
channelDescription = '',
|
||
channelType = CHANNEL_TYPE_PUBLIC,
|
||
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
|
||
}) {
|
||
const typeCode = Number(channelType);
|
||
const nameCheck = typeCode === CHANNEL_TYPE_PERSONAL
|
||
? validatePersonalChannelName(channelName)
|
||
: validateChannelDisplayName(channelName);
|
||
if (!nameCheck.ok) {
|
||
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
|
||
}
|
||
const cleanName = nameCheck.normalized;
|
||
const cleanDescription = normalizeChannelDescription(channelDescription);
|
||
|
||
const nameBytes = utf8Bytes(cleanName);
|
||
if (nameBytes.length < 1 || nameBytes.length > 255) {
|
||
throw new Error('Channel name must be 1..255 bytes');
|
||
}
|
||
|
||
const descriptionBytes = utf8Bytes(cleanDescription);
|
||
if (descriptionBytes.length > 200) {
|
||
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
||
}
|
||
const typeVer = Number(channelTypeVersion);
|
||
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
|
||
throw new Error('Некорректный тип канала.');
|
||
}
|
||
if (!Number.isFinite(typeVer) || typeVer < 0 || typeVer > 65535) {
|
||
throw new Error('Некорректная версия типа канала.');
|
||
}
|
||
|
||
return concatBytes(
|
||
int32Bytes(lineCode),
|
||
int32Bytes(prevLineNumber),
|
||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||
int32Bytes(thisLineNumber),
|
||
int8Byte(nameBytes.length),
|
||
nameBytes,
|
||
int16Bytes(descriptionBytes.length),
|
||
descriptionBytes,
|
||
uint16Bytes(typeCode),
|
||
uint16Bytes(typeVer),
|
||
);
|
||
}
|
||
|
||
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 }) {
|
||
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 resolveLatestLineStep(message) {
|
||
const lineStep = Number(message?.lineStep);
|
||
if (Number.isFinite(lineStep) && lineStep >= 0) return lineStep;
|
||
const fallbackByVersions = Number(message?.versionsTotal);
|
||
if (Number.isFinite(fallbackByVersions) && fallbackByVersions > 0) return Math.max(0, fallbackByVersions - 1);
|
||
return null;
|
||
}
|
||
|
||
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();
|
||
this.passwordKeyBundleCache = new Map();
|
||
this.passwordKeyBundleInFlight = new Map();
|
||
this.currentLogin = '';
|
||
this.currentSessionId = '';
|
||
this.remoteAddBlockSessionId = '';
|
||
}
|
||
|
||
async reconnect(serverUrl) {
|
||
const normalized = normalizeServerUrl(serverUrl);
|
||
if (normalized === this.serverUrl) return;
|
||
this.ws.close();
|
||
this.serverUrl = normalized;
|
||
this.ws = new WsJsonClient(this.serverUrl);
|
||
this.headerHashCache = new Map();
|
||
this.writeLocks.clear();
|
||
}
|
||
|
||
setActiveSessionContext({ login = '', sessionId = '' } = {}) {
|
||
this.currentLogin = String(login || '').trim();
|
||
this.currentSessionId = String(sessionId || '').trim();
|
||
}
|
||
|
||
clearActiveSessionContext() {
|
||
this.currentLogin = '';
|
||
this.currentSessionId = '';
|
||
}
|
||
|
||
setRemoteAddBlockSessionId(sessionId = '') {
|
||
this.remoteAddBlockSessionId = String(sessionId || '').trim();
|
||
}
|
||
|
||
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) {
|
||
const response = await this.ws.request('GetUser', { login });
|
||
if (response.status !== 200) throw opError('GetUser', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async ensureLoginFree(login) {
|
||
const payload = await this.getUser(login);
|
||
return payload.exists !== true;
|
||
}
|
||
|
||
async derivePasswordKeyBundle(login, password, options = {}) {
|
||
const normalizedLogin = String(login ?? '');
|
||
const normalizedPassword = String(password ?? '');
|
||
const cacheKey = `${normalizedLogin}\n${normalizedPassword}`;
|
||
const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : null;
|
||
const isCancelled = typeof options?.isCancelled === 'function' ? options.isCancelled : null;
|
||
|
||
if (this.passwordKeyBundleCache.has(cacheKey)) {
|
||
if (onProgress) onProgress({ percent: 100, stage: 'cached', message: 'Ключи уже сгенерированы в памяти.' });
|
||
return this.passwordKeyBundleCache.get(cacheKey);
|
||
}
|
||
if (this.passwordKeyBundleInFlight.has(cacheKey)) {
|
||
return this.passwordKeyBundleInFlight.get(cacheKey);
|
||
}
|
||
|
||
const task = (async () => {
|
||
if (onProgress) onProgress({ percent: 3, stage: 'prepare', message: 'Подготовка параметров генерации...' });
|
||
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
|
||
|
||
const masterSecret = await deriveMasterSecretFromPassword(normalizedPassword, {
|
||
login: normalizedLogin,
|
||
onProgress: (value01) => {
|
||
if (!onProgress) return;
|
||
const v = Math.max(0, Math.min(1, Number(value01) || 0));
|
||
const percent = Math.round(5 + (v * 88));
|
||
onProgress({ percent, stage: 'secret', message: 'Генерация секрета из пароля...' });
|
||
},
|
||
});
|
||
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
|
||
|
||
if (onProgress) onProgress({ percent: 93, stage: 'derive', message: 'Вычисление recovery key...' });
|
||
const recoveryPair = await deriveEd25519FromMasterSecret(masterSecret, 'recovery.key');
|
||
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
|
||
|
||
if (onProgress) onProgress({ percent: 95, stage: 'derive', message: 'Вычисление root key...' });
|
||
const rootPair = await deriveEd25519FromMasterSecret(masterSecret, 'root.key');
|
||
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
|
||
|
||
if (onProgress) onProgress({ percent: 97, stage: 'derive', message: 'Вычисление blockchain key...' });
|
||
const blockchainPair = await deriveEd25519FromMasterSecret(masterSecret, 'blockchain.key');
|
||
if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED');
|
||
|
||
if (onProgress) onProgress({ percent: 99, stage: 'derive', message: 'Вычисление client key...' });
|
||
const clientPair = await deriveEd25519FromMasterSecret(masterSecret, 'client.key');
|
||
const result = {
|
||
masterSecretB64: bytesToBase64(masterSecret),
|
||
recoveryPair,
|
||
rootPair,
|
||
blockchainPair,
|
||
clientPair,
|
||
};
|
||
this.passwordKeyBundleCache.set(cacheKey, result);
|
||
if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' });
|
||
return result;
|
||
})().finally(() => {
|
||
this.passwordKeyBundleInFlight.delete(cacheKey);
|
||
});
|
||
|
||
this.passwordKeyBundleInFlight.set(cacheKey, task);
|
||
return task;
|
||
}
|
||
|
||
async createAuthSession(login, keyBundle) {
|
||
const cleanLogin = (login || '').trim();
|
||
if (!cleanLogin) throw new Error('Введите логин');
|
||
const clientPair = keyBundle?.clientPair;
|
||
if (!clientPair) throw new Error('createAuthSession: не передан clientPair');
|
||
|
||
const sessionPair = await generateEd25519Pair();
|
||
const sessionKeyPub = await exportEd25519PublicKeyB64(sessionPair.publicKey);
|
||
const sessionKey = `ed25519/${sessionKeyPub}`;
|
||
const storagePwd = randomBase64(32);
|
||
|
||
const challengeResp = await this.ws.request('AuthChallenge', { login: cleanLogin });
|
||
if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp);
|
||
|
||
const authNonce = challengeResp?.payload?.authNonce;
|
||
if (!authNonce) throw new Error('AuthChallenge: сервер не вернул authNonce');
|
||
|
||
const timeMs = Date.now();
|
||
const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${sessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
|
||
const signatureB64 = await signBase64(clientPair.privateKey, preimage);
|
||
|
||
const createResp = await this.ws.request('CreateAuthSession', {
|
||
login: cleanLogin,
|
||
storagePwd,
|
||
sessionKey,
|
||
timeMs,
|
||
authNonce,
|
||
clientKey: clientPair.publicKeyB64,
|
||
signatureB64,
|
||
sessionType: SESSION_TYPE_CLIENT,
|
||
clientPlatform: makeClientPlatform(),
|
||
clientInfo: makeClientInfo(),
|
||
});
|
||
if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);
|
||
|
||
const sessionId = createResp?.payload?.sessionId;
|
||
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
||
|
||
return {
|
||
login: cleanLogin,
|
||
sessionId,
|
||
storagePwd,
|
||
sessionMaterial: {
|
||
sessionId,
|
||
sessionKey,
|
||
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
|
||
},
|
||
};
|
||
}
|
||
|
||
async registerUser(login, password) {
|
||
const cleanLogin = (login || '').trim();
|
||
if (!cleanLogin) throw new Error('Введите логин');
|
||
|
||
const isFree = await this.ensureLoginFree(cleanLogin);
|
||
if (!isFree) throw new Error('Этот логин уже занят');
|
||
|
||
const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password);
|
||
const clientPair = keyBundle?.clientPair;
|
||
|
||
const addResp = await this.ws.request('AddUser', {
|
||
login: cleanLogin,
|
||
blockchainName: `${cleanLogin}-${BCH_SUFFIX}`,
|
||
solanaKey: clientPair.publicKeyB64,
|
||
blockchainKey: keyBundle.blockchainPair.publicKeyB64,
|
||
clientKey: clientPair.publicKeyB64,
|
||
bchLimit: 1000000,
|
||
});
|
||
if (addResp.status !== 200) throw opError('AddUser', addResp);
|
||
|
||
const session = await this.createAuthSession(cleanLogin, keyBundle);
|
||
return { ...session, keyBundle };
|
||
}
|
||
|
||
async registerUserWithKeyBundle(login, keyBundle) {
|
||
const cleanLogin = (login || '').trim();
|
||
if (!cleanLogin) throw new Error('Введите логин');
|
||
const clientPair = keyBundle?.clientPair;
|
||
|
||
const addResp = await this.ws.request('AddUser', {
|
||
login: cleanLogin,
|
||
blockchainName: `${cleanLogin}-${BCH_SUFFIX}`,
|
||
solanaKey: clientPair.publicKeyB64,
|
||
blockchainKey: keyBundle.blockchainPair.publicKeyB64,
|
||
clientKey: clientPair.publicKeyB64,
|
||
bchLimit: 1000000,
|
||
});
|
||
if (addResp.status !== 200) throw opError('AddUser', addResp);
|
||
|
||
const session = await this.createAuthSession(cleanLogin, keyBundle);
|
||
return { ...session, keyBundle };
|
||
}
|
||
|
||
async createSessionForExistingUser(login, password) {
|
||
const cleanLogin = (login || '').trim();
|
||
if (!cleanLogin) throw new Error('Введите логин');
|
||
|
||
const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password);
|
||
const session = await this.createAuthSession(cleanLogin, keyBundle);
|
||
return { ...session, keyBundle };
|
||
}
|
||
|
||
async createSessionFromImportedSecrets(login, secrets) {
|
||
const cleanLogin = (login || '').trim();
|
||
if (!cleanLogin) throw new Error('В QR-коде нет логина');
|
||
const clientKey = String(secrets?.clientKey || secrets?.clientKey || '').trim();
|
||
if (!clientKey) throw new Error('В QR-коде нет client key для входа');
|
||
|
||
const privateKey = await importPkcs8Ed25519(clientKey);
|
||
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(clientKey);
|
||
const session = await this.createAuthSession(cleanLogin, {
|
||
clientPair: {
|
||
privateKey,
|
||
publicKeyB64,
|
||
},
|
||
});
|
||
return session;
|
||
}
|
||
|
||
async createDelegatedSessionWithClientKey({
|
||
login,
|
||
clientPrivPkcs8,
|
||
sessionKey,
|
||
sessionType = SESSION_TYPE_WALLET,
|
||
clientPlatform = 'Delegated session',
|
||
clientInfo = 'Delegated session via pairing',
|
||
}) {
|
||
const cleanLogin = String(login || '').trim();
|
||
const cleanSessionKey = String(sessionKey || '').trim();
|
||
const cleanClientPriv = String(clientPrivPkcs8 || '').trim();
|
||
if (!cleanLogin) throw new Error('createDelegatedSessionWithClientKey: пустой login');
|
||
if (!cleanSessionKey) throw new Error('createDelegatedSessionWithClientKey: пустой sessionKey');
|
||
if (!cleanClientPriv) throw new Error('createDelegatedSessionWithClientKey: пустой client private key');
|
||
|
||
const clientPrivateKey = await importPkcs8Ed25519(cleanClientPriv);
|
||
const clientPublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(cleanClientPriv);
|
||
const storagePwd = randomBase64(32);
|
||
const tempAuth = new AuthService(this.serverUrl);
|
||
|
||
try {
|
||
const challengeResp = await tempAuth.ws.request('AuthChallenge', { login: cleanLogin });
|
||
if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp);
|
||
|
||
const authNonce = challengeResp?.payload?.authNonce;
|
||
if (!authNonce) throw new Error('AuthChallenge: сервер не вернул authNonce');
|
||
|
||
const timeMs = Date.now();
|
||
const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${cleanSessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
|
||
const signatureB64 = await signBase64(clientPrivateKey, preimage);
|
||
|
||
const createResp = await tempAuth.ws.request('CreateAuthSession', {
|
||
login: cleanLogin,
|
||
storagePwd,
|
||
sessionKey: cleanSessionKey,
|
||
timeMs,
|
||
authNonce,
|
||
clientKey: clientPublicKeyB64,
|
||
signatureB64,
|
||
sessionType: Number(sessionType) || SESSION_TYPE_WALLET,
|
||
clientPlatform: String(clientPlatform || '').trim() || 'Delegated session',
|
||
clientInfo: String(clientInfo || '').trim() || 'Delegated session via pairing',
|
||
});
|
||
if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);
|
||
|
||
const sessionId = createResp?.payload?.sessionId;
|
||
if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId');
|
||
|
||
return {
|
||
login: cleanLogin,
|
||
sessionId,
|
||
storagePwd,
|
||
sessionKey: cleanSessionKey,
|
||
sessionType: Number(sessionType) || SESSION_TYPE_WALLET,
|
||
clientPlatform: String(clientPlatform || '').trim() || 'Delegated session',
|
||
};
|
||
} finally {
|
||
tempAuth.ws.close();
|
||
}
|
||
}
|
||
|
||
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
|
||
let currentSecrets = {};
|
||
try {
|
||
const loaded = await loadEncryptedUserSecrets(login, storagePwd);
|
||
if (loaded && typeof loaded === 'object') {
|
||
currentSecrets = loaded;
|
||
}
|
||
} catch {
|
||
// Если контейнера ещё нет или пароль новый для этого логина — создадим новый ниже.
|
||
}
|
||
|
||
const secrets = {
|
||
...currentSecrets,
|
||
clientKey: keyBundle.clientPair.privatePkcs8B64,
|
||
};
|
||
if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
|
||
if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
|
||
await saveEncryptedUserSecrets(login, storagePwd, secrets);
|
||
}
|
||
|
||
async persistSessionMaterial(login, sessionMaterial) {
|
||
await saveSessionMaterial(login, sessionMaterial);
|
||
}
|
||
|
||
|
||
async resumeSession(login, preferredSessionId = '') {
|
||
const cleanLogin = (login || '').trim();
|
||
if (!cleanLogin) throw new Error('Нет login для авто-входа');
|
||
|
||
const sessionMaterial = await loadSessionMaterial(cleanLogin);
|
||
if (!sessionMaterial?.sessionId || !sessionMaterial?.sessionKey || !sessionMaterial?.sessionPrivPkcs8) {
|
||
throw new Error('На устройстве нет сохраненного ключа сессии');
|
||
}
|
||
|
||
const targetSessionId = preferredSessionId || sessionMaterial.sessionId;
|
||
const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
|
||
|
||
const challengeResp = await this.ws.request('SessionChallenge', { sessionId: targetSessionId });
|
||
if (challengeResp.status !== 200) throw opError('SessionChallenge', challengeResp);
|
||
|
||
const nonce = challengeResp?.payload?.nonce;
|
||
if (!nonce) throw new Error('SessionChallenge: не вернулся nonce');
|
||
|
||
const timeMs = Date.now();
|
||
const preimage = `SESSION_LOGIN:${targetSessionId}:${timeMs}:${nonce}`;
|
||
const signatureB64 = await signBase64(privateKey, preimage);
|
||
|
||
const loginResp = await this.ws.request('SessionLogin', {
|
||
sessionId: targetSessionId,
|
||
sessionKey: sessionMaterial.sessionKey,
|
||
timeMs,
|
||
signatureB64,
|
||
sessionType: SESSION_TYPE_CLIENT,
|
||
clientPlatform: makeClientPlatform(),
|
||
clientInfo: makeClientInfo(),
|
||
});
|
||
if (loginResp.status !== 200) throw opError('SessionLogin', loginResp);
|
||
|
||
const storagePwd = loginResp?.payload?.storagePwd;
|
||
if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd');
|
||
|
||
return {
|
||
login: cleanLogin,
|
||
sessionId: targetSessionId,
|
||
storagePwd,
|
||
};
|
||
}
|
||
|
||
async listSessions() {
|
||
const response = await this.ws.request('ListSessions', {});
|
||
if (response.status !== 200) throw opError('ListSessions', response);
|
||
return response?.payload?.sessions || [];
|
||
}
|
||
|
||
async waitForSignal({ signalType, signalRequestId, timeoutMs = 15000 }) {
|
||
const cleanSignalType = String(signalType || '').trim();
|
||
const cleanSignalRequestId = String(signalRequestId || '').trim();
|
||
if (!cleanSignalType || !cleanSignalRequestId) {
|
||
throw new Error('waitForSignal: не переданы signalType/signalRequestId');
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
let unsubscribe = () => {};
|
||
const timer = window.setTimeout(() => {
|
||
unsubscribe();
|
||
reject(new Error(`Таймаут ожидания сигнала ${cleanSignalType}`));
|
||
}, timeoutMs);
|
||
|
||
unsubscribe = this.onEvent('IncomingSignal', (evt) => {
|
||
const payload = evt?.payload || {};
|
||
if (String(payload?.signalType || '').trim() !== cleanSignalType) return;
|
||
if (String(payload?.signalRequestId || '').trim() !== cleanSignalRequestId) return;
|
||
window.clearTimeout(timer);
|
||
unsubscribe();
|
||
resolve(payload);
|
||
});
|
||
});
|
||
}
|
||
|
||
async sendSignal({
|
||
toLogin,
|
||
targetMode = SIGNAL_TARGET_SINGLE,
|
||
targetSessionId = '',
|
||
signalType,
|
||
signalRequestId,
|
||
data = '',
|
||
storagePwd = '',
|
||
includeClientSignature = true,
|
||
timeMs = Date.now(),
|
||
} = {}) {
|
||
const cleanToLogin = String(toLogin || '').trim();
|
||
const cleanTargetMode = String(targetMode || '').trim();
|
||
const cleanTargetSessionId = String(targetSessionId || '').trim();
|
||
const cleanSignalType = String(signalType || '').trim();
|
||
const cleanSignalRequestId = String(signalRequestId || '').trim();
|
||
const cleanLogin = String(this.currentLogin || '').trim();
|
||
const cleanSessionId = String(this.currentSessionId || '').trim();
|
||
if (!cleanLogin || !cleanSessionId) throw new Error('SendSignal: нет активного login/sessionId');
|
||
if (!cleanToLogin || !cleanSignalType || !cleanSignalRequestId) {
|
||
throw new Error('SendSignal: не переданы toLogin/signalType/signalRequestId');
|
||
}
|
||
if (cleanTargetMode !== SIGNAL_TARGET_SINGLE && cleanTargetMode !== SIGNAL_TARGET_ALL) {
|
||
throw new Error('SendSignal: bad targetMode');
|
||
}
|
||
if (cleanTargetMode === SIGNAL_TARGET_SINGLE && !cleanTargetSessionId) {
|
||
throw new Error('SendSignal: targetSessionId обязателен для single_session');
|
||
}
|
||
|
||
const sessionMaterial = await loadSessionMaterial(cleanLogin);
|
||
if (!sessionMaterial?.sessionPrivPkcs8) {
|
||
throw new Error('На устройстве нет сохранённого session key для SendSignal');
|
||
}
|
||
const sessionPrivateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
|
||
|
||
const dataText = typeof data === 'string' ? data : JSON.stringify(data || {});
|
||
const dataSha256B64 = await sha256Base64FromText(dataText);
|
||
|
||
const sessionPreimage = `SEND_SIGNAL_SESSION:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`;
|
||
const sessionSignatureB64 = await signBase64(sessionPrivateKey, sessionPreimage);
|
||
|
||
let clientSignatureB64 = '';
|
||
if (includeClientSignature) {
|
||
if (!storagePwd) throw new Error('SendSignal: нужен storagePwd для подписи client key');
|
||
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||
const clientPrivatePkcs8 = String(secrets?.clientKey || '').trim();
|
||
if (!clientPrivatePkcs8) {
|
||
throw new Error('На устройстве нет сохранённого client key для SendSignal');
|
||
}
|
||
const clientPrivateKey = await importPkcs8Ed25519(clientPrivatePkcs8);
|
||
const clientPreimage = `SEND_SIGNAL_CLIENT:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`;
|
||
clientSignatureB64 = await signBase64(clientPrivateKey, clientPreimage);
|
||
}
|
||
|
||
const response = await this.ws.request('SendSignal', {
|
||
toLogin: cleanToLogin,
|
||
targetMode: cleanTargetMode,
|
||
targetSessionId: cleanTargetMode === SIGNAL_TARGET_SINGLE ? cleanTargetSessionId : '',
|
||
signalType: cleanSignalType,
|
||
signalRequestId: cleanSignalRequestId,
|
||
data: dataText,
|
||
timeMs: Number(timeMs),
|
||
sessionSignatureB64,
|
||
clientSignatureB64,
|
||
});
|
||
if (response.status !== 200) throw opError('SendSignal', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async closeSession(sessionId) {
|
||
const response = await this.ws.request('CloseActiveSession', { sessionId });
|
||
if (response.status !== 200) throw opError('CloseActiveSession', response);
|
||
}
|
||
|
||
async getTrustedDeviceLoginSettings() {
|
||
const response = await this.ws.request('GetTrustedDeviceLoginSettings', {});
|
||
if (response.status !== 200) throw opError('GetTrustedDeviceLoginSettings', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async upsertTrustedDeviceLoginSettings({ enabled, passwordHash = '' }) {
|
||
const response = await this.ws.request('UpsertTrustedDeviceLoginSettings', {
|
||
enabled: !!enabled,
|
||
passwordHash: String(passwordHash || '').trim(),
|
||
});
|
||
if (response.status !== 200) throw opError('UpsertTrustedDeviceLoginSettings', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async startTrustedDeviceLogin({
|
||
login,
|
||
passwordHash,
|
||
requesterSessionKey,
|
||
requesterSessionType = SESSION_TYPE_CLIENT,
|
||
requesterClientPlatform = makeClientPlatform(),
|
||
payloadType = 3,
|
||
}) {
|
||
const response = await this.ws.request('StartTrustedDeviceLogin', {
|
||
login: String(login || '').trim(),
|
||
passwordHash: String(passwordHash || '').trim(),
|
||
requesterSessionKey: String(requesterSessionKey || '').trim(),
|
||
requesterSessionType: Number(requesterSessionType) || SESSION_TYPE_CLIENT,
|
||
requesterClientPlatform: String(requesterClientPlatform || '').trim() || makeClientPlatform(),
|
||
payloadType: Number(payloadType) || 3,
|
||
});
|
||
if (response.status !== 200) throw opError('StartTrustedDeviceLogin', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async listTrustedDeviceLoginRequests() {
|
||
const response = await this.ws.request('ListTrustedDeviceLoginRequests', {});
|
||
if (response.status !== 200) throw opError('ListTrustedDeviceLoginRequests', response);
|
||
return Array.isArray(response?.payload?.requests) ? response.payload.requests : [];
|
||
}
|
||
|
||
async approveTrustedDeviceLogin(pairingId, encryptedPayload) {
|
||
const response = await this.ws.request('ApproveTrustedDeviceLogin', {
|
||
pairingId: String(pairingId || '').trim(),
|
||
encryptedPayload: String(encryptedPayload || '').trim(),
|
||
});
|
||
if (response.status !== 200) throw opError('ApproveTrustedDeviceLogin', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async rejectTrustedDeviceLogin(pairingId, reason = '') {
|
||
const response = await this.ws.request('RejectTrustedDeviceLogin', {
|
||
pairingId: String(pairingId || '').trim(),
|
||
reason: String(reason || '').trim(),
|
||
});
|
||
if (response.status !== 200) throw opError('RejectTrustedDeviceLogin', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async cancelTrustedDeviceLogin(pairingId, requesterSessionKey) {
|
||
const response = await this.ws.request('CancelTrustedDeviceLogin', {
|
||
pairingId: String(pairingId || '').trim(),
|
||
requesterSessionKey: String(requesterSessionKey || '').trim(),
|
||
});
|
||
if (response.status !== 200) throw opError('CancelTrustedDeviceLogin', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async getTrustedDeviceLoginStatus(pairingId) {
|
||
const response = await this.ws.request('GetTrustedDeviceLoginStatus', {
|
||
pairingId: String(pairingId || '').trim(),
|
||
});
|
||
if (response.status !== 200) throw opError('GetTrustedDeviceLoginStatus', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async upsertEspPairingSettings(args) { return this.upsertTrustedDeviceLoginSettings(args); }
|
||
async startEspPairing(args) { return this.startTrustedDeviceLogin(args); }
|
||
async listEspPairingRequests() { return this.listTrustedDeviceLoginRequests(); }
|
||
async approveEspPairing(pairingId, encryptedPayload) { return this.approveTrustedDeviceLogin(pairingId, encryptedPayload); }
|
||
async rejectEspPairing(pairingId, reason = '') { return this.rejectTrustedDeviceLogin(pairingId, reason); }
|
||
async cancelEspPairing(pairingId, requesterSessionKey) { return this.cancelTrustedDeviceLogin(pairingId, requesterSessionKey); }
|
||
async getEspPairingStatus(pairingId) { return this.getTrustedDeviceLoginStatus(pairingId); }
|
||
|
||
async listSubscriptionsFeed(login, limit = 200) {
|
||
const response = await this.ws.request('ListSubscriptionsFeed', { login, limit });
|
||
if (response.status !== 200) throw opError('ListSubscriptionsFeed', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
|
||
const normalizedChannel = {
|
||
ownerBlockchainName: String(channel?.ownerBlockchainName || '').trim(),
|
||
channelRootBlockNumber: Number(channel?.channelRootBlockNumber),
|
||
channelRootBlockHash: String(channel?.channelRootBlockHash || '').trim(),
|
||
};
|
||
const payload = { channel: normalizedChannel, 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, login = '') {
|
||
const normalizedMessage = {
|
||
blockchainName: String(message?.blockchainName || '').trim(),
|
||
blockNumber: Number(message?.blockNumber),
|
||
blockHash: String(message?.blockHash || '').trim(),
|
||
};
|
||
const payload = { message: normalizedMessage, 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 resolveFreshBlockchainCursor(login) {
|
||
const cleanLogin = String(login || '').trim();
|
||
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);
|
||
return {
|
||
blockchainName,
|
||
cursor: {
|
||
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
||
serverLastGlobalHash: freshHash,
|
||
},
|
||
};
|
||
}
|
||
|
||
async submitPreparedAddBlock({ login, storagePwd, blockchainName, blockNumber, prevBlockHash, preimage }) {
|
||
const cleanLogin = String(login || '').trim();
|
||
const cleanBlockchainName = String(blockchainName || '').trim();
|
||
const cleanPrevBlockHash = normalizeHex32(prevBlockHash, ZERO_HASH_HEX);
|
||
if (!cleanLogin || !cleanBlockchainName) throw new Error('submitPreparedAddBlock: missing login/blockchainName');
|
||
if (!(preimage instanceof Uint8Array) || preimage.length === 0) {
|
||
throw new Error('submitPreparedAddBlock: bad preimage');
|
||
}
|
||
|
||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||
const blockchainPrivatePkcs8 = String(savedKeys?.blockchainKey || '').trim();
|
||
if (blockchainPrivatePkcs8) {
|
||
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
||
const hash32 = await sha256Bytes(preimage);
|
||
const signatureBytes = await signBytes(privateKey, hash32);
|
||
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
|
||
return this.ws.request('AddBlock', {
|
||
blockchainName: cleanBlockchainName,
|
||
blockNumber: Number(blockNumber),
|
||
prevBlockHash: cleanPrevBlockHash,
|
||
blockBytesB64: bytesToBase64(fullBlock),
|
||
});
|
||
}
|
||
|
||
const remoteSessionId = String(this.remoteAddBlockSessionId || '').trim();
|
||
if (!remoteSessionId) {
|
||
throw new Error('На устройстве нет blockchain key и не выбрана homeserver-сессия для remote AddBlock');
|
||
}
|
||
|
||
const signalRequestId = createSignalRequestId('remote-addblock');
|
||
const responseWait = this.waitForSignal({
|
||
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT,
|
||
signalRequestId,
|
||
timeoutMs: 20000,
|
||
});
|
||
|
||
const signalData = {
|
||
operation: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
|
||
signalRequestId,
|
||
login: cleanLogin,
|
||
blockchainName: cleanBlockchainName,
|
||
blockNumber: Number(blockNumber),
|
||
prevBlockHash: cleanPrevBlockHash,
|
||
blockPreimageB64: bytesToBase64(preimage),
|
||
};
|
||
|
||
await this.sendSignal({
|
||
toLogin: cleanLogin,
|
||
targetMode: SIGNAL_TARGET_SINGLE,
|
||
targetSessionId: remoteSessionId,
|
||
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
|
||
signalRequestId,
|
||
data: JSON.stringify(signalData),
|
||
storagePwd,
|
||
includeClientSignature: true,
|
||
});
|
||
|
||
const signalPayload = await responseWait;
|
||
let result = {};
|
||
try {
|
||
result = JSON.parse(String(signalPayload?.data || '{}'));
|
||
} catch {
|
||
throw new Error('Некорректный ответ remote AddBlock от homeserver');
|
||
}
|
||
if (!result?.ok) {
|
||
throw new Error(String(result?.errorMessage || result?.error || 'remote_addblock_failed'));
|
||
}
|
||
|
||
return {
|
||
status: 200,
|
||
payload: {
|
||
serverLastGlobalNumber: Number(result?.serverLastGlobalNumber ?? blockNumber),
|
||
serverLastGlobalHash: String(result?.serverLastGlobalHash || ZERO_HASH_HEX),
|
||
remote: true,
|
||
},
|
||
};
|
||
}
|
||
|
||
async runAddBlockWithRetry({ login, storagePwd, resolveFreshState, buildPreimage }) {
|
||
let freshState = await resolveFreshState();
|
||
let blockchainName = String(freshState?.blockchainName || '').trim();
|
||
if (!blockchainName) throw new Error('runAddBlockWithRetry: blockchainName is empty');
|
||
|
||
const tryAdd = async (cursor) => {
|
||
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
||
const prevBlockHash = normalizeHex32(cursor?.serverLastGlobalHash, ZERO64);
|
||
const preimage = await buildPreimage({ blockNumber, prevBlockHash, blockchainName });
|
||
return this.submitPreparedAddBlock({
|
||
login,
|
||
storagePwd,
|
||
blockchainName,
|
||
blockNumber,
|
||
prevBlockHash,
|
||
preimage,
|
||
});
|
||
};
|
||
|
||
let cursor = freshState.cursor;
|
||
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);
|
||
} else {
|
||
freshState = await resolveFreshState();
|
||
blockchainName = String(freshState?.blockchainName || blockchainName).trim() || blockchainName;
|
||
cursor = freshState.cursor;
|
||
response = await tryAdd(cursor);
|
||
}
|
||
}
|
||
|
||
if (response.status !== 200) throw opError('AddBlock', response);
|
||
return {
|
||
response,
|
||
blockchainName,
|
||
};
|
||
}
|
||
|
||
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 { response, blockchainName } = await this.runAddBlockWithRetry({
|
||
login: cleanLogin,
|
||
storagePwd,
|
||
resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin),
|
||
buildPreimage: async ({ blockNumber, prevBlockHash }) => buildBlockPreimage({
|
||
prevBlockHashHex: prevBlockHash,
|
||
blockNumber,
|
||
msgType,
|
||
msgSubType,
|
||
msgVersion,
|
||
bodyBytes,
|
||
}),
|
||
});
|
||
|
||
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 addBlockRepost({ login, channel, message, text, storagePwd }) {
|
||
const cleanLogin = String(login || '').trim();
|
||
if (!cleanLogin) throw new Error('Missing login');
|
||
const cleanText = String(text || '').trim();
|
||
if (!cleanText) throw new Error('Комментарий к репосту обязателен');
|
||
const target = normalizeMessageRefTarget(message, 'repost');
|
||
const selector = channel || {};
|
||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
||
const root = Number(selector?.channelRootBlockNumber);
|
||
const key = `repost:${cleanLogin}:${owner}:${root}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
|
||
|
||
return this.runWriteLocked(key, async () => {
|
||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||
if (!owner || !Number.isFinite(root) || root < 0) throw new Error('Invalid channel selector');
|
||
if (owner !== blockchainName) throw new Error('Repost 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 === root);
|
||
if (!rootChannel) throw new Error('Channel root not found');
|
||
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||
}
|
||
|
||
let prevLineNumber = root;
|
||
let prevLineHashHex = rootHashHex;
|
||
let thisLineNumber = 0;
|
||
try {
|
||
const latestPayload = await this.getChannelMessages({
|
||
ownerBlockchainName: owner,
|
||
channelRootBlockNumber: root,
|
||
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;
|
||
thisLineNumber = Number.isFinite(latestLineStep)
|
||
? Math.max(0, latestLineStep + 1)
|
||
: 1;
|
||
}
|
||
} catch {
|
||
// fallback to root anchor
|
||
}
|
||
|
||
const bodyBytes = makeTextRepostBodyBytes({
|
||
lineCode: root,
|
||
prevLineNumber,
|
||
prevLineHashHex,
|
||
thisLineNumber,
|
||
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_REPOST,
|
||
msgVersion: 1,
|
||
bodyBytes,
|
||
});
|
||
});
|
||
}
|
||
|
||
async addBlockEditMessage({
|
||
login,
|
||
message,
|
||
text,
|
||
storagePwd,
|
||
isChannelPost = false,
|
||
channel = null,
|
||
}) {
|
||
const cleanLogin = String(login || '').trim();
|
||
const cleanText = String(text || '').trim();
|
||
const target = normalizeMessageRefTarget(message, 'edit');
|
||
const lockKey = `edit:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}:${isChannelPost ? 'post' : 'reply'}`;
|
||
|
||
return this.runWriteLocked(lockKey, async () => {
|
||
if (isChannelPost) {
|
||
const selector = channel || {};
|
||
const ownerBlockchainName = String(selector?.ownerBlockchainName || target.blockchainName || '').trim();
|
||
const lineCode = Number(selector?.channelRootBlockNumber);
|
||
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
||
throw new Error('Invalid channel selector for edit');
|
||
}
|
||
|
||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||
if (rootHashHex === ZERO64) {
|
||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, ownerBlockchainName);
|
||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
||
if (rootChannel) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||
}
|
||
|
||
let prevLineNumber = lineCode;
|
||
let prevLineHashHex = rootHashHex;
|
||
let thisLineNumber = 1;
|
||
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 latestVersions = Array.isArray(latestMessage?.versions) ? latestMessage.versions : [];
|
||
const latestVersion = latestVersions[latestVersions.length - 1] || null;
|
||
const latestBlockNumber = Number(latestVersion?.blockNumber ?? latestMessage?.messageRef?.blockNumber);
|
||
const latestBlockHash = normalizeHex32(latestVersion?.blockHash ?? latestMessage?.messageRef?.blockHash, '');
|
||
const latestLineStep = resolveLatestLineStep(latestMessage);
|
||
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
||
prevLineNumber = latestBlockNumber;
|
||
prevLineHashHex = latestBlockHash;
|
||
thisLineNumber = Number.isFinite(latestLineStep)
|
||
? Math.max(0, latestLineStep)
|
||
: 1;
|
||
}
|
||
} catch {
|
||
// fallback to root anchor
|
||
}
|
||
|
||
const bodyBytes = makeTextEditPostBodyBytes({
|
||
lineCode,
|
||
prevLineNumber,
|
||
prevLineHashHex,
|
||
thisLineNumber,
|
||
toBlockNumber: target.blockNumber,
|
||
toBlockHashHex: target.blockHash,
|
||
text: cleanText,
|
||
});
|
||
|
||
return this.addBlockSigned({
|
||
login: cleanLogin,
|
||
storagePwd,
|
||
msgType: MSG_TYPE_TEXT,
|
||
msgSubType: MSG_SUBTYPE_TEXT_EDIT_POST,
|
||
msgVersion: 1,
|
||
bodyBytes,
|
||
});
|
||
}
|
||
|
||
const bodyBytes = makeTextEditReplyBodyBytes({
|
||
toBlockNumber: target.blockNumber,
|
||
toBlockHashHex: target.blockHash,
|
||
text: cleanText,
|
||
});
|
||
|
||
return this.addBlockSigned({
|
||
login: cleanLogin,
|
||
storagePwd,
|
||
msgType: MSG_TYPE_TEXT,
|
||
msgSubType: MSG_SUBTYPE_TEXT_EDIT_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 || ''),
|
||
channelTypeCode: Number(item?.channel?.channelTypeCode ?? CHANNEL_TYPE_PUBLIC),
|
||
}))
|
||
.filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
|
||
}
|
||
|
||
async addBlockCreateChannel({
|
||
login,
|
||
channelName,
|
||
channelDescription = '',
|
||
channelType = CHANNEL_TYPE_PUBLIC,
|
||
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
|
||
storagePwd,
|
||
}) {
|
||
const cleanLogin = (login || '').trim();
|
||
if (!cleanLogin) throw new Error('Missing login');
|
||
|
||
const typeCode = Number(channelType);
|
||
const nameCheck = typeCode === CHANNEL_TYPE_PERSONAL
|
||
? validatePersonalChannelName(channelName)
|
||
: validateChannelDisplayName(channelName);
|
||
if (!nameCheck.ok) {
|
||
throw new Error(typeCode === CHANNEL_TYPE_PERSONAL ? nameCheck.error : channelNameErrorText(nameCheck.code));
|
||
}
|
||
const cleanChannelName = normalizeChannelDisplayName(nameCheck.normalized);
|
||
const cleanChannelDescription = normalizeChannelDescription(channelDescription);
|
||
const channelSlug = toCanonicalChannelSlug(cleanChannelName);
|
||
const typeVersion = Number(channelTypeVersion);
|
||
const key = `create-channel:${cleanLogin}:${typeCode}:${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;
|
||
}
|
||
|
||
let payload;
|
||
try {
|
||
payload = await this.addBlockSigned({
|
||
login: cleanLogin,
|
||
storagePwd,
|
||
msgType: MSG_TYPE_TECH,
|
||
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
||
msgVersion: CREATE_CHANNEL_BODY_VERSION,
|
||
bodyBytes: makeCreateChannelBodyBytes({
|
||
lineCode: 0,
|
||
prevLineNumber,
|
||
prevLineHashHex,
|
||
thisLineNumber,
|
||
channelName: cleanChannelName,
|
||
channelDescription: cleanChannelDescription,
|
||
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 = {
|
||
ownerBlockchainName: blockchainName,
|
||
channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
|
||
channelRootBlockHash: normalizeHex32(payload?.serverLastGlobalHash, ZERO64),
|
||
};
|
||
|
||
return {
|
||
...payload,
|
||
channel: {
|
||
...selector,
|
||
},
|
||
};
|
||
});
|
||
}
|
||
|
||
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 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 bodyBytes = makeTextPostBodyBytes({
|
||
lineCode,
|
||
prevLineNumber,
|
||
prevLineHashHex,
|
||
thisLineNumber,
|
||
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);
|
||
}
|
||
|
||
async upsertPushToken({ endpoint, p256dhKey, authKey, sessionId, platform = 'web', userAgent = navigator.userAgent || '' }) {
|
||
const response = await this.ws.request('UpsertPushToken', { endpoint, p256dhKey, authKey, sessionId, platform, userAgent });
|
||
if (response.status !== 200) throw opError('UpsertPushToken', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async sendTestWebPush({ login = '', sessionId = '', title = '', text = '' } = {}) {
|
||
const payload = {};
|
||
if (String(login || '').trim()) payload.login = String(login || '').trim();
|
||
if (String(sessionId || '').trim()) payload.sessionId = String(sessionId || '').trim();
|
||
if (String(title || '').trim()) payload.title = String(title || '').trim();
|
||
if (String(text || '').trim()) payload.text = String(text || '').trim();
|
||
const response = await this.ws.request('SendTestWebPush', payload);
|
||
if (response.status !== 200) throw opError('SendTestWebPush', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async buildSignedDm2Block({
|
||
login,
|
||
toLogin,
|
||
storagePwd,
|
||
timeMs,
|
||
nonce,
|
||
messageType,
|
||
payloadBytes,
|
||
}) {
|
||
const cleanFromLogin = String(login || '').trim();
|
||
const cleanToLogin = String(toLogin || '').trim();
|
||
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
|
||
|
||
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
|
||
const clientPriv = secrets?.clientKey || secrets?.clientKey;
|
||
if (!clientPriv) throw new Error('Не найден приватный clientKey');
|
||
const privateKey = await importPkcs8Ed25519(clientPriv);
|
||
|
||
const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
|
||
const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
|
||
if (!(payloadBytes instanceof Uint8Array) || payloadBytes.length < 1 || payloadBytes.length > 4096) {
|
||
throw new Error('payload должен быть 1..4096 байт');
|
||
}
|
||
|
||
const preimage = concatBytes(
|
||
DM2_PREFIX,
|
||
uint8Bytes(toBytes.length), toBytes,
|
||
uint8Bytes(fromBytes.length), fromBytes,
|
||
uint64Bytes(timeMs),
|
||
uint32Bytes(nonce),
|
||
uint16Bytes(messageType),
|
||
uint16Bytes(payloadBytes.length),
|
||
payloadBytes,
|
||
);
|
||
const signature = await signBytes(privateKey, preimage);
|
||
return concatBytes(preimage, signature);
|
||
}
|
||
|
||
async buildSignedDmV1Block({
|
||
login,
|
||
toLogin,
|
||
storagePwd,
|
||
timeMs,
|
||
nonce,
|
||
messageType,
|
||
revisionTimeMs = 0,
|
||
encryptedBodyBytes = new Uint8Array(0),
|
||
}) {
|
||
const cleanFromLogin = String(login || '').trim();
|
||
const cleanToLogin = String(toLogin || '').trim();
|
||
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
|
||
if (!(encryptedBodyBytes instanceof Uint8Array) || encryptedBodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
|
||
throw new Error(`encryptedBody должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`);
|
||
}
|
||
|
||
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
|
||
const clientPriv = secrets?.clientKey || secrets?.clientKey;
|
||
if (!clientPriv) throw new Error('Не найден приватный clientKey');
|
||
const privateKey = await importPkcs8Ed25519(clientPriv);
|
||
|
||
const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
|
||
const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
|
||
const preimage = concatBytes(
|
||
DM_PREFIX_V1,
|
||
uint8Bytes(DM_FORMAT_VERSION_MAJOR),
|
||
uint8Bytes(DM_FORMAT_VERSION_MINOR),
|
||
uint8Bytes(toBytes.length), toBytes,
|
||
uint8Bytes(fromBytes.length), fromBytes,
|
||
uint64Bytes(timeMs),
|
||
uint32Bytes(nonce),
|
||
uint16Bytes(messageType),
|
||
uint64Bytes(revisionTimeMs),
|
||
uint8Bytes(0),
|
||
uint32Bytes(encryptedBodyBytes.length),
|
||
encryptedBodyBytes,
|
||
);
|
||
const signature = await signBytes(privateKey, preimage);
|
||
return concatBytes(preimage, signature);
|
||
}
|
||
|
||
parseSignedMessageBlob(blobB64) {
|
||
const bytes = base64ToBytes(String(blobB64 || '').trim());
|
||
return parseSignedMessageBlockBytes(bytes);
|
||
}
|
||
|
||
parseReadReceiptPayload(payloadBytes) {
|
||
if (!(payloadBytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
|
||
let o = 0;
|
||
const read = (n) => {
|
||
if (o + n > payloadBytes.length) throw new Error('BAD_RECEIPT_LEN');
|
||
const out = payloadBytes.slice(o, o + n);
|
||
o += n;
|
||
return out;
|
||
};
|
||
const readU8 = () => read(1)[0];
|
||
const readU16 = () => {
|
||
const part = read(2);
|
||
return new DataView(part.buffer, part.byteOffset, 2).getUint16(0, false);
|
||
};
|
||
const readU32 = () => {
|
||
const part = read(4);
|
||
return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false);
|
||
};
|
||
const readU64 = () => {
|
||
const part = read(8);
|
||
return Number(new DataView(part.buffer, part.byteOffset, 8).getBigUint64(0, false));
|
||
};
|
||
const readAscii = () => {
|
||
const len = readU8();
|
||
const part = read(len);
|
||
return new TextDecoder().decode(part);
|
||
};
|
||
const refToLogin = readAscii();
|
||
const refFromLogin = readAscii();
|
||
const refTimeMs = readU64();
|
||
const refNonce = readU32();
|
||
const refType = readU16();
|
||
if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_LEN');
|
||
return { refToLogin, refFromLogin, refTimeMs, refNonce, refType };
|
||
}
|
||
|
||
async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) {
|
||
const body = { incomingBlobB64, outgoingBlobB64 };
|
||
const primaryOp = 'ReceiveOutcomingMessage';
|
||
let response = await this.ws.request(primaryOp, body);
|
||
if (response.status === 404) {
|
||
response = await this.ws.request('SendMessagePair', body);
|
||
if (response.status !== 200) throw opError('SendMessagePair', response);
|
||
return response.payload || {};
|
||
}
|
||
if (response.status !== 200) throw opError(primaryOp, response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async sendDirectMessageRevision({
|
||
login,
|
||
toLogin,
|
||
text = '',
|
||
storagePwd,
|
||
timeMs,
|
||
nonce,
|
||
revisionTimeMs = 0,
|
||
}) {
|
||
const cleanFromLogin = String(login || '').trim();
|
||
const cleanToLogin = String(toLogin || '').trim();
|
||
const cleanText = String(text || '');
|
||
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
||
const normalizedTimeMs = Number(timeMs);
|
||
const normalizedNonce = Number(nonce);
|
||
const normalizedRevisionTimeMs = Number(revisionTimeMs || 0);
|
||
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
|
||
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
|
||
if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs < 0) throw new Error('Некорректный revisionTimeMs');
|
||
const encryptedBodyBytes = utf8Bytes(cleanText);
|
||
|
||
const incomingBlock = await this.buildSignedDmV1Block({
|
||
login: cleanFromLogin,
|
||
toLogin: cleanToLogin,
|
||
storagePwd,
|
||
timeMs: normalizedTimeMs,
|
||
nonce: normalizedNonce,
|
||
messageType: DM2_TYPE_INCOMING,
|
||
revisionTimeMs: normalizedRevisionTimeMs,
|
||
encryptedBodyBytes,
|
||
});
|
||
const outgoingBlock = await this.buildSignedDmV1Block({
|
||
login: cleanFromLogin,
|
||
toLogin: cleanToLogin,
|
||
storagePwd,
|
||
timeMs: normalizedTimeMs,
|
||
nonce: normalizedNonce,
|
||
messageType: DM2_TYPE_OUTGOING_COPY,
|
||
revisionTimeMs: normalizedRevisionTimeMs,
|
||
encryptedBodyBytes,
|
||
});
|
||
|
||
const payload = await this.sendMessagePair({
|
||
incomingBlobB64: bytesToBase64(incomingBlock),
|
||
outgoingBlobB64: bytesToBase64(outgoingBlock),
|
||
});
|
||
return {
|
||
...payload,
|
||
localIncomingBlobB64: bytesToBase64(incomingBlock),
|
||
localOutgoingBlobB64: bytesToBase64(outgoingBlock),
|
||
localBaseKey: dm2BaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs: normalizedTimeMs, nonce: normalizedNonce }),
|
||
};
|
||
}
|
||
|
||
async sendDirectMessage({ login, toLogin, text, storagePwd }) {
|
||
const cleanText = String(text || '');
|
||
if (!cleanText) throw new Error('Пустое сообщение');
|
||
return this.sendDirectMessageRevision({
|
||
login,
|
||
toLogin,
|
||
text: cleanText,
|
||
storagePwd,
|
||
timeMs: Date.now(),
|
||
nonce: Math.floor(Math.random() * 0x100000000),
|
||
revisionTimeMs: 0,
|
||
});
|
||
}
|
||
|
||
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs }) {
|
||
return this.sendDirectMessageRevision({
|
||
login,
|
||
toLogin,
|
||
text: '',
|
||
storagePwd,
|
||
timeMs,
|
||
nonce,
|
||
revisionTimeMs,
|
||
});
|
||
}
|
||
|
||
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce, refType = DM2_TYPE_INCOMING }) {
|
||
const timeMs = Date.now();
|
||
const nonce = Math.floor(Math.random() * 0x100000000);
|
||
const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType });
|
||
|
||
const type3 = await this.buildSignedDm2Block({
|
||
login,
|
||
toLogin,
|
||
storagePwd,
|
||
timeMs,
|
||
nonce,
|
||
messageType: DM2_TYPE_READ_INCOMING,
|
||
payloadBytes: payload,
|
||
});
|
||
const type4 = await this.buildSignedDm2Block({
|
||
login,
|
||
toLogin,
|
||
storagePwd,
|
||
timeMs,
|
||
nonce,
|
||
messageType: DM2_TYPE_READ_OUTGOING_COPY,
|
||
payloadBytes: payload,
|
||
});
|
||
return this.sendMessagePair({
|
||
incomingBlobB64: bytesToBase64(type3),
|
||
outgoingBlobB64: bytesToBase64(type4),
|
||
});
|
||
}
|
||
|
||
async ackSessionDelivery(messageKey) {
|
||
const response = await this.ws.request('AckSessionDelivery', { messageKey });
|
||
if (response.status !== 200) throw opError('AckSessionDelivery', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
|
||
async callInviteBroadcast({ toLogin, callId, type = 100 }) {
|
||
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type });
|
||
if (response.status !== 200) throw opError('CallInviteBroadcast', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async callSignalToSession({ toLogin, targetSessionId, callId, type, data = '' }) {
|
||
const response = await this.ws.request('CallSignalToSession', { toLogin, targetSessionId, callId, type, data });
|
||
if (response.status !== 200) throw opError('CallSignalToSession', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async getCallIceConfig() {
|
||
const response = await this.ws.request('GetCallIceConfig', {});
|
||
if (response.status !== 200) throw opError('GetCallIceConfig', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async sendCallDeliveryReport(payload = {}) {
|
||
const response = await this.ws.request('CallDeliveryReport', payload);
|
||
if (response.status !== 200) throw opError('CallDeliveryReport', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async listContacts() {
|
||
const response = await this.ws.request('ListContacts', {});
|
||
if (response.status !== 200) throw opError('ListContacts', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
|
||
async addCloseFriend(toLogin) {
|
||
const response = await this.ws.request('AddCloseFriend', { toLogin });
|
||
if (response.status !== 200) throw opError('AddCloseFriend', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async getUserConnectionsGraph(login) {
|
||
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
||
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async searchUsers(prefix) {
|
||
const response = await this.ws.request('SearchUsers', { prefix });
|
||
if (response.status !== 200) throw opError('SearchUsers', response);
|
||
return response.payload?.logins || [];
|
||
}
|
||
|
||
|
||
|
||
async getUserParam(login, param) {
|
||
const cleanLogin = (login || '').trim();
|
||
const cleanParam = (param || '').trim();
|
||
if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param');
|
||
|
||
const response = await this.ws.request('GetUserParam', { login: cleanLogin, param: cleanParam });
|
||
if (response.status === 200) return response.payload || {};
|
||
|
||
if (response.status === 404 || response.status === 204) return {};
|
||
|
||
throw opError('GetUserParam', response);
|
||
}
|
||
|
||
async listUserParams(login) {
|
||
const cleanLogin = (login || '').trim();
|
||
if (!cleanLogin) throw new Error('Не передан login');
|
||
|
||
const response = await this.ws.request('ListUserParams', { login: cleanLogin });
|
||
if (response.status !== 200) throw opError('ListUserParams', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async getTestFreeAvatarQuota() {
|
||
const response = await this.ws.request('TestGetFreeAvatarQuota', {});
|
||
if (response.status !== 200) throw opError('TestGetFreeAvatarQuota', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async uploadTestFreeAvatar({ contentType, fileBytesBase64, sha256Hex }) {
|
||
const response = await this.ws.request('TestUploadFreeAvatar', {
|
||
contentType,
|
||
fileBytesBase64,
|
||
sha256Hex,
|
||
}, 60000);
|
||
if (response.status !== 200) throw opError('TestUploadFreeAvatar', response);
|
||
return response.payload || {};
|
||
}
|
||
|
||
async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) {
|
||
const cleanKind = String(kind || '').trim().toLowerCase();
|
||
const kinds = CONNECTION_SUBTYPES[cleanKind];
|
||
if (!kinds) throw new Error(`Неподдерживаемый тип связи: ${kind}`);
|
||
const subType = enabled ? kinds.on : kinds.off;
|
||
return this.addBlockConnection({ login, toLogin, subType, storagePwd });
|
||
}
|
||
|
||
async addBlockUserParam({ login, param, value, storagePwd }) {
|
||
const cleanLogin = (login || '').trim();
|
||
const cleanParam = (param || '').trim();
|
||
const cleanValue = String(value ?? '').trim();
|
||
if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param.');
|
||
if (!cleanValue) throw new Error('Значение параметра не может быть пустым.');
|
||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
|
||
const { response } = await this.runAddBlockWithRetry({
|
||
login: cleanLogin,
|
||
storagePwd,
|
||
resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin),
|
||
buildPreimage: async ({ blockNumber, prevBlockHash }) => {
|
||
const bodyBytes = makeUserParamBodyBytes({
|
||
lineCode: 0,
|
||
prevLineNumber: -1,
|
||
prevLineHashHex: ZERO_HASH_HEX,
|
||
thisLineNumber: -1,
|
||
key: cleanParam,
|
||
value: cleanValue,
|
||
});
|
||
return concatBytes(
|
||
int16Bytes(0),
|
||
hexToBytes(prevBlockHash),
|
||
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
|
||
int32Bytes(blockNumber),
|
||
int64Bytes(Math.floor(Date.now() / 1000)),
|
||
int16Bytes(4),
|
||
int16Bytes(1),
|
||
int16Bytes(1),
|
||
bodyBytes,
|
||
);
|
||
},
|
||
});
|
||
return response.payload || {};
|
||
}
|
||
|
||
async addBlockConnection({ login, toLogin, subType, storagePwd }) {
|
||
const cleanLogin = (login || '').trim();
|
||
const cleanToLogin = (toLogin || '').trim();
|
||
const cleanSubType = Number(subType);
|
||
if (!cleanLogin || !cleanToLogin) throw new Error('Не переданы login/toLogin для CONNECTION.');
|
||
if (!Number.isFinite(cleanSubType)) throw new Error('Не передан subType для CONNECTION.');
|
||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
|
||
if (cleanLogin.toLowerCase() === cleanToLogin.toLowerCase()) {
|
||
throw new Error('Нельзя создать связь на самого себя.');
|
||
}
|
||
|
||
const user = await this.getUser(cleanLogin);
|
||
if (user?.exists === false) throw new Error('Текущий пользователь не найден.');
|
||
|
||
const targetUser = await this.getUser(cleanToLogin);
|
||
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
|
||
const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim();
|
||
const { response } = await this.runAddBlockWithRetry({
|
||
login: cleanLogin,
|
||
storagePwd,
|
||
resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin),
|
||
buildPreimage: async ({ blockNumber, prevBlockHash }) => {
|
||
const bodyBytes = makeConnectionBodyBytes({
|
||
lineCode: 0,
|
||
prevLineNumber: -1,
|
||
prevLineHashHex: ZERO_HASH_HEX,
|
||
thisLineNumber: -1,
|
||
toBlockchainName,
|
||
toBlockNumber: 0,
|
||
toBlockHashHex: ZERO_HASH_HEX,
|
||
});
|
||
return concatBytes(
|
||
int16Bytes(0),
|
||
hexToBytes(prevBlockHash),
|
||
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
|
||
int32Bytes(blockNumber),
|
||
int64Bytes(Math.floor(Date.now() / 1000)),
|
||
int16Bytes(3),
|
||
int16Bytes(cleanSubType),
|
||
int16Bytes(1),
|
||
bodyBytes,
|
||
);
|
||
},
|
||
});
|
||
return response.payload || {};
|
||
}
|
||
|
||
|
||
async reportClientDebug({ runId = '', level = 'info', message = '', details = '' } = {}) {
|
||
try {
|
||
const response = await this.ws.request('ClientDebugLog', { runId, level, message, details }, 3000);
|
||
return response?.status === 200;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async reportClientError(details) {
|
||
try {
|
||
const response = await this.ws.request('ClientErrorLog', details || {}, 3000);
|
||
return response?.status === 200;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async reportClientUiError(details = {}) {
|
||
try {
|
||
const payload = {
|
||
source: 'ui_error',
|
||
code: 'UI_RUNTIME_ERROR',
|
||
...details,
|
||
};
|
||
const response = await this.sendCallDeliveryReport({
|
||
type: 'ui_error',
|
||
value: JSON.stringify(payload),
|
||
});
|
||
return !!response;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
close() {
|
||
this.ws.close();
|
||
}
|
||
}
|