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

548 lines
25 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 {
buildArweaveDataUrl,
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;
const RECENT_UPLOAD_MS = 20 * 60 * 1000;
function normalizeHistoryItem(input = {}) {
const item = normalizeAttachment(input);
const placedInShineAtMs = Number(input.placedInShineAtMs || 0) || 0;
const purpose = String(input.purpose || '').trim() === 'avatar' ? 'avatar' : 'attachment';
return {
...item,
purpose,
pendingPlacement: input.pendingPlacement === true && !placedInShineAtMs,
placedInShineAtMs,
};
}
export function readArweaveAttachmentHistory(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) => normalizeHistoryItem(item)).filter(Boolean);
} catch {
return [];
}
}
export function saveArweaveAttachmentHistory(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 : [])
.map((item) => normalizeHistoryItem(item))
.slice(0, MAX_HISTORY_ITEMS);
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(parsed));
} catch {
// ignore sessionStorage errors
}
}
export function clearArweaveAttachmentHistory(login) {
saveArweaveAttachmentHistory(login, []);
}
export function addArweaveAttachmentToHistory(login, attachment, { pendingPlacement = undefined, markPlaced = false } = {}) {
const item = normalizeHistoryItem(attachment);
const rows = readArweaveAttachmentHistory(login);
const existing = rows.find((row) => row.ar === item.ar) || {};
const placedInShineAtMs = markPlaced
? Date.now()
: Number(existing.placedInShineAtMs || item.placedInShineAtMs || 0) || 0;
const nextPendingPlacement = pendingPlacement === undefined
? (existing.pendingPlacement === true && !placedInShineAtMs)
: (!!pendingPlacement && !placedInShineAtMs);
const nextItem = {
...item,
pendingPlacement: nextPendingPlacement,
placedInShineAtMs,
};
const nextRows = rows.filter((row) => row.ar !== item.ar);
nextRows.unshift(nextItem);
saveArweaveAttachmentHistory(login, nextRows);
return nextItem;
}
export function markArweaveAttachmentPlaced(login, attachment) {
const item = normalizeHistoryItem(attachment);
const rows = readArweaveAttachmentHistory(login);
const existing = rows.find((row) => row.ar === item.ar) || {};
const nextItem = {
...item,
uploadedAtMs: item.uploadedAtMs || existing.uploadedAtMs || Date.now(),
pendingPlacement: false,
placedInShineAtMs: Number(existing.placedInShineAtMs || 0) || Date.now(),
};
const nextRows = rows.filter((row) => row.ar !== item.ar);
nextRows.unshift(nextItem);
saveArweaveAttachmentHistory(login, nextRows);
return nextItem;
}
export function formatArweaveHistoryTime(ms) {
const value = Number(ms || 0);
if (!Number.isFinite(value) || value <= 0) return '—';
const diffMs = Date.now() - value;
if (diffMs >= 0 && diffMs < RECENT_UPLOAD_MS) {
const minutes = Math.max(1, Math.floor(diffMs / 60000));
return `${minutes} мин назад`;
}
return new Date(value).toLocaleString('ru-RU');
}
export function formatArweaveHistoryDateTime(ms) {
const value = Number(ms || 0);
if (!Number.isFinite(value) || value <= 0) return '—';
return new Date(value).toLocaleString('ru-RU');
}
export async function getArweaveAttachmentAvailability({ gateway, attachment } = {}) {
const item = normalizeAttachment(attachment);
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
try {
const response = await fetch(url, { method: 'HEAD', cache: 'no-store' });
if (response.ok) return { status: 'available', label: '✓ Доступен', url };
} catch {
// gateway может временно не отвечать или блокировать HEAD; для UI это всё равно недоступно.
}
const ageMs = Date.now() - Number(item.uploadedAtMs || 0);
if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < RECENT_UPLOAD_MS) {
return { status: 'pending', label: '… Ещё обновляется', url };
}
return { status: 'unavailable', label: '✕ Недоступен', url };
}
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)}`;
}
async function resizeImageFileToAvatar(file) {
const source = file instanceof File ? file : null;
if (!source) throw new Error('Выберите изображение.');
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Не удалось прочитать изображение.'));
reader.readAsDataURL(source);
});
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Не удалось открыть изображение.'));
img.src = dataUrl;
});
const side = Math.min(image.naturalWidth || image.width, image.naturalHeight || image.height);
if (!side || side <= 0) throw new Error('Некорректный размер изображения.');
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas недоступен.');
const sx = Math.max(0, ((image.naturalWidth || image.width) - side) / 2);
const sy = Math.max(0, ((image.naturalHeight || image.height) - side) / 2);
ctx.drawImage(image, sx, sy, side, side, 0, 0, 512, 512);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.9));
if (!blob) throw new Error('Не удалось сжать аватар.');
return new File([blob], 'avatar.jpg', { type: 'image/jpeg', lastModified: Date.now() });
}
export function openArweaveAttachmentManager({
login,
storagePwd,
gateway,
onSelect,
selectedTxIds = [],
historyOnly = false,
mode = 'attachment',
historyPurpose = '',
} = {}) {
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;
const isAvatarMode = String(mode || '') === 'avatar';
const purposeFilter = isAvatarMode || String(historyPurpose || '').trim() === 'avatar' ? 'avatar' : '';
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
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, { pendingPlacement = undefined } = {}) {
const item = addArweaveAttachmentToHistory(cleanLogin, attachment, {
pendingPlacement,
markPlaced: false,
});
if (!pendingPlacement && 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">${isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')}</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>
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
<div class="ar-attachment-meta" data-meta="true"></div>
<p class="meta-muted inline-error" data-error="true"></p>
<div class="form-actions-grid">
${historyOnly || isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="existing">Ввести txId</button>'}
${historyOnly ? '' : `<button class="secondary-btn" type="button" data-action="history">${isAvatarMode ? 'История аватаров' : 'История загрузок'}</button>`}
<button class="primary-btn" type="button" data-action="upload" disabled>${historyOnly ? 'Загрузить в журнал' : 'Загрузить'}</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-кошелёк.');
if (isAvatarMode && !String(selectedFile.type || '').startsWith('image/')) {
throw new Error('Для аватара можно выбрать только изображение.');
}
if (isAvatarMode) {
selectedFile = await resizeImageFileToAvatar(selectedFile);
}
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 = `
${isAvatarMode ? '' : `<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: isAvatarMode ? 'avatar' : 'attachment',
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
],
});
finish(resolve, {
name: isAvatarMode ? 'Аватар' : (selectedFile.name || 'file'),
size: selectedFile.size,
sha256: selectedSha256,
ar: uploaded.id,
uploadedAtMs: Date.now(),
purpose: isAvatarMode ? 'avatar' : 'attachment',
}, { pendingPlacement: historyOnly && !isAvatarMode });
} 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 = readArweaveAttachmentHistory(cleanLogin)
.filter((item) => !purposeFilter || item.purpose === purposeFilter)
.slice()
.reverse();
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-tiles" data-history-scroll="true">
${rows.length ? rows.map((item, index) => {
const isSelected = selectedTxIdSet.has(item.ar);
return `
<article class="ar-attachment-history-tile${isSelected ? ' is-selected' : ''}" data-history-tile="${index}">
<strong class="ar-attachment-history-name">${escapeHtml(item.name)}${item.purpose === 'avatar' ? ' · аватар' : ''}</strong>
<div class="ar-attachment-history-meta-row">
<span>${escapeHtml(formatBytes(item.size))}</span>
<span>${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}</span>
<span class="ar-attachment-status ar-attachment-status--pending" data-status="${index}">Проверяем...</span>
${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Не добавлен в SHiNE</span>' : ''}
</div>
<div class="mono-cell ar-attachment-history-txid">${escapeHtml(item.ar)}</div>
<button class="${isSelected ? 'secondary-btn' : 'ghost-btn'}" type="button" data-pick="${index}" ${isSelected ? 'disabled' : ''}>${isSelected ? 'Уже добавлен ✓' : 'Выбрать'}</button>
</article>
`;
}).join('') : '<div class="card meta-muted">В этой сессии ещё нет загруженных файлов.</div>'}
</div>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
</div>
</div>
</div>
`;
bindBackdrop();
const scrollEl = root.querySelector('[data-history-scroll="true"]');
window.setTimeout(() => {
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
}, 0);
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);
});
});
rows.forEach((item, index) => {
const statusEl = root.querySelector(`[data-status="${index}"]`);
void getArweaveAttachmentAvailability({ gateway: cleanGateway, attachment: item })
.then((result) => {
if (!statusEl) return;
statusEl.className = `ar-attachment-status ar-attachment-status--${result.status}`;
statusEl.textContent = result.label;
})
.catch(() => {
if (!statusEl) return;
statusEl.className = 'ar-attachment-status ar-attachment-status--unavailable';
statusEl.textContent = 'Недоступен';
});
});
};
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();
});
}