SHiNE-server/shine-UI/js/services/arweave-file-service.js

310 lines
10 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.

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 <img> 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:<txid>..."
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 || ''),
};
}