SHA256
Добавить аватар профиля через Arweave и мастер загрузки
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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 : [];
|
||||
extraTags.forEach((item) => {
|
||||
const name = String(item?.name || '').trim();
|
||||
const value = String(item?.value || '').trim();
|
||||
if (!name || !value) return;
|
||||
tx.addTag(name, value);
|
||||
});
|
||||
|
||||
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 || ''),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user