UI: добавить вложения Arweave в каналы

This commit is contained in:
AidarKC
2026-07-30 11:04:50 +04:00
parent c7684d6fe1
commit e9e6628b21
10 changed files with 974 additions and 29 deletions
+39 -9
View File
@@ -1,6 +1,7 @@
const DEFAULT_ARWEAVE_GATEWAY = 'https://arweave.net';
const WINSTON_PER_AR = 1_000_000_000_000n;
const MAX_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
const MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES = 50 * 1024 * 1024;
const MAX_AVATAR_SIDE_PX = 768;
const AVATAR_QUALITY = 0.86;
const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/;
@@ -268,12 +269,42 @@ export async function prepareAvatarImageFile(file) {
}
}
export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
export async function loadArweaveFileMetadata({ gateway, txId, maxBytes = MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES } = {}) {
const url = buildArweaveDataUrl({ gateway, txId });
const limit = Math.max(1, Number(maxBytes || MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES));
const response = await fetch(url, { method: 'GET', cache: 'no-store' });
if (!response.ok) {
throw new Error(`Не удалось скачать файл Arweave (${response.status} ${response.statusText})`);
}
const lengthHeader = String(response.headers.get('content-length') || '').trim();
const declaredSize = Number(lengthHeader);
if (Number.isFinite(declaredSize) && declaredSize > limit) {
throw new Error(`Файл слишком большой для локальной проверки. Максимум ${Math.round(limit / 1024 / 1024)} MB.`);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength > limit) {
throw new Error(`Файл слишком большой для локальной проверки. Максимум ${Math.round(limit / 1024 / 1024)} MB.`);
}
const cleanType = String(response.headers.get('content-type') || '').split(';')[0].trim();
const sha256Hex = await sha256HexFromArrayBuffer(buffer);
return {
txId: String(txId || '').trim(),
sizeBytes: buffer.byteLength,
sha256Hex,
contentType: cleanType,
gateway: normalizeGateway(gateway),
};
}
export async function uploadArweaveFile({ gateway, jwk, file, tags = [], shineType = 'avatar' }) {
if (!jwk || typeof jwk !== 'object') {
throw new Error('Arweave-кошелёк не инициализирован.');
}
if (!(file instanceof File)) {
throw new Error('Выберите файл изображения.');
throw new Error('Выберите файл.');
}
const normalizedGateway = normalizeGateway(gateway);
@@ -287,14 +318,13 @@ export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
const tx = await arweave.createTransaction({ data }, jwk);
tx.addTag('Content-Type', String(file.type || 'application/octet-stream'));
tx.addTag('App-Name', 'SHiNE');
tx.addTag('SHiNE-Type', 'avatar');
tx.addTag('SHiNE-Type', String(shineType || 'file'));
const extraTags = Array.isArray(tags) ? tags : [];
const profileLoginTag = extraTags.find((item) => String(item?.name || '').trim() === 'SHiNE-Profile-Login');
const profileLogin = String(profileLoginTag?.value || '').trim();
if (!profileLogin) {
throw new Error('Не указан логин профиля для тега SHiNE-Profile-Login');
}
tx.addTag('SHiNE-Profile-Login', profileLogin);
extraTags.forEach((item) => {
const name = String(item?.name || '').trim();
const value = String(item?.value || '').trim();
if (name && value) tx.addTag(name, value);
});
await arweave.transactions.sign(tx, jwk);
const postResult = await arweave.transactions.post(tx);
@@ -93,6 +93,53 @@ function pickCachedWallet(secrets) {
};
}
function normalizeExtraWallets(secrets) {
const rows = Array.isArray(secrets?.arweaveWallets) ? secrets.arweaveWallets : [];
return rows
.filter((item) => item && typeof item === 'object')
.map((item) => ({
id: String(item.id || item.address || '').trim(),
label: String(item.label || '').trim(),
address: String(item.address || '').trim(),
owner: String(item.owner || '').trim(),
jwk: item.jwk,
createdAtMs: Number(item.createdAtMs || 0),
}))
.filter((item) => item.id && item.address && item.jwk && typeof item.jwk === 'object');
}
function parseJwkSecretString(secret) {
const raw = String(secret || '').trim();
if (!raw) throw new Error('Введите секрет Arweave-кошелька.');
const tryParse = (value) => {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Некорректный JWK Arweave.');
}
return parsed;
};
if (raw.startsWith('{')) return tryParse(raw);
const normalized = raw
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(Math.ceil(raw.length / 4) * 4, '=');
try {
return tryParse(atob(normalized));
} catch {
throw new Error('Секрет должен быть JSON JWK или base64url(JSON JWK).');
}
}
function validateArweaveJwk(jwk) {
if (!jwk || typeof jwk !== 'object') throw new Error('Некорректный JWK Arweave.');
['kty', 'e', 'n', 'd', 'p', 'q', 'dp', 'dq', 'qi'].forEach((key) => {
if (!String(jwk[key] || '').trim()) throw new Error(`В JWK Arweave нет поля ${key}.`);
});
}
function safeStatus(onStatus, text) {
if (typeof onStatus !== 'function') return;
try {
@@ -102,6 +149,63 @@ function safeStatus(onStatus, text) {
}
}
export async function getArweaveWalletChoices({ login, storagePwd, onStatus } = {}) {
const derived = await getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus });
const secrets = await loadEncryptedUserSecrets(String(login || '').trim(), String(storagePwd || '').trim());
const extras = normalizeExtraWallets(secrets);
return [
{
id: 'derived-client-key',
label: 'Стандартный из client key',
address: derived.address,
owner: derived.owner,
jwk: derived.jwk,
isDefault: true,
},
...extras.map((item) => ({
...item,
label: item.label || `Arweave ${item.address.slice(0, 6)}...${item.address.slice(-6)}`,
isDefault: false,
})),
];
}
export async function addArweaveWalletSecret({ login, storagePwd, secret, label = '' } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) throw new Error('Нет активной сессии.');
const jwk = parseJwkSecretString(secret);
validateArweaveJwk(jwk);
const moduleRef = await loadArweaveLib();
const Arweave = moduleRef?.default || moduleRef;
const arweave = Arweave.init({ host: 'arweave.net', port: 443, protocol: 'https' });
const address = await arweave.wallets.jwkToAddress(jwk);
const owner = String(jwk.n || '').trim();
const id = `manual-${address}`;
const createdAtMs = Date.now();
const wallet = {
id,
label: String(label || '').trim() || `Arweave ${address.slice(0, 6)}...${address.slice(-6)}`,
address,
owner,
jwk,
createdAtMs,
};
await updateEncryptedUserSecrets(cleanLogin, cleanPwd, (current) => {
const wallets = normalizeExtraWallets(current).filter((item) => item.address !== address);
wallets.push(wallet);
return {
...current,
arweaveWallets: wallets,
};
});
return wallet;
}
export async function getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
+132
View File
@@ -0,0 +1,132 @@
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js';
const ATTACH_PREFIX = '<SHiNE:attach;';
const ATTACH_BLOCK_RE = /^<SHiNE:attach;([^>]*)>\n?/u;
export function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
function normalizeName(name) {
const clean = String(name || 'file')
.replace(/[\u0000-\u001f\u007f]/g, '')
.replace(/[\\/]/g, '_')
.trim();
return clean || 'file';
}
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');
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 {
v: 1,
name,
size,
sha256: sha256Hex,
ar: txId,
uploadedAtMs: Number(input.uploadedAtMs || 0) || Date.now(),
};
}
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}>`;
}
export function composeMessageWithAttachments(text, attachments = []) {
const cleanText = String(text || '').trim();
const rows = (Array.isArray(attachments) ? attachments : []).map(buildAttachmentBlock);
if (!rows.length) return cleanText;
return `${rows.join('\n')}${cleanText ? `\n${cleanText}` : ''}`;
}
function parseFields(rawFields) {
const out = {};
String(rawFields || '').split(';').forEach((part) => {
const eq = part.indexOf('=');
if (eq <= 0) return;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key) out[key] = value;
});
return out;
}
export function parseMessageAttachments(rawText) {
let rest = String(rawText || '');
const attachments = [];
while (rest.startsWith(ATTACH_PREFIX)) {
const match = rest.match(ATTACH_BLOCK_RE);
if (!match) break;
const fields = parseFields(match[1]);
try {
attachments.push(normalizeAttachment({
name: decodeURIComponent(String(fields.name || 'file')),
size: fields.size,
sha256: fields.sha256,
ar: fields.ar,
}));
} catch {
// Битый attach-блок скрываем из UI, но не ломаем отображение текста.
}
rest = rest.slice(match[0].length);
}
return {
attachments,
text: rest.replace(/^\n+/u, ''),
};
}
export function createAttachmentListElement(attachments = [], { gateway = '', compact = false } = {}) {
const items = Array.isArray(attachments) ? attachments : [];
const wrap = document.createElement('div');
wrap.className = `message-attachments${compact ? ' message-attachments--compact' : ''}`;
items.forEach((raw) => {
let item;
try {
item = normalizeAttachment(raw);
} catch {
return;
}
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
const link = document.createElement('a');
link.className = 'message-attachment-card';
link.href = url;
link.target = '_blank';
link.rel = 'noopener';
link.title = 'Открыть файл в Arweave';
link.addEventListener('click', (event) => {
event.stopPropagation();
});
link.innerHTML = `
<span class="message-attachment-icon" aria-hidden="true">📎</span>
<span class="message-attachment-main">
<span class="message-attachment-name">${escapeHtml(item.name)}</span>
<span class="message-attachment-meta">${escapeHtml(formatBytes(item.size))} · ${escapeHtml(item.ar)}</span>
</span>
`;
wrap.append(link);
});
return wrap;
}