Files
SHiNE-server/shine-UI/js/components/arweave-attachment-manager.js
T

403 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
getArweaveUploadPrice,
loadArweaveFileMetadata,
sha256HexFromArrayBuffer,
uploadArweaveFile,
validateArweaveTxId,
} from '../services/arweave-file-service.js';
import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js';
import { escapeHtml, formatBytes, normalizeAttachment } from '../services/attachment-format.js';
const HISTORY_KEY = 'shine-ui-arweave-attachment-history-v1';
const MAX_HISTORY_ITEMS = 100;
function readHistory(login) {
try {
const raw = sessionStorage.getItem(HISTORY_KEY);
const parsed = raw ? JSON.parse(raw) : {};
const key = String(login || '').trim().toLowerCase();
const rows = Array.isArray(parsed?.[key]) ? parsed[key] : [];
return rows.map((item) => normalizeAttachment(item)).filter(Boolean);
} catch {
return [];
}
}
function saveHistory(login, rows) {
try {
const key = String(login || '').trim().toLowerCase();
const raw = sessionStorage.getItem(HISTORY_KEY);
const parsed = raw ? JSON.parse(raw) : {};
parsed[key] = (Array.isArray(rows) ? rows : []).slice(0, MAX_HISTORY_ITEMS);
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(parsed));
} catch {
// ignore sessionStorage errors
}
}
function addToHistory(login, attachment) {
const item = normalizeAttachment(attachment);
const rows = readHistory(login).filter((row) => row.ar !== item.ar);
rows.unshift(item);
saveHistory(login, rows);
return item;
}
function formatDateTime(ms) {
const value = Number(ms || 0);
if (!Number.isFinite(value) || value <= 0) return '—';
return new Date(value).toLocaleString('ru-RU');
}
function setText(node, text) {
if (node) node.textContent = String(text || '');
}
function shortAddress(value) {
const raw = String(value || '').trim();
if (raw.length <= 16) return raw;
return `${raw.slice(0, 8)}...${raw.slice(-6)}`;
}
export function openArweaveAttachmentManager({
login,
storagePwd,
gateway,
onSelect,
} = {}) {
const cleanLogin = String(login || '').trim();
const cleanStoragePwd = String(storagePwd || '').trim();
const cleanGateway = String(gateway || 'https://arweave.net').trim();
if (!cleanLogin || !cleanStoragePwd) return Promise.reject(new Error('Нет активной сессии.'));
const root = document.createElement('div');
root.className = 'ar-attachment-manager-root';
document.body.append(root);
let closed = false;
let wallets = [];
let selectedWalletId = 'derived-client-key';
let selectedFile = null;
let selectedSha256 = '';
let priceInfo = null;
let balanceInfo = null;
let autoOpenedFileDialog = false;
function selectedWallet() {
return wallets.find((item) => String(item.id) === String(selectedWalletId)) || wallets[0] || null;
}
function close(resolve, result = null) {
if (closed) return;
closed = true;
root.remove();
resolve(result);
}
function finish(resolve, attachment) {
const item = addToHistory(cleanLogin, attachment);
if (typeof onSelect === 'function') onSelect(item);
close(resolve, item);
}
return new Promise((resolve) => {
const bindBackdrop = () => {
const modal = root.querySelector('[data-ar-attach-modal="true"]');
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(resolve, null);
});
};
const renderWalletOptions = () => wallets.map((wallet) => (
`<option value="${escapeHtml(wallet.id)}">${escapeHtml(wallet.label)} · ${escapeHtml(shortAddress(wallet.address))}</option>`
)).join('');
const loadWallets = async (errorEl = null) => {
try {
wallets = await getArweaveWalletChoices({ login: cleanLogin, storagePwd: cleanStoragePwd });
if (!wallets.some((item) => String(item.id) === String(selectedWalletId))) {
selectedWalletId = String(wallets[0]?.id || 'derived-client-key');
}
} catch (error) {
setText(errorEl, error?.message || 'Не удалось загрузить Arweave-кошельки.');
}
};
const showUpload = async () => {
root.innerHTML = `
<div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card">
<h3 class="modal-title">Добавить вложение</h3>
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
<select class="input" id="ar-attach-wallet"></select>
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
<input class="input" id="ar-attach-file" type="file" />
<div class="ar-attachment-meta" data-meta="true"></div>
<p class="meta-muted inline-error" data-error="true"></p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" data-action="existing">Ввести txId</button>
<button class="secondary-btn" type="button" data-action="history">История</button>
<button class="primary-btn" type="button" data-action="upload" disabled>Загрузить</button>
</div>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div>
</div>
`;
bindBackdrop();
const walletEl = root.querySelector('#ar-attach-wallet');
const fileEl = root.querySelector('#ar-attach-file');
const metaEl = root.querySelector('[data-meta="true"]');
const errorEl = root.querySelector('[data-error="true"]');
const uploadBtn = root.querySelector('[data-action="upload"]');
await loadWallets(errorEl);
if (walletEl) {
walletEl.innerHTML = renderWalletOptions();
walletEl.value = selectedWalletId;
walletEl.addEventListener('change', () => {
selectedWalletId = String(walletEl.value || '');
uploadBtn.disabled = true;
selectedFile = null;
selectedSha256 = '';
priceInfo = null;
balanceInfo = null;
if (fileEl) fileEl.value = '';
setText(metaEl, '');
});
}
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null));
root.querySelector('[data-action="existing"]')?.addEventListener('click', showExisting);
root.querySelector('[data-action="history"]')?.addEventListener('click', showHistory);
root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet);
fileEl?.addEventListener('change', async () => {
selectedFile = fileEl.files?.[0] || null;
selectedSha256 = '';
priceInfo = null;
balanceInfo = null;
uploadBtn.disabled = true;
setText(errorEl, '');
setText(metaEl, '');
if (!selectedFile) return;
try {
const wallet = selectedWallet();
if (!wallet?.address) throw new Error('Выберите Arweave-кошелёк.');
const buffer = await selectedFile.arrayBuffer();
selectedSha256 = await sha256HexFromArrayBuffer(buffer);
priceInfo = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedFile.size });
balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address });
const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston);
metaEl.innerHTML = `
<div>Имя: ${escapeHtml(selectedFile.name || 'file')}</div>
<div>Размер: ${escapeHtml(formatBytes(selectedFile.size))}</div>
<div>SHA-256: ${escapeHtml(selectedSha256)}</div>
<div>Цена: ${escapeHtml(Number(priceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
<div>Баланс кошелька: ${escapeHtml(Number(balanceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
`;
if (!hasFunds) {
setText(errorEl, 'Недостаточно AR на выбранном кошельке.');
return;
}
uploadBtn.disabled = false;
} catch (error) {
setText(errorEl, error?.message || 'Не удалось подготовить файл.');
}
});
uploadBtn?.addEventListener('click', async () => {
if (!selectedFile || !selectedSha256) {
setText(errorEl, 'Выберите файл.');
return;
}
const wallet = selectedWallet();
if (!wallet?.jwk) {
setText(errorEl, 'Выберите Arweave-кошелёк.');
return;
}
uploadBtn.disabled = true;
setText(errorEl, 'Загружаем файл в Arweave...');
try {
const uploaded = await uploadArweaveFile({
gateway: cleanGateway,
jwk: wallet.jwk,
file: selectedFile,
shineType: 'attachment',
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: 'SHiNE-Attachment-Name', value: selectedFile.name || 'file' },
],
});
finish(resolve, {
name: selectedFile.name || 'file',
size: selectedFile.size,
sha256: selectedSha256,
ar: uploaded.id,
uploadedAtMs: Date.now(),
});
} catch (error) {
uploadBtn.disabled = false;
setText(errorEl, error?.message || 'Не удалось загрузить файл в Arweave.');
}
});
if (!autoOpenedFileDialog) {
autoOpenedFileDialog = true;
window.setTimeout(() => fileEl?.click(), 0);
}
};
const showExisting = () => {
root.innerHTML = `
<div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card">
<h3 class="modal-title">Существующий файл Arweave</h3>
<label class="meta-muted" for="ar-existing-txid">Transaction ID</label>
<input class="input" id="ar-existing-txid" type="text" maxlength="64" placeholder="43 символа txId" />
<label class="meta-muted" for="ar-existing-name">Имя файла в сообщении</label>
<input class="input" id="ar-existing-name" type="text" maxlength="180" placeholder="например report.pdf" />
<div class="ar-attachment-meta" data-meta="true"></div>
<p class="meta-muted inline-error" data-error="true"></p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="check">Скачать и проверить</button>
</div>
</div>
</div>
`;
bindBackdrop();
const txEl = root.querySelector('#ar-existing-txid');
const nameEl = root.querySelector('#ar-existing-name');
const metaEl = root.querySelector('[data-meta="true"]');
const errorEl = root.querySelector('[data-error="true"]');
const checkBtn = root.querySelector('[data-action="check"]');
root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload);
checkBtn?.addEventListener('click', async () => {
const txId = String(txEl?.value || '').trim();
const name = String(nameEl?.value || '').trim() || 'arweave-file';
if (!validateArweaveTxId(txId)) {
setText(errorEl, 'Некорректный Transaction ID Arweave.');
return;
}
checkBtn.disabled = true;
setText(errorEl, 'Скачиваем файл и считаем SHA-256...');
setText(metaEl, '');
try {
const meta = await loadArweaveFileMetadata({ gateway: cleanGateway, txId });
const item = normalizeAttachment({
name,
size: meta.sizeBytes,
sha256: meta.sha256Hex,
ar: txId,
uploadedAtMs: Date.now(),
});
metaEl.innerHTML = `
<div>Размер: ${escapeHtml(formatBytes(item.size))}</div>
<div>SHA-256: ${escapeHtml(item.sha256)}</div>
`;
setText(errorEl, '');
const addBtn = document.createElement('button');
addBtn.className = 'primary-btn';
addBtn.type = 'button';
addBtn.textContent = 'Добавить в сообщение';
addBtn.addEventListener('click', () => finish(resolve, item));
metaEl.append(addBtn);
} catch (error) {
setText(errorEl, error?.message || 'Не удалось проверить файл Arweave.');
checkBtn.disabled = false;
}
});
window.setTimeout(() => txEl?.focus(), 0);
};
const showHistory = () => {
const rows = readHistory(cleanLogin);
root.innerHTML = `
<div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card ar-attachment-manager-card--wide">
<h3 class="modal-title">Журнал загруженных файлов</h3>
<p class="meta-muted">Журнал хранится только в этой браузерной сессии.</p>
<div class="ar-attachment-history-table-wrap">
<table class="ar-attachment-history-table">
<thead>
<tr><th>Файл</th><th>txId</th><th>Размер</th><th>Дата</th><th></th></tr>
</thead>
<tbody>
${rows.length ? rows.map((item, index) => `
<tr>
<td>${escapeHtml(item.name)}</td>
<td class="mono-cell">${escapeHtml(item.ar)}</td>
<td>${escapeHtml(formatBytes(item.size))}</td>
<td>${escapeHtml(formatDateTime(item.uploadedAtMs))}</td>
<td><button class="ghost-btn" type="button" data-pick="${index}">Выбрать</button></td>
</tr>
`).join('') : '<tr><td colspan="5">В этой сессии ещё нет загруженных файлов.</td></tr>'}
</tbody>
</table>
</div>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
</div>
</div>
</div>
`;
bindBackdrop();
root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload);
root.querySelectorAll('[data-pick]').forEach((button) => {
button.addEventListener('click', () => {
const index = Number(button.getAttribute('data-pick'));
const item = rows[index];
if (item) finish(resolve, item);
});
});
};
const showAddWallet = () => {
root.innerHTML = `
<div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card">
<h3 class="modal-title">Добавить Arweave-кошелёк</h3>
<p class="meta-muted">Вставьте JSON JWK или короткую строку base64url(JSON JWK). Ключ будет сохранён в зашифрованном контейнере ключей этого пользователя на устройстве.</p>
<label class="meta-muted" for="ar-wallet-label">Название</label>
<input class="input" id="ar-wallet-label" type="text" maxlength="80" placeholder="например Основной AR" />
<label class="meta-muted" for="ar-wallet-secret">Секрет Arweave</label>
<textarea class="input" id="ar-wallet-secret" rows="7" placeholder="JSON JWK или base64url(JSON)"></textarea>
<p class="meta-muted inline-error" data-error="true"></p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="save">Добавить</button>
</div>
</div>
</div>
`;
bindBackdrop();
const labelEl = root.querySelector('#ar-wallet-label');
const secretEl = root.querySelector('#ar-wallet-secret');
const errorEl = root.querySelector('[data-error="true"]');
const saveBtn = root.querySelector('[data-action="save"]');
root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload);
saveBtn?.addEventListener('click', async () => {
saveBtn.disabled = true;
setText(errorEl, '');
try {
const wallet = await addArweaveWalletSecret({
login: cleanLogin,
storagePwd: cleanStoragePwd,
label: String(labelEl?.value || '').trim(),
secret: String(secretEl?.value || '').trim(),
});
selectedWalletId = wallet.id;
await showUpload();
} catch (error) {
saveBtn.disabled = false;
setText(errorEl, error?.message || 'Не удалось добавить Arweave-кошелёк.');
}
});
};
void showUpload();
});
}