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}$/; const SHA256_HEX_RE = /^[A-Fa-f0-9]{64}$/; 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); }); } function bytesToHex(bytes) { return Array.from(bytes, (item) => item.toString(16).padStart(2, '0')).join(''); } export function validateArweaveTxId(txId) { const value = String(txId || '').trim(); return TX_ID_RE.test(value); } export function validateSha256Hex(sha256Hex) { const value = String(sha256Hex || '').trim(); return SHA256_HEX_RE.test(value); } export async function sha256HexFromArrayBuffer(buffer) { if (!(buffer instanceof ArrayBuffer)) throw new Error('Некорректные данные для SHA256'); const digest = await crypto.subtle.digest('SHA-256', buffer); return bytesToHex(new Uint8Array(digest)); } export function buildArweaveAvatarValue(txId, sha256Hex = '') { const cleanTxId = String(txId || '').trim(); if (!validateArweaveTxId(cleanTxId)) { throw new Error('Некорректный Transaction ID Arweave'); } const cleanSha = String(sha256Hex || '').trim().toLowerCase(); if (!cleanSha) return `AR:${cleanTxId}`; if (!validateSha256Hex(cleanSha)) { throw new Error('Некорректный SHA256 хэш аватара'); } return `SHA256:${cleanSha},AR:${cleanTxId}`; } export function parseArweaveAvatarValue(value) { const raw = String(value || '').trim(); if (!raw) { return { ok: false, network: '', txId: '', sha256Hex: '' }; } const arMatch = raw.match(/(?:^|,)\s*AR:([A-Za-z0-9_-]{43})\s*(?:,|$)/); let txId = String(arMatch?.[1] || '').trim(); if (!txId) { // fallback для старых/кривых значений без запятых: "...AR:..." const fallbackAr = raw.match(/AR:([A-Za-z0-9_-]{43})/); txId = String(fallbackAr?.[1] || '').trim(); } if (!validateArweaveTxId(txId)) { return { ok: false, network: '', txId: '', sha256Hex: '' }; } const shaMatch = raw.match(/(?:^|,)\s*SHA256:([A-Fa-f0-9]{64})\s*(?:,|$)/); let sha256Hex = String(shaMatch?.[1] || '').trim().toLowerCase(); if (!sha256Hex) { const fallbackSha = raw.match(/SHA256:([A-Fa-f0-9]{64})/); sha256Hex = String(fallbackSha?.[1] || '').trim().toLowerCase(); } return { ok: true, network: 'AR', txId, sha256Hex }; } 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); const optimizedArrayBuffer = await optimizedFile.arrayBuffer(); const sha256Hex = await sha256HexFromArrayBuffer(optimizedArrayBuffer); return { file: optimizedFile, originalSizeBytes: Number(file.size || 0), optimizedSizeBytes: Number(optimizedFile.size || 0), originalWidth, originalHeight, width, height, contentType, sha256Hex, }; } 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 : []; 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); 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 || ''), }; }