310 lines
10 KiB
JavaScript
310 lines
10 KiB
JavaScript
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 || ''),
|
||
};
|
||
}
|