import { renderHeader } from '../components/header.js'; import { authService, state } from '../state.js'; import { base64ToBytes, bytesToBase58, publicKeyB64FromPkcs8Ed25519 } from '../services/crypto-utils.js'; import { loadEncryptedUserSecrets } from '../services/key-vault.js'; import { resolveShineServerByServerLogin } from '../services/shine-server-resolver.js'; import { readShineUserPda, updateShineUserPdaOnSolana } from '../services/shine-user-pda-service.js'; import { getTopupSiteUrl } from '../services/solana-wallet-service.js'; export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' }; function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function normalizeLogin(value) { return String(value || '').trim().toLowerCase(); } function equalBytes(a, b) { const left = a instanceof Uint8Array ? a : new Uint8Array(a || []); const right = b instanceof Uint8Array ? b : new Uint8Array(b || []); if (left.length !== right.length) return false; for (let i = 0; i < left.length; i += 1) { if (left[i] !== right[i]) return false; } return true; } function uniqueLogins(values) { const seen = new Set(); const result = []; (Array.isArray(values) ? values : []).forEach((value) => { const login = normalizeLogin(value); if (!login || seen.has(login)) return; seen.add(login); result.push(login); }); return result; } function shortenSignature(value) { const text = String(value || '').trim(); if (text.length <= 16) return text; return `${text.slice(0, 8)}...${text.slice(-8)}`; } function isInsufficientFundsForRentError(error) { const text = [ error?.message, error?.transactionMessage, Array.isArray(error?.logs) ? error.logs.join('\n') : '', Array.isArray(error?.transactionLogs) ? error.transactionLogs.join('\n') : '', Array.isArray(error?.simulationLogs) ? error.simulationLogs.join('\n') : '', ].filter(Boolean).join('\n').toLowerCase(); return text.includes('insufficient funds') && text.includes('rent'); } function clientAddressFromPublicB64(publicKeyB64) { const bytes = base64ToBytes(publicKeyB64); if (bytes.length !== 32) throw new Error('client public key должен быть 32 байта'); return bytesToBase58(bytes); } async function clientAddressFromPrivatePkcs8(privatePkcs8B64) { return clientAddressFromPublicB64(await publicKeyB64FromPkcs8Ed25519(privatePkcs8B64)); } function createConfirmModal() { const root = document.getElementById('modal-root'); if (!(root instanceof HTMLElement)) return null; let onConfirm = null; let onCancel = null; const close = () => { root.innerHTML = ''; onConfirm = null; onCancel = null; }; return { open({ title, text, note = '', confirmLabel = 'Да', cancelLabel = 'Нет', onConfirm: confirmHandler, onCancel: cancelHandler, }) { root.innerHTML = ` `; const modal = root.querySelector('#access-servers-confirm-modal'); const titleEl = root.querySelector('#access-servers-dialog-title'); const textEl = root.querySelector('#access-servers-dialog-text'); const noteEl = root.querySelector('#access-servers-dialog-note'); const cancelBtn = root.querySelector('#access-servers-dialog-cancel'); const confirmBtn = root.querySelector('#access-servers-dialog-confirm'); if (!(modal instanceof HTMLElement) || !(titleEl instanceof HTMLElement) || !(textEl instanceof HTMLElement) || !(noteEl instanceof HTMLElement) || !(cancelBtn instanceof HTMLButtonElement) || !(confirmBtn instanceof HTMLButtonElement)) { close(); return; } titleEl.textContent = String(title || 'Подтверждение'); textEl.textContent = String(text || ''); if (note) { noteEl.hidden = false; noteEl.textContent = String(note); } else { noteEl.hidden = true; noteEl.textContent = ''; } cancelBtn.textContent = String(cancelLabel || 'Нет'); confirmBtn.textContent = String(confirmLabel || 'Да'); onConfirm = confirmHandler || null; onCancel = cancelHandler || null; modal.addEventListener('click', (event) => { if (event.target === modal) close(); }); cancelBtn.addEventListener('click', async () => { const handler = onCancel; close(); if (typeof handler === 'function') await handler(); }); confirmBtn.addEventListener('click', async () => { const handler = onConfirm; close(); if (typeof handler === 'function') await handler(); }); }, destroy() { close(); }, }; } function createPasswordModal() { const root = document.getElementById('modal-root'); if (!(root instanceof HTMLElement)) return null; return { open({ title, text, note = '' }) { root.innerHTML = ` `; return new Promise((resolve) => { const modal = root.querySelector('#access-servers-password-modal'); const titleEl = root.querySelector('#access-servers-password-title'); const textEl = root.querySelector('#access-servers-password-text'); const noteEl = root.querySelector('#access-servers-password-note'); const inputEl = root.querySelector('#access-servers-password-input'); const cancelBtn = root.querySelector('#access-servers-password-cancel'); const confirmBtn = root.querySelector('#access-servers-password-confirm'); if (!(modal instanceof HTMLElement) || !(titleEl instanceof HTMLElement) || !(textEl instanceof HTMLElement) || !(noteEl instanceof HTMLElement) || !(inputEl instanceof HTMLInputElement) || !(cancelBtn instanceof HTMLButtonElement) || !(confirmBtn instanceof HTMLButtonElement)) { root.innerHTML = ''; resolve(null); return; } titleEl.textContent = String(title || 'Введите пароль'); textEl.textContent = String(text || ''); if (note) { noteEl.hidden = false; noteEl.textContent = String(note); } else { noteEl.hidden = true; noteEl.textContent = ''; } const close = (result = null) => { root.innerHTML = ''; resolve(result); }; modal.addEventListener('click', (event) => { if (event.target === modal) close(null); }); cancelBtn.addEventListener('click', () => close(null)); inputEl.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); confirmBtn.click(); } }); confirmBtn.addEventListener('click', () => { const password = String(inputEl.value || ''); if (!password.trim()) { inputEl.focus(); return; } const mode = root.querySelector('input[name="access-servers-key-mode"]:checked'); close({ password, saveRoot: mode instanceof HTMLInputElement && mode.value === 'save', }); }); window.setTimeout(() => inputEl.focus(), 0); }); }, destroy() { root.innerHTML = ''; }, }; } export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; const sessionLogin = normalizeLogin(state.session.login); const solanaEndpoint = String(state.entrySettings.solanaServer || '').trim(); const storagePwd = String(state.session.storagePwdInMemory || '').trim(); let disposed = false; let operationBusy = false; let currentAccessServers = []; let selectedCandidate = null; let suggestionsLoading = false; const confirmModal = createConfirmModal(); const passwordModal = createPasswordModal(); const introCard = document.createElement('div'); introCard.className = 'card stack'; introCard.innerHTML = `

Где хранятся личные данные

Серверы доступа хранят зашифрованную личную переписку пользователя и участвуют в звонках. Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния, а доступ пользователя и приватная переписка хранятся только на этих серверах.

`; const listCard = document.createElement('div'); listCard.className = 'card stack'; const listTitle = document.createElement('p'); listTitle.className = 'field-label'; listTitle.textContent = 'Текущий список серверов доступа'; const listHint = document.createElement('p'); listHint.className = 'meta-muted'; listHint.textContent = sessionLogin ? `Пользователь: @${sessionLogin}` : 'В текущей сессии не найден логин пользователя.'; const listBody = document.createElement('div'); listBody.className = 'stack'; const listStatus = document.createElement('p'); listStatus.className = 'meta-muted'; listStatus.textContent = 'Загрузка данных из PDA...'; listCard.append(listTitle, listHint, listBody, listStatus); const addCard = document.createElement('div'); addCard.className = 'card stack'; const addTitle = document.createElement('p'); addTitle.className = 'field-label'; addTitle.textContent = 'Добавить сервер доступа'; const addHint = document.createElement('p'); addHint.className = 'meta-muted'; addHint.textContent = 'Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.'; const addInput = document.createElement('input'); addInput.className = 'input'; addInput.type = 'text'; addInput.autocomplete = 'off'; addInput.placeholder = 'Например: shineup'; const suggestEl = document.createElement('div'); suggestEl.className = 'profile-relative-search-suggest'; suggestEl.hidden = true; const addStatus = document.createElement('p'); addStatus.className = 'meta-muted'; addStatus.textContent = 'Для изменения списка понадобится подпись root key.'; const addButton = document.createElement('button'); addButton.className = 'primary-btn'; addButton.type = 'button'; addButton.textContent = 'Добавить сервер'; addButton.disabled = true; addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton); const refreshAddButton = () => { addButton.disabled = operationBusy || (!selectedCandidate && !normalizeLogin(addInput.value)); }; const setOperationBusy = (busy) => { operationBusy = busy; addInput.disabled = busy; refreshAddButton(); listBody.querySelectorAll('button').forEach((button) => { button.disabled = busy; }); }; const setSelectedCandidate = (candidate) => { selectedCandidate = candidate; refreshAddButton(); if (candidate) { addInput.value = candidate.login; addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`; } else { addStatus.textContent = 'Для изменения списка понадобится подпись root key.'; } }; const renderServerList = () => { listBody.innerHTML = ''; if (!currentAccessServers.length) { const empty = document.createElement('p'); empty.className = 'meta-muted'; empty.textContent = 'Список серверов доступа пока пуст.'; listBody.append(empty); return; } currentAccessServers.forEach((server, index) => { const button = document.createElement('button'); button.className = 'text-btn'; button.type = 'button'; button.disabled = operationBusy; button.innerHTML = ` @${escapeHtml(server.login)} ${escapeHtml(server.url || 'URL не указан')} `; button.addEventListener('click', () => { if (operationBusy) return; const isLast = currentAccessServers.length <= 1; const performDisable = async () => { const nextList = currentAccessServers .map((item) => item.login) .filter((login) => login !== server.login); try { await updateAccessServers(nextList, { statusTarget: listStatus, successText: `Сервер доступа @${server.login} отключён.`, inFlightText: `Обновляем PDA и отключаем сервер @${server.login}...`, }); } catch { // Сообщение уже показано в статусе. } }; const askFinal = (noteText = '') => { confirmModal?.open({ title: isLast ? 'Последний сервер доступа' : 'Отключить сервер доступа?', text: isLast ? 'Это последний сервер доступа пользователя. Если его отключить, личная переписка пользователя на серверах доступа будет удалена.' : `Хотите изменить запись в блокчейне Solana и отключить сервер доступа @${server.login}?`, note: noteText, confirmLabel: isLast ? 'Понимаю' : 'Да', cancelLabel: isLast ? 'Отмена' : 'Нет', onConfirm: performDisable, }); }; if (isLast) { confirmModal?.open({ title: 'Отключить последний сервер?', text: `У пользователя остался только один сервер доступа: @${server.login}.`, note: 'После отключения последнего сервера доступа личная переписка пользователя на серверах доступа будет удалена.', confirmLabel: 'Продолжить', cancelLabel: 'Отмена', onConfirm: () => askFinal('Это повторное предупреждение перед записью нового списка серверов в Solana PDA.'), }); return; } askFinal(); }); listBody.append(button); if (index < currentAccessServers.length - 1) { const divider = document.createElement('div'); divider.style.height = '1px'; divider.style.background = 'rgba(255,255,255,0.08)'; listBody.append(divider); } }); }; const loadCurrentServers = async () => { if (!sessionLogin) { currentAccessServers = []; renderServerList(); listStatus.textContent = 'Нет активной пользовательской сессии.'; return; } if (!solanaEndpoint) { currentAccessServers = []; renderServerList(); listStatus.textContent = 'Не задан Solana RPC endpoint.'; return; } listStatus.textContent = 'Читаем серверы доступа из Solana PDA...'; try { const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint }); const logins = uniqueLogins(parsed?.accessServers); const rows = []; for (const login of logins) { try { const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint }); rows.push({ login: resolved.serverLogin, url: resolved.httpBase }); } catch { rows.push({ login, url: '' }); } } currentAccessServers = rows; renderServerList(); listStatus.textContent = rows.length ? `Найдено серверов доступа: ${rows.length}` : 'В PDA пользователя пока нет серверов доступа.'; } catch (error) { currentAccessServers = []; renderServerList(); listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.'; } }; const renderSuggestions = (items) => { suggestEl.innerHTML = ''; if (!items.length) { suggestEl.hidden = true; return; } suggestEl.hidden = false; suggestEl.innerHTML = items.map((item) => ( `` )).join(''); }; const showTopupRequiredStatus = (target, clientAddress) => { const address = String(clientAddress || '').trim(); const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup'; target.innerHTML = ` Не хватает SOL на client key для оплаты Solana rent/fee при обновлении user PDA. ${address ? `Кошелёк: ${escapeHtml(address)}` : ''} Пополнить DEVNET кошелёк `; }; const loadSuggestions = async () => { const prefix = normalizeLogin(addInput.value); if (suggestionsLoading || prefix.length < 2 || operationBusy) { suggestEl.hidden = true; suggestEl.innerHTML = ''; return; } suggestionsLoading = true; try { const logins = await authService.searchUsers(prefix, { isServer: true }); const items = []; for (const login of logins.slice(0, 6)) { try { const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint }); items.push({ login: resolved.serverLogin, url: resolved.httpBase }); } catch { // Если server PDA повреждена, не показываем подсказку. } } if (!disposed) renderSuggestions(items); } catch (error) { if (!disposed) { suggestEl.hidden = true; suggestEl.innerHTML = ''; addStatus.textContent = error?.message || 'Не удалось получить список серверов.'; } } finally { suggestionsLoading = false; } }; const resolveAccessServerSigningMaterial = async (currentPda) => { if (!sessionLogin) throw new Error('Нет активной пользовательской сессии.'); if (!storagePwd) throw new Error('В памяти сессии нет storagePwd. Выполните вход заново.'); let saved = null; try { saved = await loadEncryptedUserSecrets(sessionLogin, storagePwd); } catch { saved = null; } const savedRoot = String(saved?.rootKey || '').trim(); const savedClient = String(saved?.clientKey || '').trim(); if (savedRoot && savedClient) { return { rootPrivatePkcs8B64: savedRoot, clientPrivatePkcs8B64: savedClient, clientAddress: await clientAddressFromPrivatePkcs8(savedClient), }; } const passwordResult = await passwordModal?.open({ title: 'Нужен пароль для обновления серверов доступа', text: 'Чтобы изменить список серверов доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.', note: savedClient ? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.' : 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.', }); if (!passwordResult) { throw new Error('Операция отменена пользователем.'); } const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password); const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64); const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64); if (!equalBytes(derivedRootPublic, currentPda.rootKey)) { throw new Error('Пароль не подходит: root key не совпал с user PDA.'); } if (!equalBytes(derivedClientPublic, currentPda.clientKey)) { throw new Error('Пароль не подходит: client key не совпал с user PDA.'); } if (passwordResult.saveRoot) { await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: false, }); } return { rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64, clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64, clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64), }; }; const updateAccessServers = async (nextLogins, { statusTarget, successText, inFlightText, } = {}) => { const target = statusTarget instanceof HTMLElement ? statusTarget : listStatus; if (!sessionLogin) throw new Error('Нет активной пользовательской сессии.'); if (!solanaEndpoint) throw new Error('Не задан Solana RPC endpoint.'); if (operationBusy) return; const normalizedList = uniqueLogins(nextLogins); setOperationBusy(true); target.textContent = String(inFlightText || 'Обновляем список серверов доступа...'); let signingMaterial = null; try { const currentPda = await readShineUserPda({ login: sessionLogin, solanaEndpoint }); signingMaterial = await resolveAccessServerSigningMaterial(currentPda); const tx = await updateShineUserPdaOnSolana({ login: sessionLogin, solanaEndpoint, rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64, clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64, accessServers: normalizedList, }); await loadCurrentServers(); target.textContent = `${String(successText || 'Список серверов доступа обновлён.')} Tx: ${shortenSignature(tx?.signature)}`; setSelectedCandidate(null); suggestEl.hidden = true; suggestEl.innerHTML = ''; refreshAddButton(); } catch (error) { if (isInsufficientFundsForRentError(error)) { showTopupRequiredStatus(target, signingMaterial?.clientAddress); } else { target.textContent = error?.message || 'Не удалось обновить список серверов доступа.'; } throw error; } finally { setOperationBusy(false); } }; addInput.addEventListener('input', () => { setSelectedCandidate(null); refreshAddButton(); void loadSuggestions(); }); addInput.addEventListener('focus', () => { void loadSuggestions(); }); suggestEl.addEventListener('click', (event) => { const target = event.target instanceof HTMLElement ? event.target.closest('[data-login]') : null; if (!(target instanceof HTMLElement)) return; setSelectedCandidate({ login: String(target.dataset.login || ''), url: String(target.dataset.url || ''), }); suggestEl.hidden = true; suggestEl.innerHTML = ''; }); addButton.addEventListener('click', async () => { if (operationBusy) return; const login = normalizeLogin(selectedCandidate?.login || addInput.value); if (!login) { setSelectedCandidate(null); addStatus.textContent = 'Сначала укажите логин сервера доступа.'; return; } if (currentAccessServers.some((item) => item.login === login)) { addStatus.textContent = `Сервер @${login} уже есть в списке.`; return; } try { const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint }); confirmModal?.open({ title: 'Добавить сервер доступа?', text: `Вы хотите добавить сервер доступа @${resolved.serverLogin}?`, note: resolved.httpBase ? `Адрес сервера: ${resolved.httpBase}\nИзменение будет записано в Solana user PDA.` : 'Изменение будет записано в Solana user PDA.', onConfirm: async () => { const nextList = uniqueLogins([...currentAccessServers.map((item) => item.login), resolved.serverLogin]); try { await updateAccessServers(nextList, { statusTarget: addStatus, successText: `Сервер доступа @${resolved.serverLogin} добавлен.`, inFlightText: `Обновляем PDA и добавляем сервер @${resolved.serverLogin}...`, }); } catch { // Сообщение уже показано в статусе. } }, }); } catch (error) { addStatus.textContent = error?.message || 'Не удалось проверить выбранный сервер.'; } }); screen.append( renderHeader({ title: 'Серверы доступа', leftAction: { label: '←', onClick: () => navigate('settings-view') }, }), introCard, listCard, addCard, ); void loadCurrentServers(); screen.cleanup = () => { disposed = true; confirmModal?.destroy(); passwordModal?.destroy(); }; return screen; }