SHA256
Добавить Turbo-загрузку и превью для вложений
This commit is contained in:
@@ -32,15 +32,31 @@ function normalizeName(name) {
|
||||
return clean || 'file';
|
||||
}
|
||||
|
||||
function normalizePreview(input = {}) {
|
||||
const previewTxId = String(input.previewAr || input.ar || input.txId || '').trim();
|
||||
const previewSha256Hex = String(input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
||||
if (!previewTxId || !previewSha256Hex) return null;
|
||||
if (!validateArweaveTxId(previewTxId)) return null;
|
||||
if (!validateSha256Hex(previewSha256Hex)) return null;
|
||||
return {
|
||||
ar: previewTxId,
|
||||
sha256: previewSha256Hex,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAttachment(input = {}) {
|
||||
const txId = String(input.ar || input.txId || '').trim();
|
||||
const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
||||
const size = Number(input.size || input.sizeBytes || 0);
|
||||
const name = normalizeName(input.name || input.fileName || 'file');
|
||||
const preview = normalizePreview(input.preview || {
|
||||
previewAr: input.previewAr,
|
||||
previewSha256: input.previewSha256,
|
||||
});
|
||||
if (!validateArweaveTxId(txId)) throw new Error('Некорректный Transaction ID Arweave.');
|
||||
if (!validateSha256Hex(sha256Hex)) throw new Error('Некорректный SHA-256 файла.');
|
||||
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер файла.');
|
||||
return {
|
||||
const out = {
|
||||
v: 1,
|
||||
name,
|
||||
size,
|
||||
@@ -48,12 +64,20 @@ export function normalizeAttachment(input = {}) {
|
||||
ar: txId,
|
||||
uploadedAtMs: Number(input.uploadedAtMs || 0) || Date.now(),
|
||||
};
|
||||
if (preview) {
|
||||
out.v = 2;
|
||||
out.preview = preview;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildAttachmentBlock(attachment) {
|
||||
const item = normalizeAttachment(attachment);
|
||||
const encodedName = encodeURIComponent(item.name);
|
||||
return `<SHiNE:attach;v=1;name=${encodedName};size=${item.size};sha256=${item.sha256};ar=${item.ar}>`;
|
||||
const previewFields = item.preview
|
||||
? `;previewAr=${item.preview.ar};previewSha256=${item.preview.sha256}`
|
||||
: '';
|
||||
return `<SHiNE:attach;v=${item.preview ? 2 : 1};name=${encodedName};size=${item.size};sha256=${item.sha256};ar=${item.ar}${previewFields}>`;
|
||||
}
|
||||
|
||||
export function composeMessageWithAttachments(text, attachments = []) {
|
||||
@@ -91,6 +115,8 @@ export function parseMessageAttachments(rawText) {
|
||||
size: fields.size,
|
||||
sha256: fields.sha256,
|
||||
ar: fields.ar,
|
||||
previewAr: fields.previewAr,
|
||||
previewSha256: fields.previewSha256,
|
||||
}));
|
||||
} catch {
|
||||
// Битый attach-блок скрываем из UI, но не ломаем отображение текста.
|
||||
@@ -236,7 +262,7 @@ function createFileCard({ item, url, messageTimestampMs, slide }) {
|
||||
return card;
|
||||
}
|
||||
|
||||
function createMediaSlide({ item, url, kind, messageTimestampMs, slide }) {
|
||||
function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl = '' }) {
|
||||
const frame = document.createElement('div');
|
||||
frame.className = `message-attachment-media-frame message-attachment-media-frame--${kind}`;
|
||||
frame.role = 'button';
|
||||
@@ -264,6 +290,7 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide }) {
|
||||
const video = document.createElement('video');
|
||||
video.className = 'message-attachment-media';
|
||||
video.src = url;
|
||||
if (previewUrl) video.poster = previewUrl;
|
||||
video.preload = 'metadata';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
@@ -320,11 +347,12 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
|
||||
const item = items[index];
|
||||
const kind = getAttachmentKind(item);
|
||||
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
|
||||
const previewUrl = item.preview?.ar ? buildArweaveDataUrl({ gateway, txId: item.preview.ar }) : '';
|
||||
slide.dataset.unavailable = '0';
|
||||
slide.replaceChildren();
|
||||
slide.append(kind === 'file'
|
||||
? createFileCard({ item, url, messageTimestampMs, slide })
|
||||
: createMediaSlide({ item, url, kind, messageTimestampMs, slide }));
|
||||
: createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl }));
|
||||
prev.hidden = items.length <= 1 || index <= 0;
|
||||
next.hidden = items.length <= 1 || index >= items.length - 1;
|
||||
counter.textContent = `${index + 1} из ${items.length}`;
|
||||
|
||||
@@ -17,6 +17,34 @@ const BASE58_MAP = (() => {
|
||||
return out;
|
||||
})();
|
||||
|
||||
function encodeBase58(bytesLike) {
|
||||
const bytes = bytesLike instanceof Uint8Array ? bytesLike : Uint8Array.from(bytesLike || []);
|
||||
if (!bytes.length) return '';
|
||||
|
||||
const digits = [0];
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
let carry = bytes[i];
|
||||
for (let j = 0; j < digits.length; j += 1) {
|
||||
const value = (digits[j] << 8) + carry;
|
||||
digits[j] = value % 58;
|
||||
carry = (value / 58) | 0;
|
||||
}
|
||||
while (carry > 0) {
|
||||
digits.push(carry % 58);
|
||||
carry = (carry / 58) | 0;
|
||||
}
|
||||
}
|
||||
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length && bytes[i] === 0; i += 1) {
|
||||
out += BASE58_ALPHABET[0];
|
||||
}
|
||||
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
||||
out += BASE58_ALPHABET[digits[i]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeEndpoint(url) {
|
||||
const raw = String(url || '').trim();
|
||||
if (!raw) return DEFAULT_SOLANA_ENDPOINT;
|
||||
@@ -65,13 +93,27 @@ function decodeBase58(input) {
|
||||
async function keypairFromPkcs8(pkcs8B64) {
|
||||
const solana = await loadSolanaLib();
|
||||
const seed32 = extractClientKey32FromStoredValue(pkcs8B64);
|
||||
return solana.Keypair.fromSeed(seed32);
|
||||
try {
|
||||
return solana.Keypair.fromSeed(seed32);
|
||||
} finally {
|
||||
seed32.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function keypairFromStoredSecret(storedSecret) {
|
||||
const solana = await loadSolanaLib();
|
||||
const seed32 = extractClientKey32FromStoredValue(storedSecret);
|
||||
try {
|
||||
return solana.Keypair.fromSeed(seed32);
|
||||
} finally {
|
||||
seed32.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRandomSolanaWallet() {
|
||||
const solana = await loadSolanaLib();
|
||||
const keypair = solana.Keypair.generate();
|
||||
const privateKey32Base58 = solana.bs58.encode(keypair.secretKey.slice(0, 32));
|
||||
const privateKey32Base58 = encodeBase58(keypair.secretKey.slice(0, 32));
|
||||
return {
|
||||
address: keypair.publicKey.toBase58(),
|
||||
privateKey32Base58,
|
||||
@@ -107,7 +149,7 @@ export async function getWalletFromStoredClientKey({ login, storagePwd }) {
|
||||
if (!clientPrivate) {
|
||||
throw new Error('На устройстве не найден client.key');
|
||||
}
|
||||
const keypair = await keypairFromPkcs8(clientPrivate);
|
||||
const keypair = await keypairFromStoredSecret(clientPrivate);
|
||||
return {
|
||||
address: keypair.publicKey.toBase58(),
|
||||
keypair,
|
||||
@@ -115,6 +157,66 @@ export async function getWalletFromStoredClientKey({ login, storagePwd }) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWalletFromStoredRootKey({ login, storagePwd }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanPwd = String(storagePwd || '').trim();
|
||||
if (!cleanLogin || !cleanPwd) {
|
||||
throw new Error('Нет активной сессии для доступа к root key');
|
||||
}
|
||||
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
|
||||
const rootPrivate = String(secrets?.rootKey || '').trim();
|
||||
if (!rootPrivate) {
|
||||
throw new Error('На устройстве не найден root key');
|
||||
}
|
||||
const keypair = await keypairFromStoredSecret(rootPrivate);
|
||||
return {
|
||||
address: keypair.publicKey.toBase58(),
|
||||
keypair,
|
||||
rootPrivatePkcs8B64: rootPrivate,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStoredSolanaWalletChoices({ login, storagePwd, includeRoot = true } = {}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanPwd = String(storagePwd || '').trim();
|
||||
if (!cleanLogin || !cleanPwd) {
|
||||
throw new Error('Нет активной сессии для доступа к ключам');
|
||||
}
|
||||
|
||||
const choices = [];
|
||||
const clientWallet = await getWalletFromStoredClientKey({ login: cleanLogin, storagePwd: cleanPwd });
|
||||
choices.push({
|
||||
id: 'client-key',
|
||||
keySource: 'client',
|
||||
label: 'client key',
|
||||
address: clientWallet.address,
|
||||
keypair: clientWallet.keypair,
|
||||
});
|
||||
|
||||
if (includeRoot) {
|
||||
try {
|
||||
const rootWallet = await getWalletFromStoredRootKey({ login: cleanLogin, storagePwd: cleanPwd });
|
||||
choices.push({
|
||||
id: 'root-key',
|
||||
keySource: 'root',
|
||||
label: 'root key',
|
||||
address: rootWallet.address,
|
||||
keypair: rootWallet.keypair,
|
||||
});
|
||||
} catch {
|
||||
// root key на устройстве может отсутствовать, это допустимо
|
||||
}
|
||||
}
|
||||
|
||||
return choices;
|
||||
}
|
||||
|
||||
export async function encodeSolanaSecretKeyBase58(secretKey) {
|
||||
const bytes = secretKey instanceof Uint8Array ? secretKey : Uint8Array.from(secretKey || []);
|
||||
if (!bytes.length) throw new Error('Пустой Solana secret key');
|
||||
return encodeBase58(bytes);
|
||||
}
|
||||
|
||||
export async function getBalanceSol({ endpoint, address }) {
|
||||
const solana = await loadSolanaLib();
|
||||
const rpc = normalizeEndpoint(endpoint);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { getBalanceSol, getStoredSolanaWalletChoices, encodeSolanaSecretKeyBase58, formatSol } from './solana-wallet-service.js';
|
||||
import { state } from '../state.js';
|
||||
|
||||
const FREE_TURBO_UPLOAD_BYTES = 100 * 1024;
|
||||
const WINC_PER_CREDIT = 1_000_000_000_000n;
|
||||
|
||||
let turboLibPromise = null;
|
||||
|
||||
function parseSolToLamports(amountSol) {
|
||||
const raw = String(amountSol ?? '').trim().replace(',', '.');
|
||||
const match = raw.match(/^(\d+)(?:\.(\d+))?$/);
|
||||
if (!match) {
|
||||
throw new Error('Сумма пополнения должна быть числом');
|
||||
}
|
||||
const intPart = BigInt(match[1] || '0');
|
||||
const frac = String(match[2] || '');
|
||||
if (frac.length > 9) {
|
||||
throw new Error('Слишком много знаков после запятой для SOL');
|
||||
}
|
||||
const fracPadded = `${frac}${'0'.repeat(9 - frac.length)}`;
|
||||
const lamports = (intPart * 1_000_000_000n) + BigInt(fracPadded || '0');
|
||||
if (lamports <= 0n) {
|
||||
throw new Error('Сумма пополнения должна быть больше 0');
|
||||
}
|
||||
return lamports.toString();
|
||||
}
|
||||
|
||||
function normalizeKeySource(value) {
|
||||
return String(value || '').trim().toLowerCase() === 'root' ? 'root' : 'client';
|
||||
}
|
||||
|
||||
async function loadTurboLib() {
|
||||
if (!turboLibPromise) {
|
||||
turboLibPromise = import('../vendor/turbo-sdk.web.bundle.js');
|
||||
}
|
||||
return turboLibPromise;
|
||||
}
|
||||
|
||||
async function getTurboWalletChoice({ login, storagePwd, keySource = 'client' } = {}) {
|
||||
const normalizedKeySource = normalizeKeySource(keySource);
|
||||
const choices = await getStoredSolanaWalletChoices({ login, storagePwd, includeRoot: true });
|
||||
const selected = choices.find((item) => item.keySource === normalizedKeySource) || choices[0] || null;
|
||||
if (!selected?.keypair) {
|
||||
throw new Error(normalizedKeySource === 'root'
|
||||
? 'На устройстве не найден root key для Turbo.'
|
||||
: 'На устройстве не найден client key для Turbo.');
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function createTurboClientFromWalletChoice(choice) {
|
||||
const moduleRef = await loadTurboLib();
|
||||
const TurboFactory = moduleRef?.TurboFactory;
|
||||
if (!TurboFactory?.authenticated) {
|
||||
throw new Error('Turbo SDK не удалось инициализировать.');
|
||||
}
|
||||
const privateKey = await encodeSolanaSecretKeyBase58(choice.keypair.secretKey);
|
||||
return TurboFactory.authenticated({
|
||||
privateKey,
|
||||
token: 'solana',
|
||||
});
|
||||
}
|
||||
|
||||
function creditsFromWinc(winc) {
|
||||
const value = BigInt(String(winc || '0'));
|
||||
return Number(value) / Number(WINC_PER_CREDIT);
|
||||
}
|
||||
|
||||
function shortAddress(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (raw.length <= 16) return raw;
|
||||
return `${raw.slice(0, 8)}...${raw.slice(-6)}`;
|
||||
}
|
||||
|
||||
export function isFreeTurboUploadSize(byteLength) {
|
||||
const bytes = Number(byteLength || 0);
|
||||
return Number.isFinite(bytes) && bytes > 0 && bytes <= FREE_TURBO_UPLOAD_BYTES;
|
||||
}
|
||||
|
||||
export function getFreeTurboUploadBytesLimit() {
|
||||
return FREE_TURBO_UPLOAD_BYTES;
|
||||
}
|
||||
|
||||
export function formatTurboCredits(value, digits = 6) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
return n.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTurboContextForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) {
|
||||
const choice = await getTurboWalletChoice({ login, storagePwd, keySource });
|
||||
const turbo = await createTurboClientFromWalletChoice(choice);
|
||||
return {
|
||||
turbo,
|
||||
address: choice.address,
|
||||
keySource: choice.keySource,
|
||||
label: choice.label,
|
||||
keypair: choice.keypair,
|
||||
shortAddress: shortAddress(choice.address),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTurboBalanceForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) {
|
||||
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
|
||||
const balance = await ctx.turbo.getBalance(ctx.address);
|
||||
const solanaBalance = await getBalanceSol({
|
||||
endpoint: state.registrationPayment?.endpoint || state.entrySettings?.solanaRpc || undefined,
|
||||
address: ctx.address,
|
||||
});
|
||||
return {
|
||||
...ctx,
|
||||
controlledWinc: String(balance.controlledWinc || '0'),
|
||||
spendableWinc: String(balance.winc || '0'),
|
||||
effectiveWinc: String(balance.effectiveBalance || '0'),
|
||||
controlledCredits: creditsFromWinc(balance.controlledWinc || '0'),
|
||||
spendableCredits: creditsFromWinc(balance.winc || '0'),
|
||||
effectiveCredits: creditsFromWinc(balance.effectiveBalance || '0'),
|
||||
solBalance: solanaBalance.sol,
|
||||
solLamports: solanaBalance.lamports,
|
||||
};
|
||||
}
|
||||
|
||||
export async function estimateTurboUploadPrice({ login, storagePwd, keySource = 'client', byteLength } = {}) {
|
||||
const bytes = Number(byteLength);
|
||||
if (!Number.isInteger(bytes) || bytes <= 0) {
|
||||
throw new Error('Некорректный размер файла для Turbo.');
|
||||
}
|
||||
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
|
||||
const rows = await ctx.turbo.getUploadCosts({ bytes: [bytes] });
|
||||
const price = Array.isArray(rows) ? rows[0] : null;
|
||||
if (!price) {
|
||||
throw new Error('Turbo не вернул цену загрузки.');
|
||||
}
|
||||
const winc = String(price.winc || '0');
|
||||
return {
|
||||
...ctx,
|
||||
byteLength: bytes,
|
||||
isFree: isFreeTurboUploadSize(bytes),
|
||||
winc,
|
||||
credits: creditsFromWinc(winc),
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadFileWithTurbo({
|
||||
login,
|
||||
storagePwd,
|
||||
keySource = 'client',
|
||||
file,
|
||||
tags = [],
|
||||
shineType = 'attachment',
|
||||
onProgress,
|
||||
} = {}) {
|
||||
if (!(file instanceof File)) {
|
||||
throw new Error('Выберите файл для загрузки через Turbo.');
|
||||
}
|
||||
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
|
||||
const result = await ctx.turbo.uploadFile({
|
||||
file,
|
||||
dataItemOpts: {
|
||||
tags: [
|
||||
{ name: 'Content-Type', value: String(file.type || 'application/octet-stream') },
|
||||
{ name: 'App-Name', value: 'SHiNE' },
|
||||
{ name: 'SHiNE-Type', value: String(shineType || 'attachment') },
|
||||
...((Array.isArray(tags) ? tags : []).filter((item) => item?.name && item?.value)),
|
||||
],
|
||||
},
|
||||
events: typeof onProgress === 'function'
|
||||
? {
|
||||
onProgress: ({ totalBytes, processedBytes, step }) => {
|
||||
onProgress({
|
||||
totalBytes,
|
||||
processedBytes,
|
||||
step,
|
||||
});
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
...ctx,
|
||||
id: String(result?.id || '').trim(),
|
||||
owner: String(result?.owner || '').trim(),
|
||||
winc: String(result?.winc || '0'),
|
||||
credits: creditsFromWinc(result?.winc || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function topUpTurboWithSolana({
|
||||
login,
|
||||
storagePwd,
|
||||
keySource = 'client',
|
||||
amountSol,
|
||||
turboCreditDestinationAddress = '',
|
||||
} = {}) {
|
||||
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
|
||||
const tokenAmount = parseSolToLamports(amountSol);
|
||||
const result = await ctx.turbo.topUpWithTokens({
|
||||
tokenAmount,
|
||||
turboCreditDestinationAddress: String(turboCreditDestinationAddress || '').trim() || undefined,
|
||||
});
|
||||
return {
|
||||
...ctx,
|
||||
amountSol: formatSol(amountSol, 9),
|
||||
lamports: tokenAmount,
|
||||
txId: String(result?.id || '').trim(),
|
||||
target: String(result?.target || '').trim(),
|
||||
rewardWinc: String(result?.reward || '0'),
|
||||
rewardCredits: creditsFromWinc(result?.reward || '0'),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user