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 || ''),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user