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 || ''),
};
}