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

271 lines
8.7 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}$/;
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);
});
}
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 : [];
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 || ''),
};
}