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_AVATAR_SIDE_PX = 768; const AVATAR_QUALITY = 0.86; const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/; let arweaveLibPromise = null; function normalizeGateway(rawGateway) { const raw = String(rawGateway || '').trim(); if (!raw) return DEFAULT_ARWEAVE_GATEWAY; let parsed; try { parsed = new URL(raw); } catch { throw new Error('Некорректный Arweave gateway URL'); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error('Arweave gateway должен использовать http или https'); } const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/+$/g, ''); return normalized || DEFAULT_ARWEAVE_GATEWAY; } function parseGatewayForArweaveInit(gateway) { let parsed; try { parsed = new URL(gateway); } catch { throw new Error('Некорректный Arweave gateway URL'); } const protocol = parsed.protocol.replace(':', ''); if (protocol !== 'http' && protocol !== 'https') { throw new Error('Arweave gateway должен использовать http или https'); } const port = parsed.port ? Number(parsed.port) : (protocol === 'https' ? 443 : 80); return { protocol, host: parsed.hostname, port, }; } async function loadArweaveLib() { if (!arweaveLibPromise) { arweaveLibPromise = import('https://esm.sh/arweave@1.15.7?bundle'); } return arweaveLibPromise; } function toArFromWinston(winston) { return Number(BigInt(winston)) / Number(WINSTON_PER_AR); } function blobToFile(blob, name, type) { const effectiveType = String(type || blob.type || 'application/octet-stream'); if (typeof File === 'function') { return new File([blob], name, { type: effectiveType }); } return Object.assign(blob, { name, lastModified: Date.now(), type: effectiveType }); } async function createBitmapWithFallback(file) { if (typeof createImageBitmap === 'function') { try { return await createImageBitmap(file); } catch { // continue with fallback } } const url = URL.createObjectURL(file); try { const image = await new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error('Не удалось прочитать изображение')); img.src = url; }); return image; } finally { URL.revokeObjectURL(url); } } function canvasToBlob(canvas, type, quality) { return new Promise((resolve) => { canvas.toBlob((blob) => resolve(blob), type, quality); }); } export function validateArweaveTxId(txId) { const value = String(txId || '').trim(); return TX_ID_RE.test(value); } export function buildArweaveAvatarValue(txId) { const cleanTxId = String(txId || '').trim(); if (!validateArweaveTxId(cleanTxId)) { throw new Error('Некорректный Transaction ID Arweave'); } return `AR:${cleanTxId}`; } export function parseArweaveAvatarValue(value) { const raw = String(value || '').trim(); if (!raw.startsWith('AR:')) { return { ok: false, network: '', txId: '' }; } const txId = raw.slice(3).trim(); if (!validateArweaveTxId(txId)) { return { ok: false, network: '', txId: '' }; } return { ok: true, network: 'AR', txId }; } export function buildArweaveDataUrl({ gateway, txId }) { const normalizedGateway = normalizeGateway(gateway); const cleanTxId = String(txId || '').trim(); if (!validateArweaveTxId(cleanTxId)) { throw new Error('Некорректный Transaction ID Arweave'); } return `${normalizedGateway}/${cleanTxId}`; } export async function getArweaveUploadPrice({ gateway, byteLength }) { const normalizedGateway = normalizeGateway(gateway); const bytes = Number(byteLength); if (!Number.isInteger(bytes) || bytes <= 0) { throw new Error('Некорректный размер файла'); } const response = await fetch(`${normalizedGateway}/price/${bytes}`, { method: 'GET' }); const raw = String(await response.text()).trim(); if (!response.ok) { throw new Error(`Не удалось получить цену загрузки (${response.status} ${response.statusText})`); } if (!/^\d+$/.test(raw)) { throw new Error('Arweave gateway вернул некорректную цену'); } return { gateway: normalizedGateway, winston: raw, ar: toArFromWinston(raw), }; } export function isSupportedAvatarImageType(type) { const clean = String(type || '').trim().toLowerCase(); return clean === 'image/jpeg' || clean === 'image/png' || clean === 'image/webp'; } export function validateAvatarSourceFile(file) { if (!(file instanceof File)) { throw new Error('Выберите файл изображения.'); } if (!isSupportedAvatarImageType(file.type)) { throw new Error('Поддерживаются только JPEG, PNG или WebP.'); } if (Number(file.size || 0) > MAX_AVATAR_SOURCE_BYTES) { throw new Error('Файл слишком большой. Максимум 10 MB.'); } } export async function prepareAvatarImageFile(file) { validateAvatarSourceFile(file); let bitmap; try { bitmap = await createBitmapWithFallback(file); } catch { throw new Error('Не удалось подготовить изображение.'); } try { const originalWidth = Number(bitmap.width || 0); const originalHeight = Number(bitmap.height || 0); if (!originalWidth || !originalHeight) { throw new Error('Не удалось подготовить изображение.'); } const scale = Math.min(1, MAX_AVATAR_SIDE_PX / Math.max(originalWidth, originalHeight)); const width = Math.max(1, Math.round(originalWidth * scale)); const height = Math.max(1, Math.round(originalHeight * scale)); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Не удалось подготовить изображение.'); } ctx.drawImage(bitmap, 0, 0, width, height); let contentType = 'image/webp'; let fileName = 'avatar.webp'; let blob = await canvasToBlob(canvas, contentType, AVATAR_QUALITY); if (!blob) { contentType = 'image/jpeg'; fileName = 'avatar.jpg'; blob = await canvasToBlob(canvas, contentType, AVATAR_QUALITY); } if (!blob) { throw new Error('Не удалось подготовить изображение.'); } const optimizedFile = blobToFile(blob, fileName, contentType); return { file: optimizedFile, originalSizeBytes: Number(file.size || 0), optimizedSizeBytes: Number(optimizedFile.size || 0), originalWidth, originalHeight, width, height, contentType, }; } catch (error) { if (error instanceof Error) throw error; throw new Error('Не удалось подготовить изображение.'); } finally { if (bitmap && typeof bitmap.close === 'function') { bitmap.close(); } } } export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) { if (!jwk || typeof jwk !== 'object') { throw new Error('Arweave-кошелёк не инициализирован.'); } if (!(file instanceof File)) { throw new Error('Выберите файл изображения.'); } const normalizedGateway = normalizeGateway(gateway); const { host, port, protocol } = parseGatewayForArweaveInit(normalizedGateway); const data = await file.arrayBuffer(); const moduleRef = await loadArweaveLib(); const Arweave = moduleRef?.default || moduleRef; const arweave = Arweave.init({ host, port, protocol }); 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'); const extraTags = Array.isArray(tags) ? tags : []; extraTags.forEach((item) => { const name = String(item?.name || '').trim(); const value = String(item?.value || '').trim(); if (!name || !value) return; tx.addTag(name, value); }); await arweave.transactions.sign(tx, jwk); const postResult = await arweave.transactions.post(tx); return { id: tx.id, status: Number(postResult?.status || 0), statusText: String(postResult?.statusText || ''), sizeBytes: Number(file.size || 0), contentType: String(file.type || ''), }; }