Добавить аватар профиля через Arweave и мастер загрузки

This commit is contained in:
AidarKC
2026-04-26 01:41:09 +03:00
parent 126cf2f5c3
commit 667c5310bf
10 changed files with 1002 additions and 10 deletions
@@ -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 || ''),
};
}
+54 -3
View File
@@ -1,4 +1,4 @@
import { loadEncryptedUserSecrets } from './key-vault.js';
import { loadEncryptedUserSecrets, updateEncryptedUserSecrets } from './key-vault.js';
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
import { deriveArweaveWalletFromDeviceKey32 } from './sawd-v1.js';
@@ -72,7 +72,37 @@ async function loadArweaveLib() {
return arweaveLibPromise;
}
export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd }) {
function pickCachedWallet(secrets) {
const cached = secrets?.arweaveWallet;
if (!cached || typeof cached !== 'object') return null;
const derivation = String(cached.derivation || '').trim();
const address = String(cached.address || '').trim();
const owner = String(cached.owner || '').trim();
const jwk = cached.jwk;
if (derivation !== 'SAWD-v1' || !address || !owner || !jwk || typeof jwk !== 'object') {
return null;
}
if (!String(jwk.kty || '').trim() || !String(jwk.e || '').trim() || !String(jwk.n || '').trim() || !String(jwk.d || '').trim()) {
return null;
}
return {
derivation,
address,
owner,
jwk,
};
}
function safeStatus(onStatus, text) {
if (typeof onStatus !== 'function') return;
try {
onStatus(String(text || ''));
} catch {
// ignore callback errors
}
}
export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd, onStatus } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
@@ -80,6 +110,14 @@ export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd })
}
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
const cached = pickCachedWallet(secrets);
if (cached) {
safeStatus(onStatus, 'Arweave-кошелёк загружен.');
return cached;
}
safeStatus(onStatus, 'Сейчас мы впервые получаем Arweave-кошелёк из вашего device key. Это может занять немного времени.');
const storedDeviceKey = String(secrets?.deviceKey || '').trim();
if (!storedDeviceKey) {
throw new Error('На устройстве не найден device.key (wallet.key)');
@@ -93,11 +131,24 @@ export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd })
deviceKey32.fill(0);
}
return {
const cachedWallet = {
derivation: wallet.derivation,
address: wallet.address,
owner: wallet.owner,
jwk: wallet.jwk,
createdAtMs: Date.now(),
};
await updateEncryptedUserSecrets(cleanLogin, cleanPwd, (nextSecrets) => ({
...nextSecrets,
arweaveWallet: cachedWallet,
}));
return {
derivation: cachedWallet.derivation,
address: cachedWallet.address,
owner: cachedWallet.owner,
jwk: cachedWallet.jwk,
};
}
+14
View File
@@ -65,6 +65,20 @@ export async function loadEncryptedUserSecrets(login, storagePwd) {
return decryptJsonWithStoragePwd(row.encrypted, storagePwd);
}
export async function updateEncryptedUserSecrets(login, storagePwd, updater) {
if (typeof updater !== 'function') {
throw new Error('updateEncryptedUserSecrets: updater должен быть функцией');
}
const current = await loadEncryptedUserSecrets(login, storagePwd);
const next = await updater(current);
if (!next || typeof next !== 'object') {
throw new Error('updateEncryptedUserSecrets: updater должен вернуть объект secrets');
}
await saveEncryptedUserSecrets(login, storagePwd, next);
return next;
}
export async function saveSessionMaterial(login, material) {
await put(STORE_SESSIONS, {
login,
@@ -1,4 +1,5 @@
import { authService, state } from '../state.js';
import { buildArweaveAvatarValue, parseArweaveAvatarValue, validateArweaveTxId } from './arweave-file-service.js';
export const profileFieldDefs = [
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
@@ -118,12 +119,28 @@ export async function loadProfileSnapshot(login) {
const latestGender = loadLatestByAliasesFromItems(items, ['gender']);
const gender = normalizeGenderValue(latestGender?.value || PROFILE_GENDER_UNKNOWN);
const latestAvatar = loadLatestByAliasesFromItems(items, ['ava']);
const parsedAvatar = parseArweaveAvatarValue(latestAvatar?.value || '');
const avatar = parsedAvatar.ok
? {
value: buildArweaveAvatarValue(parsedAvatar.txId),
source: 'arweave',
txId: parsedAvatar.txId,
timeMs: latestAvatar?.timeMs || 0,
}
: {
value: '',
source: '',
txId: '',
timeMs: latestAvatar?.timeMs || 0,
};
return {
fields,
toggles,
gender,
genderTimeMs: latestGender?.timeMs || 0,
avatar,
};
}
@@ -157,3 +174,11 @@ export async function saveProfileGender(login, gender) {
storagePwd,
});
}
export async function saveProfileAvatarArweave(login, txId) {
const cleanTxId = String(txId || '').trim();
if (!validateArweaveTxId(cleanTxId)) {
throw new Error('Некорректный Transaction ID Arweave');
}
await saveProfileParamBlock(login, 'ava', buildArweaveAvatarValue(cleanTxId));
}