Добавить аватар профиля через 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

View File

@ -1,2 +1,2 @@
client.version=1.2.4
server.version=1.2.4
client.version=1.2.5
server.version=1.2.5

View File

@ -6,8 +6,8 @@
<link rel="manifest" href="./manifest.webmanifest" />
<title>Shine UI Demo</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260426011500';
window.__SHINE_CLIENT_VERSION__ = '1.2.4';
window.__SHINE_BUILD_HASH__ = '20260426025000';
window.__SHINE_CLIENT_VERSION__ = '1.2.5';
</script>
<script>
(function attachStylesWithBuildHash() {

View File

@ -0,0 +1,436 @@
import { getArweaveBalance, getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js';
import {
buildArweaveDataUrl,
getArweaveUploadPrice,
prepareAvatarImageFile,
uploadArweaveFile,
validateArweaveTxId,
validateAvatarSourceFile,
} from '../services/arweave-file-service.js';
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
function formatAr(ar) {
const n = Number(ar);
if (!Number.isFinite(n)) return '0';
return n.toLocaleString('ru-RU', { maximumFractionDigits: 6 });
}
function clearJwk(ctx) {
if (!ctx?.jwk || typeof ctx.jwk !== 'object') return;
Object.keys(ctx.jwk).forEach((key) => {
ctx.jwk[key] = '';
});
ctx.jwk = null;
}
function setNodeText(node, text) {
if (node) node.textContent = String(text || '');
}
export function openAvatarWizard({
login,
storagePwd,
gateway,
navigate,
onAvatarSaved,
onStatus,
} = {}) {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(false);
const cleanLogin = String(login || '').trim();
const cleanStoragePwd = String(storagePwd || '').trim();
const cleanGateway = String(gateway || '').trim();
if (!cleanLogin || !cleanStoragePwd) {
return Promise.reject(new Error('Нет активной сессии.'));
}
let closed = false;
let lastPreviewUrl = '';
let walletCtx = null;
let balanceInfo = null;
let optimized = null;
let priceInfo = null;
let uploadedTxId = '';
function revokePreviewUrl() {
if (!lastPreviewUrl) return;
URL.revokeObjectURL(lastPreviewUrl);
lastPreviewUrl = '';
}
function close(result = false, resolve) {
if (closed) return;
closed = true;
revokePreviewUrl();
clearJwk(walletCtx);
walletCtx = null;
root.innerHTML = '';
resolve(result);
}
async function ensurePreviewImage(url, imageEl) {
return new Promise((resolve, reject) => {
const img = imageEl;
img.onload = () => resolve(true);
img.onerror = () => reject(new Error('Не удалось загрузить изображение по этому Transaction ID'));
img.src = url;
});
}
return new Promise((resolve) => {
const showStepChoice = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Сменить аватар</h3>
<p class="meta-muted">Вы можете использовать уже загруженный файл Arweave или загрузить новый файл.</p>
<div class="avatar-wizard-choice-grid">
<button class="primary-btn" type="button" data-action="use-existing">Использовать существующий файл в Arweave</button>
<button class="primary-btn" type="button" data-action="upload-new">Загрузить новый файл в Arweave</button>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
root.querySelector('[data-action="use-existing"]')?.addEventListener('click', showStepExistingInput);
root.querySelector('[data-action="upload-new"]')?.addEventListener('click', () => { void showStepUpload(); });
};
const showStepExistingInput = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Использовать существующий файл в Arweave</h3>
<label class="meta-muted" for="avatar-existing-txid">Transaction ID Arweave</label>
<input class="input" id="avatar-existing-txid" type="text" maxlength="64" placeholder="Введите Transaction ID" />
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="next">Далее</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const inputEl = root.querySelector('#avatar-existing-txid');
const errorEl = root.querySelector('[data-error="true"]');
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
root.querySelector('[data-action="next"]')?.addEventListener('click', () => {
const txId = String(inputEl?.value || '').trim();
if (!validateArweaveTxId(txId)) {
setNodeText(errorEl, 'Некорректный Transaction ID Arweave.');
return;
}
setNodeText(errorEl, '');
void showStepExistingPreview(txId);
});
window.setTimeout(() => inputEl?.focus(), 0);
};
const showStepExistingPreview = async (txId) => {
if (closed) return;
const previewUrl = buildArweaveDataUrl({ gateway: cleanGateway, txId });
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Предпросмотр аватара</h3>
<div class="avatar-preview-circle avatar-wizard-preview">
<img alt="Предпросмотр аватара" data-preview-image="true" />
</div>
<p class="meta-muted">После сохранения в профиль будет записан только Transaction ID. Сам файл хранится в Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="save">Сохранить аватар</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const imageEl = root.querySelector('[data-preview-image="true"]');
const errorEl = root.querySelector('[data-error="true"]');
const saveBtn = root.querySelector('[data-action="save"]');
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepExistingInput);
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
try {
await ensurePreviewImage(previewUrl, imageEl);
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = false;
} catch (error) {
setNodeText(errorEl, 'Не удалось загрузить изображение по этому Transaction ID');
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
}
saveBtn?.addEventListener('click', async () => {
try {
await saveProfileAvatarArweave(cleanLogin, txId);
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
close(true, resolve);
} catch {
setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.');
}
});
};
const showStepZeroBalance = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Недостаточно средств</h3>
<p class="meta-muted">У вас нулевой баланс Arweave. Для загрузки аватара нужен AR.</p>
<div class="avatar-wizard-actions">
<button class="primary-btn" type="button" data-action="to-wallet">Перейти в кошелёк</button>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div>
</div>
</div>
`;
root.querySelector('[data-action="to-wallet"]')?.addEventListener('click', () => {
close(false, resolve);
if (typeof navigate === 'function') navigate('wallet-view');
});
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
};
const showStepUploadForm = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Загрузить новый файл в Arweave</h3>
<p class="meta-muted">Баланс: ${formatAr(balanceInfo?.ar)} AR</p>
<label class="meta-muted" for="avatar-file-input">Выберите изображение</label>
<input class="input" id="avatar-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Максимальный исходный файл 10 MB. Перед загрузкой изображение будет уменьшено для аватарки.</p>
<div class="avatar-preview-circle avatar-wizard-preview" hidden data-preview-wrap="true">
<img alt="Предпросмотр аватара" data-preview-image="true" />
</div>
<div class="avatar-wizard-meta" data-meta="true"></div>
<p class="meta-muted">После сохранения в профиль будет записан только Transaction ID. Сам файл хранится в Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="upload" disabled>Загрузить в Arweave</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const fileInput = root.querySelector('#avatar-file-input');
const errorEl = root.querySelector('[data-error="true"]');
const metaEl = root.querySelector('[data-meta="true"]');
const previewWrap = root.querySelector('[data-preview-wrap="true"]');
const previewImage = root.querySelector('[data-preview-image="true"]');
const uploadBtn = root.querySelector('[data-action="upload"]');
let selectedFile = null;
optimized = null;
priceInfo = null;
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
fileInput?.addEventListener('change', async () => {
setNodeText(errorEl, '');
setNodeText(metaEl, '');
uploadBtn.disabled = true;
revokePreviewUrl();
selectedFile = fileInput.files?.[0] || null;
if (!selectedFile) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
try {
validateAvatarSourceFile(selectedFile);
optimized = await prepareAvatarImageFile(selectedFile);
priceInfo = await getArweaveUploadPrice({
gateway: cleanGateway,
byteLength: optimized.file.size,
});
lastPreviewUrl = URL.createObjectURL(optimized.file);
previewImage.src = lastPreviewUrl;
previewWrap.hidden = false;
const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston);
metaEl.innerHTML = `
<div>Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}</div>
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
<div>Примерная цена загрузки: ${escapeHtml(formatAr(priceInfo.ar))} AR</div>
`;
if (!hasFunds) {
setNodeText(errorEl, 'Недостаточно средств для загрузки.');
uploadBtn.disabled = true;
return;
}
uploadBtn.disabled = false;
} catch (error) {
const message = String(error?.message || '');
if (message === 'Выберите файл изображения.' || message === 'Поддерживаются только JPEG, PNG или WebP.' || message === 'Файл слишком большой. Максимум 10 MB.') {
setNodeText(errorEl, message);
} else if (message.includes('цену загрузки')) {
setNodeText(errorEl, 'Не удалось получить баланс Arweave.');
} else {
setNodeText(errorEl, 'Не удалось подготовить изображение.');
}
}
});
uploadBtn?.addEventListener('click', async () => {
setNodeText(errorEl, '');
if (!optimized?.file) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
if (!priceInfo || BigInt(balanceInfo.winston) < BigInt(priceInfo.winston)) {
setNodeText(errorEl, 'Недостаточно средств для загрузки.');
return;
}
uploadBtn.disabled = true;
try {
const uploaded = await uploadArweaveFile({
gateway: cleanGateway,
jwk: walletCtx?.jwk,
file: optimized.file,
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
],
});
uploadedTxId = String(uploaded.id || '').trim();
if (!uploadedTxId) {
throw new Error('Пустой Transaction ID');
}
showStepUploaded();
} catch {
setNodeText(errorEl, 'Не удалось загрузить файл в Arweave.');
uploadBtn.disabled = false;
}
});
};
const showStepUploaded = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Файл загружен в Arweave</h3>
<p class="meta-muted">Transaction ID:</p>
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button>
<button class="primary-btn" type="button" data-action="set-avatar">Сделать аватаром</button>
</div>
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
</div>
</div>
`;
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="copy-id"]')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(uploadedTxId);
setNodeText(errorEl, '');
} catch {
setNodeText(errorEl, 'Не удалось скопировать Transaction ID.');
}
});
root.querySelector('[data-action="set-avatar"]')?.addEventListener('click', async () => {
try {
await saveProfileAvatarArweave(cleanLogin, uploadedTxId);
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
close(true, resolve);
} catch {
setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.');
}
});
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
};
const showStepUpload = async () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Подготовка загрузки</h3>
<p class="meta-muted" data-loading="true">Загружаем Arweave-кошелёк...</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
</div>
</div>
</div>
`;
const loadingEl = root.querySelector('[data-loading="true"]');
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
try {
walletCtx = await getArweaveWalletFromStoredDeviceKey({
login: cleanLogin,
storagePwd: cleanStoragePwd,
onStatus: (message) => {
setNodeText(loadingEl, message);
if (typeof onStatus === 'function') onStatus(message);
},
});
balanceInfo = await getArweaveBalance({
gateway: cleanGateway,
address: walletCtx.address,
});
} catch (error) {
setNodeText(errorEl, 'Не удалось получить баланс Arweave.');
return;
}
if (BigInt(balanceInfo.winston) <= 0n) {
showStepZeroBalance();
return;
}
showStepUploadForm();
};
showStepChoice();
});
}

View File

@ -11,6 +11,8 @@ import {
saveProfileToggle,
} from '../services/user-profile-params.js';
import { buildIdentityLines, loadUserProfileCard } from '../services/user-connections.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { openAvatarWizard } from '../components/avatar-wizard.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@ -102,7 +104,13 @@ export function render({ navigate }) {
topRow.className = 'row';
topRow.innerHTML = `
<div class="row" style="gap:12px; align-items:center;">
<div class="avatar large">${profile.avatarInitials}</div>
<div class="profile-avatar-block">
<div class="avatar large profile-avatar" data-profile-avatar="true">
<span data-avatar-fallback="true">${profile.avatarInitials}</span>
<img src="" alt="Аватар" data-avatar-image="true" hidden />
</div>
<button class="ghost-btn profile-avatar-change-btn" type="button" data-change-avatar="true">Сменить аватар</button>
</div>
<div class="profile-identity-lines" data-profile-identity="true">
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
</div>
@ -139,11 +147,15 @@ export function render({ navigate }) {
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
const addRelativeBtn = relativesCard.querySelector('[data-add-relative="true"]');
const changeAvatarBtn = topRow.querySelector('[data-change-avatar="true"]');
let currentFields = [];
let currentToggles = [];
let currentGender = PROFILE_GENDER_UNKNOWN;
let currentAvatar = { value: '', source: '', txId: '', timeMs: 0 };
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
const avatarImageEl = topRow.querySelector('[data-avatar-image="true"]');
const avatarFallbackEl = topRow.querySelector('[data-avatar-fallback="true"]');
function openGenderPickerModal(initialGender) {
const root = document.getElementById('modal-root');
@ -207,6 +219,58 @@ export function render({ navigate }) {
)).join('');
}
function buildAvatarFallbackText() {
const firstName = String(currentFields.find((field) => field.key === 'first_name')?.value || '').trim();
const lastName = String(currentFields.find((field) => field.key === 'last_name')?.value || '').trim();
const fromNames = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
if (fromNames.trim()) return fromNames;
const loginFirst = String(login || '').trim().charAt(0).toUpperCase();
if (loginFirst) return loginFirst;
return String(profile.avatarInitials || '??').trim().slice(0, 2).toUpperCase();
}
function showAvatarFallback() {
if (avatarImageEl instanceof HTMLImageElement) {
avatarImageEl.hidden = true;
avatarImageEl.removeAttribute('src');
}
if (avatarFallbackEl instanceof HTMLElement) {
avatarFallbackEl.hidden = false;
avatarFallbackEl.textContent = buildAvatarFallbackText();
}
}
function updateAvatarUi() {
if (!(avatarImageEl instanceof HTMLImageElement)) return;
const txId = String(currentAvatar?.txId || '').trim();
if (!txId) {
showAvatarFallback();
return;
}
let avatarUrl = '';
try {
avatarUrl = buildArweaveDataUrl({
gateway: state.entrySettings.arweaveServer,
txId,
});
} catch {
showAvatarFallback();
return;
}
avatarImageEl.onload = () => {
if (avatarFallbackEl instanceof HTMLElement) avatarFallbackEl.hidden = true;
avatarImageEl.hidden = false;
};
avatarImageEl.onerror = () => {
showAvatarFallback();
};
avatarImageEl.src = avatarUrl;
}
function updateToggleButton(button, prefix, enabled) {
button.textContent = `${prefix}: ${toggleText(enabled)}`;
button.classList.remove('is-no', 'is-yes-official', 'is-yes-shine');
@ -545,6 +609,7 @@ export function render({ navigate }) {
officialBtn.disabled = true;
shineBtn.disabled = true;
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = true;
if (changeAvatarBtn instanceof HTMLButtonElement) changeAvatarBtn.disabled = true;
const genderActionBtn = listWrap.querySelector('[data-edit-gender="true"]');
if (genderActionBtn instanceof HTMLButtonElement) {
genderActionBtn.disabled = true;
@ -555,11 +620,13 @@ export function render({ navigate }) {
currentFields = snapshot.fields;
currentToggles = snapshot.toggles;
currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN;
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', timeMs: 0 };
syncIdentity();
renderFields(currentFields);
updateTogglesUi();
updateGenderUi();
updateAvatarUi();
status.className = 'status-line';
status.textContent = '';
@ -572,6 +639,7 @@ export function render({ navigate }) {
officialBtn.disabled = false;
shineBtn.disabled = false;
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = false;
if (changeAvatarBtn instanceof HTMLButtonElement) changeAvatarBtn.disabled = false;
const genderActionBtnAfter = listWrap.querySelector('[data-edit-gender="true"]');
if (genderActionBtnAfter instanceof HTMLButtonElement) {
genderActionBtnAfter.disabled = false;
@ -579,6 +647,37 @@ export function render({ navigate }) {
}
}
async function onChangeAvatarClick() {
status.className = 'status-line';
status.textContent = 'Открываем мастер аватара...';
try {
await openAvatarWizard({
login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
navigate,
onStatus: (message) => {
status.className = 'status-line';
status.textContent = String(message || '');
},
onAvatarSaved: async () => {
status.className = 'status-line';
status.textContent = 'Сохраняем аватар в профиль...';
await refreshProfileSnapshot();
status.className = 'status-line is-available';
status.textContent = 'Аватар обновлён.';
},
});
if (!status.textContent) {
status.className = 'status-line';
}
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = error?.message || 'Не удалось открыть мастер аватара.';
}
}
async function onToggleClick(toggleKey) {
const toggle = currentToggles.find((item) => item.key === toggleKey) || { enabled: false };
const nextEnabled = !toggle.enabled;
@ -742,10 +841,12 @@ export function render({ navigate }) {
officialBtn.addEventListener('click', () => onToggleClick('official'));
shineBtn.addEventListener('click', () => onToggleClick('shine'));
addRelativeBtn?.addEventListener('click', onAddRelativeClick);
changeAvatarBtn?.addEventListener('click', () => { void onChangeAvatarClick(); });
card.append(topRow, badgesRow, status, listWrap, relativesCard);
screen.append(card);
showAvatarFallback();
refreshProfileSnapshot();
return screen;

View File

@ -316,7 +316,18 @@ export function render({ navigate }) {
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
card.append(balanceWrap, addressCard);
const helpCard = document.createElement('details');
helpCard.className = 'card';
helpCard.style.padding = '10px';
helpCard.innerHTML = `
<summary style="cursor:pointer; font-weight:600;">Как получен этот адрес?</summary>
<p class="meta-muted" style="margin-top:8px;">
SHiNE берёт ваш локальный device.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
</p>
`;
card.append(balanceWrap, addressCard, helpCard);
const actions = document.createElement('div');
actions.className = 'stack';
@ -412,7 +423,18 @@ export function render({ navigate }) {
setStatus('Генерация Arweave-кошелька...');
try {
arweaveWalletCtx = await getArweaveWalletFromStoredDeviceKey(sessionArgsOrThrow());
arweaveWalletCtx = await getArweaveWalletFromStoredDeviceKey({
...sessionArgsOrThrow(),
onStatus: (message) => {
const text = String(message || '').trim();
if (!text) return;
if (text.includes('впервые получаем Arweave-кошелёк')) {
setStatus('Сейчас мы впервые получаем Arweave-кошелёк из вашего приватного device key. Это может занять немного времени. После этого кошелёк будет храниться только в зашифрованном контейнере этого устройства.');
return;
}
setStatus(text);
},
});
if (modeToken !== activeModeToken) return;
walletAddress = arweaveWalletCtx.address;
addressEl.textContent = walletAddress;

View File

@ -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 || ''),
};
}

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,
};
}

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,

View File

@ -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));
}

View File

@ -242,6 +242,78 @@
font-size: 20px;
}
.profile-avatar-block {
display: grid;
justify-items: center;
gap: 8px;
}
.profile-avatar {
position: relative;
overflow: hidden;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
.profile-avatar-change-btn {
min-height: 34px;
padding: 6px 10px;
}
.avatar-preview-circle {
width: 124px;
height: 124px;
margin: 0 auto;
border-radius: 50%;
overflow: hidden;
border: 1px solid rgba(155, 182, 233, 0.46);
background: rgba(13, 26, 50, 0.86);
box-shadow: inset 0 0 0 1px rgba(240, 248, 255, 0.1);
}
.avatar-preview-circle img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
.avatar-wizard-preview {
width: 140px;
height: 140px;
}
.avatar-wizard-meta {
font-size: 13px;
line-height: 1.35;
color: #d9e7ff;
word-break: break-word;
}
.avatar-wizard-error {
min-height: 18px;
font-size: 13px;
color: #f6a8b3;
}
.avatar-wizard-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.avatar-wizard-choice-grid {
display: grid;
gap: 10px;
}
.profile-param-item {
padding: 10px;
gap: 6px;