SHA256
403 lines
17 KiB
JavaScript
403 lines
17 KiB
JavaScript
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();
|
||
});
|
||
}
|