Files
SHiNE-server/shine-UI/js/pages/access-servers-view.js
T

704 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 = `
<div class="modal" id="access-servers-confirm-modal">
<div class="modal-card stack" style="max-width:min(94vw,34rem);">
<h3 class="modal-title" id="access-servers-dialog-title"></h3>
<p class="meta-muted" id="access-servers-dialog-text" style="white-space:pre-wrap; line-height:1.45;"></p>
<p class="meta-muted" id="access-servers-dialog-note"${note ? '' : ' hidden'} style="white-space:pre-wrap; line-height:1.45;"></p>
<div class="auth-footer-actions">
<button class="ghost-btn" type="button" id="access-servers-dialog-cancel"></button>
<button class="primary-btn" type="button" id="access-servers-dialog-confirm"></button>
</div>
</div>
</div>
`;
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 = `
<div class="modal" id="access-servers-password-modal">
<div class="modal-card stack" style="max-width:min(94vw,36rem);">
<h3 class="modal-title" id="access-servers-password-title"></h3>
<p class="meta-muted" id="access-servers-password-text" style="white-space:pre-wrap; line-height:1.45;"></p>
<p class="meta-muted" id="access-servers-password-note"${note ? '' : ' hidden'} style="white-space:pre-wrap; line-height:1.45;"></p>
<label class="stack" style="gap:0.35rem;">
<span class="field-label">Пароль аккаунта</span>
<input class="input" id="access-servers-password-input" type="password" autocomplete="current-password" placeholder="Введите пароль" />
</label>
<div class="stack" style="gap:0.45rem;">
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="once" checked />
<span>Использовать root key только сейчас</span>
</label>
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="save" />
<span>Сохранить root key на этом устройстве</span>
</label>
</div>
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button>
<button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button>
</div>
</div>
</div>
`;
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 = `
<p class="field-label">Где хранятся личные данные</p>
<p class="meta-muted">
Серверы доступа хранят зашифрованную личную переписку пользователя и участвуют в звонках.
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
а доступ пользователя и приватная переписка хранятся только на этих серверах.
</p>
`;
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 = `
<span style="display:block; text-align:left;">
<strong>@${escapeHtml(server.login)}</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(server.url || 'URL не указан')}</span>
</span>
`;
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) => (
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
@${escapeHtml(item.login)}
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(item.url || 'URL не указан')}</span>
</button>`
)).join('');
};
const showTopupRequiredStatus = (target, clientAddress) => {
const address = String(clientAddress || '').trim();
const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup';
target.innerHTML = `
<span style="display:block; margin-bottom:0.55rem;">
Не хватает SOL на client key для оплаты Solana rent/fee при обновлении user PDA.
</span>
${address ? `<span style="display:block; overflow-wrap:anywhere; margin-bottom:0.55rem;">Кошелёк: ${escapeHtml(address)}</span>` : ''}
<a class="primary-btn" href="${escapeHtml(topupUrl)}" target="_blank" rel="noopener" style="display:inline-flex; text-decoration:none;">Пополнить DEVNET кошелёк</a>
`;
};
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;
}