Перенести server UI в shine-UI и объединить PDA-модуль

This commit is contained in:
AidarKC
2026-06-03 15:11:26 +04:00
parent c97b3e3ec3
commit d12371b84f
22 changed files with 2006 additions and 753 deletions
+127
View File
@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Регистрация сервера — SHiNE Server Admin</title>
<link rel="stylesheet" href="styles.css" />
<style>
.pwd-wrap { display: flex; }
.pwd-wrap input { flex: 1; border-radius: var(--radius) 0 0 var(--radius); }
.btn-eye { border: 1px solid var(--border); border-left: none; background: #0d0d0d;
color: var(--text-muted); border-radius: 0 var(--radius) var(--radius) 0;
padding: 0 16px; cursor: pointer; font-size: 13px; }
.btn-eye:hover { color: var(--accent); border-color: var(--accent); }
.gen-msg { font-size: 12px; margin-top: 8px; padding: 8px 12px; border-radius: var(--radius); display: none; }
.gen-msg.ok { display:block; background:#1a2e1a; border:1px solid #2a4a2a; color:#7dcc7d; }
.gen-msg.err { display:block; background:#2e1a1a; border:1px solid #5a2a2a; color:#f08080; }
.kp-title { font-size:11px; font-weight:700; color:var(--accent); text-transform:uppercase; letter-spacing:.06em; margin-bottom:8px; }
.kp-row { display:flex; gap:8px; align-items:flex-start; margin-bottom:6px; }
.kp-row:last-child { margin-bottom:0; }
.kp-lbl { font-size:11px; color:var(--text-muted); min-width:60px; padding-top:10px; }
.kp-inp { flex:1; font-size:11px; font-family:monospace; padding:8px 10px; }
.kp-block { margin-bottom:14px; padding-bottom:14px; border-bottom:1px solid var(--border); }
.kp-block:last-child { border-bottom:none; margin-bottom:0; padding-bottom:0; }
.sec-lbl { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; margin:16px 0 10px; }
.sol-box { margin-top:14px; background:#0d1a0d; border:1px solid #2a4a2a; border-radius:var(--radius); padding:10px 14px; display:none; }
.sol-box.show { display:block; }
.sol-ttl { font-size:12px; font-weight:600; color:#7dcc7d; }
.sol-adr { font-family:monospace; font-size:12px; word-break:break-all; margin-top:4px; }
.sol-ht { font-size:11px; color:var(--text-muted); margin-top:4px; }
</style>
</head>
<body>
<div class="container">
<div class="nav-links">
<a href="../server-ui.html">← Назад</a>
<a href="update-server-pda.html">Обновить PDA</a>
</div>
<h1>Регистрация серверного аккаунта</h1>
<p class="subtitle">Создаёт user_pda в Solana с флагом is_server=true</p>
<div class="card">
<h2>Параметры Solana</h2>
<div class="field">
<label>Solana Endpoint</label>
<input type="text" id="endpoint" value="https://api.devnet.solana.com" />
<div class="hint">devnet: https://api.devnet.solana.com · mainnet: https://api.mainnet-beta.solana.com</div>
</div>
</div>
<div class="card">
<h2>Данные сервера</h2>
<div class="field">
<label>Логин сервера</label>
<input type="text" id="login" placeholder="shineupme" maxlength="20" />
<div class="hint">Только a-z, 0-9, _ · без точки · макс. 20 символов</div>
</div>
<div class="field">
<label>Адрес сервера (URL)</label>
<input type="text" id="serverAddress" placeholder="https://shineup.me/ws" />
</div>
<div class="field">
<label>Серверы синхронизации (sync_servers)</label>
<textarea id="syncServers" placeholder="По одному логину на строку (можно оставить пустым)"></textarea>
</div>
<div class="field">
<label>Серверы доступа (access_servers, опционально)</label>
<textarea id="accessServers" placeholder="Обычно пусто для серверного PDA"></textarea>
</div>
</div>
<div class="card">
<h2>Ключи сервера</h2>
<div class="field">
<label>Пароль</label>
<div class="pwd-wrap">
<input type="password" id="password" placeholder="Пароль аккаунта сервера" autocomplete="new-password" />
<button class="btn-eye" id="btnEye" type="button">Показать</button>
</div>
<div class="hint">Нажмите «Сгенерировать» — поля ниже заполнятся из логина + пароля (Argon2id).<br/>Или введите ключи вручную.</div>
</div>
<div class="btn-row">
<button class="btn-secondary" id="btnGen" type="button">Сгенерировать ключи</button>
</div>
<div class="gen-msg" id="genMsg"></div>
<div class="sec-lbl">Секрет (master secret, base58)</div>
<div class="field" style="margin-bottom:0">
<input type="text" id="masterSecret" placeholder="32-байтовый master secret в base58 (~44 символа)" />
</div>
<div class="sec-lbl">Ключевые пары (base58)</div>
<div class="kp-block">
<div class="kp-title">Root Key — подпись PDA-записи</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="rootPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="rootPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block">
<div class="kp-title">Blockchain Key — подпись LastBlockState</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="bchPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="bchPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block">
<div class="kp-title">Device Key — оплата транзакции Solana</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="devPub" placeholder="base58, ~44 символа (= Solana-адрес)" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="devPriv" placeholder="seed base58, ~44 символа" /></div>
<div class="sol-box" id="solBox">
<div class="sol-ttl">Положите SOL на этот адрес перед регистрацией:</div>
<div class="sol-adr" id="solAdr"></div>
<div class="sol-ht">Это Solana-адрес device-ключа (public key в base58 = Solana-адрес). С него оплачивается создание PDA.</div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn-primary" id="btnCreate">Зарегистрировать сервер</button>
</div>
<div class="status" id="status"></div>
</div>
<script type="module" src="./js/create-server-pda-page.js"></script>
</body>
</html>
@@ -0,0 +1,97 @@
import { registerServerOnSolana } from '../../js/services/shine-user-pda-service.js';
import {
$,
buildKeyBundleFromForm,
clearGenMessage,
clearStatus,
deriveKeyBundleFromPassword,
fillKeyFields,
parseLoginList,
setGenMessage,
setStatus,
setupPasswordEye,
updateSolAddress,
validateLoginOrThrow,
wireDeviceAddressPreview,
} from './server-ui-shared.js';
const fieldMap = {
masterSecret: 'masterSecret',
rootPub: 'rootPub',
rootPriv: 'rootPriv',
bchPub: 'bchPub',
bchPriv: 'bchPriv',
devPub: 'devPub',
devPriv: 'devPriv',
solBox: 'solBox',
solAdr: 'solAdr',
};
setupPasswordEye($('btnEye'), $('password'));
wireDeviceAddressPreview(fieldMap);
$('btnGen').addEventListener('click', async () => {
clearGenMessage($('genMsg'));
clearStatus($('status'));
$('btnGen').disabled = true;
try {
const login = validateLoginOrThrow($('login').value);
const password = $('password').value;
const { masterSecret32, keyBundle } = await deriveKeyBundleFromPassword({
login,
password,
});
fillKeyFields(fieldMap, keyBundle, masterSecret32);
updateSolAddress(fieldMap);
setGenMessage($('genMsg'), 'Ключи и master secret сгенерированы из логина и пароля.', 'ok');
} catch (error) {
setGenMessage($('genMsg'), error?.message || String(error), 'err');
} finally {
$('btnGen').disabled = false;
}
});
$('btnCreate').addEventListener('click', async () => {
clearStatus($('status'));
clearGenMessage($('genMsg'));
$('btnCreate').disabled = true;
try {
const login = validateLoginOrThrow($('login').value);
const endpoint = String($('endpoint').value || '').trim();
if (!endpoint) throw new Error('Укажите Solana endpoint');
const serverAddress = String($('serverAddress').value || '').trim();
if (!serverAddress) throw new Error('Укажите адрес сервера');
setStatus($('status'), 'Проверка и сборка keyBundle...', 'info');
const { keyBundle, normalized } = await buildKeyBundleFromForm(fieldMap);
$('rootPub').value = normalized.rootPubB58;
$('rootPriv').value = normalized.rootPrivB58;
$('bchPub').value = normalized.bchPubB58;
$('bchPriv').value = normalized.bchPrivB58;
$('devPub').value = normalized.devPubB58;
$('devPriv').value = normalized.devPrivB58;
updateSolAddress(fieldMap);
setStatus($('status'), 'Отправка create_user_pda в Solana...', 'info');
const result = await registerServerOnSolana({
login,
keyBundle,
serverAddress,
syncServers: parseLoginList($('syncServers').value),
accessServers: parseLoginList($('accessServers').value),
solanaEndpoint: endpoint,
});
setStatus(
$('status'),
`✓ Сервер зарегистрирован!\n\nЛогин: ${login}\nPDA: ${result.pdaAddress}\nBlockchain: ${result.blockchainName}\nТранзакция: ${result.signature}`,
'success',
);
} catch (error) {
setStatus($('status'), error?.message || String(error), 'error');
} finally {
$('btnCreate').disabled = false;
}
});
document.body.dataset.ready = '1';
+196
View File
@@ -0,0 +1,196 @@
import {
base58ToBytes,
base64ToBytes,
bytesToBase58,
bytesToBase64,
deriveEd25519FromMasterSecret,
deriveMasterSecretFromPassword,
publicKeyB64FromPkcs8Ed25519,
} from '../../js/services/crypto-utils.js';
const LOGIN_RE = /^[a-z0-9_]{1,20}$/;
const ED25519_PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
export function $(id) {
return document.getElementById(id);
}
export function normalizeLogin(login) {
return String(login || '').trim().toLowerCase();
}
export function validateLoginOrThrow(login) {
const clean = normalizeLogin(login);
if (!LOGIN_RE.test(clean)) {
throw new Error('Логин должен содержать только a-z, 0-9, _ и быть длиной 1..20 символов');
}
return clean;
}
export function parseLoginList(text) {
return String(text || '')
.split(/\r?\n/)
.map((value) => value.trim().toLowerCase())
.filter(Boolean);
}
export function formatBigInt(value) {
return BigInt(value || 0n).toString(10);
}
export function formatTimestamp(value) {
const ts = Number(BigInt(value || 0n));
if (!Number.isFinite(ts) || ts <= 0) return '—';
return new Date(ts).toLocaleString('ru-RU');
}
export function setStatus(node, text, kind = 'info') {
node.className = `status ${kind}`;
node.textContent = String(text || '');
}
export function clearStatus(node) {
node.className = 'status';
node.textContent = '';
}
export function setGenMessage(node, text, kind) {
node.className = `gen-msg ${kind}`;
node.textContent = String(text || '');
}
export function clearGenMessage(node) {
node.className = 'gen-msg';
node.textContent = '';
}
export function setupPasswordEye(button, input) {
button.addEventListener('click', () => {
const nextType = input.type === 'password' ? 'text' : 'password';
input.type = nextType;
button.textContent = nextType === 'password' ? 'Показать' : 'Скрыть';
});
}
function ensure32Bytes(bytes) {
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
if (input.length > 32) throw new Error(`Ожидалось максимум 32 байта, получено ${input.length}`);
if (input.length === 32) return input;
const out = new Uint8Array(32);
out.set(input, 32 - input.length);
return out;
}
function pkcs8FromSeed32(seed32) {
const seed = ensure32Bytes(seed32);
const out = new Uint8Array(ED25519_PKCS8_PREFIX.length + seed.length);
out.set(ED25519_PKCS8_PREFIX, 0);
out.set(seed, ED25519_PKCS8_PREFIX.length);
return out;
}
async function pairFromSeedBase58(seedB58, explicitPubB58) {
const seed32 = ensure32Bytes(base58ToBytes(seedB58));
const privatePkcs8B64 = bytesToBase64(pkcs8FromSeed32(seed32));
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(privatePkcs8B64);
const actualPubB58 = bytesToBase58(base64ToBytes(publicKeyB64));
const expectedPubB58 = String(explicitPubB58 || '').trim();
if (expectedPubB58 && actualPubB58 !== expectedPubB58) {
throw new Error(`Публичный ключ не совпадает с приватным seed: ${expectedPubB58}`);
}
return {
publicKeyB64,
privatePkcs8B64,
publicKeyB58: actualPubB58,
privateSeedB58: bytesToBase58(seed32),
};
}
export async function buildKeyBundleFromForm(fieldMap, options = {}) {
const requireBlockchain = options.requireBlockchain !== false;
const root = await pairFromSeedBase58($(fieldMap.rootPriv).value, $(fieldMap.rootPub).value);
const device = await pairFromSeedBase58($(fieldMap.devPriv).value, $(fieldMap.devPub).value);
const blockchainPriv = String($(fieldMap.bchPriv).value || '').trim();
const blockchainPub = String($(fieldMap.bchPub).value || '').trim();
const hasBlockchainInput = Boolean(blockchainPriv || blockchainPub);
let blockchain = null;
if (requireBlockchain || hasBlockchainInput) {
blockchain = await pairFromSeedBase58(blockchainPriv, blockchainPub);
}
return {
keyBundle: {
rootPair: { publicKeyB64: root.publicKeyB64, privatePkcs8B64: root.privatePkcs8B64 },
blockchainPair: blockchain
? { publicKeyB64: blockchain.publicKeyB64, privatePkcs8B64: blockchain.privatePkcs8B64 }
: null,
devicePair: { publicKeyB64: device.publicKeyB64, privatePkcs8B64: device.privatePkcs8B64 },
},
normalized: {
rootPubB58: root.publicKeyB58,
rootPrivB58: root.privateSeedB58,
bchPubB58: blockchain?.publicKeyB58 || '',
bchPrivB58: blockchain?.privateSeedB58 || '',
devPubB58: device.publicKeyB58,
devPrivB58: device.privateSeedB58,
},
};
}
export async function deriveKeyBundleFromPassword({ login, password, onProgress }) {
const cleanLogin = validateLoginOrThrow(login);
const cleanPassword = String(password ?? '');
if (!cleanPassword) throw new Error('Введите пароль');
const masterSecret32 = await deriveMasterSecretFromPassword(cleanPassword, {
login: cleanLogin,
onProgress,
});
const [rootPair, blockchainPair, devicePair] = await Promise.all([
deriveEd25519FromMasterSecret(masterSecret32, 'root.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'bch.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'dev.key'),
]);
return {
masterSecret32,
keyBundle: { rootPair, blockchainPair, devicePair },
};
}
export function fillKeyFields(fieldMap, keyBundle, masterSecret32) {
if (masterSecret32) {
$(fieldMap.masterSecret).value = bytesToBase58(masterSecret32);
}
$(fieldMap.rootPub).value = bytesToBase58(base64ToBytes(keyBundle.rootPair.publicKeyB64));
$(fieldMap.rootPriv).value = bytesToBase58(base64ToBytes(keyBundle.rootPair.privatePkcs8B64).slice(-32));
$(fieldMap.bchPub).value = bytesToBase58(base64ToBytes(keyBundle.blockchainPair.publicKeyB64));
$(fieldMap.bchPriv).value = bytesToBase58(base64ToBytes(keyBundle.blockchainPair.privatePkcs8B64).slice(-32));
$(fieldMap.devPub).value = bytesToBase58(base64ToBytes(keyBundle.devicePair.publicKeyB64));
$(fieldMap.devPriv).value = bytesToBase58(base64ToBytes(keyBundle.devicePair.privatePkcs8B64).slice(-32));
}
export function updateSolAddress(fieldMap) {
const box = $(fieldMap.solBox);
const label = $(fieldMap.solAdr);
const pubB58 = String($(fieldMap.devPub).value || '').trim();
if (!pubB58) {
box.classList.remove('show');
label.textContent = '';
return;
}
try {
ensure32Bytes(base58ToBytes(pubB58));
label.textContent = pubB58;
box.classList.add('show');
} catch {
box.classList.remove('show');
label.textContent = '';
}
}
export function wireDeviceAddressPreview(fieldMap) {
const update = () => updateSolAddress(fieldMap);
$(fieldMap.devPub).addEventListener('input', update);
update();
}
@@ -0,0 +1,140 @@
import { readShineUserPda, updateServerOnSolana } from '../../js/services/shine-user-pda-service.js';
import {
$,
buildKeyBundleFromForm,
clearGenMessage,
clearStatus,
deriveKeyBundleFromPassword,
fillKeyFields,
formatBigInt,
formatTimestamp,
parseLoginList,
setGenMessage,
setStatus,
setupPasswordEye,
updateSolAddress,
validateLoginOrThrow,
wireDeviceAddressPreview,
} from './server-ui-shared.js';
const fieldMap = {
masterSecret: 'masterSecret',
rootPub: 'rootPub',
rootPriv: 'rootPriv',
bchPub: 'bchPub',
bchPriv: 'bchPriv',
devPub: 'devPub',
devPriv: 'devPriv',
solBox: 'solBox',
solAdr: 'solAdr',
};
let currentPda = null;
setupPasswordEye($('btnEye'), $('password'));
wireDeviceAddressPreview(fieldMap);
$('btnGen').addEventListener('click', async () => {
clearGenMessage($('genMsg'));
clearStatus($('status'));
$('btnGen').disabled = true;
try {
const login = validateLoginOrThrow($('login').value);
const password = $('password').value;
const { masterSecret32, keyBundle } = await deriveKeyBundleFromPassword({ login, password });
fillKeyFields(fieldMap, keyBundle, masterSecret32);
updateSolAddress(fieldMap);
setGenMessage($('genMsg'), 'Ключи и master secret сгенерированы из логина и пароля.', 'ok');
} catch (error) {
setGenMessage($('genMsg'), error?.message || String(error), 'err');
} finally {
$('btnGen').disabled = false;
}
});
$('btnLoad').addEventListener('click', async () => {
clearStatus($('status'));
clearGenMessage($('genMsg'));
$('btnLoad').disabled = true;
currentPda = null;
$('pdaInfo').style.display = 'none';
$('updateForm').style.display = 'none';
try {
const login = validateLoginOrThrow($('login').value);
const endpoint = String($('endpoint').value || '').trim();
if (!endpoint) throw new Error('Укажите Solana endpoint');
setStatus($('status'), 'Загрузка PDA из Solana...', 'info');
const parsed = await readShineUserPda({ login, solanaEndpoint: endpoint });
if (!parsed.isServer) throw new Error('Эта PDA не является серверной');
currentPda = parsed;
$('iAddr').textContent = parsed.pdaAddress;
$('iVer').textContent = `#${parsed.recordNumber}`;
$('iCreated').textContent = formatTimestamp(parsed.createdAtMs);
$('iUpdated').textContent = formatTimestamp(parsed.updatedAtMs);
$('iSrvAddr').textContent = parsed.serverAddress || '—';
$('iSync').textContent = parsed.syncServers.length ? parsed.syncServers.join(', ') : '—';
$('iBch').textContent = parsed.blockchain.blockchainName;
$('iLimit').textContent = formatBigInt(parsed.blockchain.paidLimitBytes);
$('serverAddress').value = parsed.serverAddress || '';
$('syncServers').value = parsed.syncServers.join('\n');
$('pdaInfo').style.display = 'block';
$('updateForm').style.display = 'block';
setStatus($('status'), 'PDA загружена. Можно менять адрес или sync_servers.', 'success');
} catch (error) {
setStatus($('status'), error?.message || String(error), 'error');
} finally {
$('btnLoad').disabled = false;
}
});
$('btnUpdate').addEventListener('click', async () => {
clearStatus($('status'));
clearGenMessage($('genMsg'));
$('btnUpdate').disabled = true;
try {
if (!currentPda) throw new Error('Сначала загрузите PDA');
const endpoint = String($('endpoint').value || '').trim();
if (!endpoint) throw new Error('Укажите Solana endpoint');
const serverAddress = String($('serverAddress').value || '').trim();
if (!serverAddress) throw new Error('Укажите адрес сервера');
setStatus($('status'), 'Проверка и сборка keyBundle...', 'info');
const { keyBundle, normalized } = await buildKeyBundleFromForm(fieldMap, { requireBlockchain: false });
$('rootPub').value = normalized.rootPubB58;
$('rootPriv').value = normalized.rootPrivB58;
$('bchPub').value = normalized.bchPubB58;
$('bchPriv').value = normalized.bchPrivB58;
$('devPub').value = normalized.devPubB58;
$('devPriv').value = normalized.devPrivB58;
updateSolAddress(fieldMap);
setStatus($('status'), 'Отправка update_user_pda в Solana...', 'info');
const result = await updateServerOnSolana({
login: currentPda.login,
keyBundle,
serverAddress,
addressFormatType: currentPda.addressFormatType ?? 1,
addressFormatVersion: currentPda.addressFormatVersion ?? 0,
syncServers: parseLoginList($('syncServers').value),
solanaEndpoint: endpoint,
});
setStatus(
$('status'),
`✓ PDA обновлена!\n\nЛогин: ${currentPda.login}\nPDA: ${result.pdaAddress}\nТранзакция: ${result.signature}`,
'success',
);
currentPda = null;
$('pdaInfo').style.display = 'none';
$('updateForm').style.display = 'none';
} catch (error) {
setStatus($('status'), error?.message || String(error), 'error');
} finally {
$('btnUpdate').disabled = false;
}
});
document.body.dataset.ready = '1';
+193
View File
@@ -0,0 +1,193 @@
/* SHiNE Server Admin UI — тёмная тема */
:root {
--bg: #111;
--surface: #1a1a1a;
--border: #2a2a2a;
--text: #e0e0e0;
--text-muted: #888;
--accent: #4a9eff;
--accent-hover: #6ab4ff;
--success: #4caf50;
--error: #f44336;
--warning: #ff9800;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
font-size: 14px;
line-height: 1.5;
padding: 24px 16px;
}
.container {
max-width: 640px;
margin: 0 auto;
}
h1 {
font-size: 20px;
font-weight: 600;
color: var(--accent);
margin-bottom: 4px;
}
.subtitle {
color: var(--text-muted);
margin-bottom: 24px;
font-size: 13px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
}
.card h2 {
font-size: 15px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text);
}
.field {
margin-bottom: 14px;
}
label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
input[type="text"], input[type="password"], textarea {
width: 100%;
background: #0d0d0d;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: monospace;
font-size: 13px;
padding: 10px 12px;
outline: none;
transition: border-color 0.15s;
resize: vertical;
}
input[type="text"]:focus, input[type="password"]:focus, textarea:focus {
border-color: var(--accent);
}
input[type="text"][readonly] {
opacity: 0.6;
}
textarea {
min-height: 80px;
}
.hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
.btn-row {
display: flex;
gap: 10px;
margin-top: 20px;
flex-wrap: wrap;
}
button {
padding: 10px 20px;
border-radius: var(--radius);
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s, background 0.15s;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn-secondary {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.status {
padding: 12px 16px;
border-radius: var(--radius);
font-size: 13px;
margin-top: 16px;
word-break: break-all;
display: none;
}
.status.info { display: block; background: #1a2433; border: 1px solid #2a4a6a; color: #7bb8ff; }
.status.success { display: block; background: #1a2e1a; border: 1px solid #2a4a2a; color: #7dcc7d; }
.status.error { display: block; background: #2e1a1a; border: 1px solid #5a2a2a; color: #f08080; }
.pda-info {
display: none;
margin-top: 12px;
}
.pda-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.pda-row:last-child { border-bottom: none; }
.pda-key { color: var(--text-muted); min-width: 160px; }
.pda-value { color: var(--text); font-family: monospace; text-align: right; word-break: break-all; }
.nav-links {
margin-bottom: 20px;
}
.nav-links a {
color: var(--accent);
text-decoration: none;
margin-right: 16px;
font-size: 13px;
}
.nav-links a:hover { text-decoration: underline; }
.section-divider {
border: none;
border-top: 1px solid var(--border);
margin: 20px 0;
}
+143
View File
@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Обновление PDA сервера — SHiNE Server Admin</title>
<link rel="stylesheet" href="styles.css" />
<style>
.pwd-wrap { display: flex; }
.pwd-wrap input { flex: 1; border-radius: var(--radius) 0 0 var(--radius); }
.btn-eye { border: 1px solid var(--border); border-left: none; background: #0d0d0d;
color: var(--text-muted); border-radius: 0 var(--radius) var(--radius) 0;
padding: 0 16px; cursor: pointer; font-size: 13px; }
.btn-eye:hover { color: var(--accent); border-color: var(--accent); }
.gen-msg { font-size: 12px; margin-top: 8px; padding: 8px 12px; border-radius: var(--radius); display: none; }
.gen-msg.ok { display:block; background:#1a2e1a; border:1px solid #2a4a2a; color:#7dcc7d; }
.gen-msg.err { display:block; background:#2e1a1a; border:1px solid #5a2a2a; color:#f08080; }
.kp-title { font-size:11px; font-weight:700; color:var(--accent); text-transform:uppercase; letter-spacing:.06em; margin-bottom:8px; }
.kp-row { display:flex; gap:8px; align-items:flex-start; margin-bottom:6px; }
.kp-row:last-child { margin-bottom:0; }
.kp-lbl { font-size:11px; color:var(--text-muted); min-width:60px; padding-top:10px; }
.kp-inp { flex:1; font-size:11px; font-family:monospace; padding:8px 10px; }
.kp-block { margin-bottom:14px; padding-bottom:14px; border-bottom:1px solid var(--border); }
.kp-block:last-child { border-bottom:none; margin-bottom:0; padding-bottom:0; }
.sec-lbl { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; margin:16px 0 10px; }
.sol-box { margin-top:14px; background:#0d1a0d; border:1px solid #2a4a2a; border-radius:var(--radius); padding:10px 14px; display:none; }
.sol-box.show { display:block; }
.sol-ttl { font-size:12px; font-weight:600; color:#7dcc7d; }
.sol-adr { font-family:monospace; font-size:12px; word-break:break-all; margin-top:4px; }
.sol-ht { font-size:11px; color:var(--text-muted); margin-top:4px; }
.muted { font-size:12px; color:var(--text-muted); margin-bottom:14px; line-height:1.6; }
</style>
</head>
<body>
<div class="container">
<div class="nav-links">
<a href="../server-ui.html">← Назад</a>
<a href="create-server-pda.html">Создать PDA</a>
</div>
<h1>Обновление PDA сервера</h1>
<p class="subtitle">Меняет адрес сервера или список серверов синхронизации</p>
<div class="card">
<h2>Параметры Solana</h2>
<div class="field">
<label>Solana Endpoint</label>
<input type="text" id="endpoint" value="https://api.devnet.solana.com" />
</div>
</div>
<div class="card">
<h2>Загрузить существующую PDA</h2>
<div class="field">
<label>Логин сервера</label>
<input type="text" id="login" placeholder="shineupme" maxlength="20" />
</div>
<div class="btn-row">
<button class="btn-secondary" id="btnLoad">Загрузить PDA</button>
</div>
<div class="pda-info" id="pdaInfo">
<hr class="section-divider" />
<div class="pda-row"><span class="pda-key">PDA адрес</span><span class="pda-value" id="iAddr"></span></div>
<div class="pda-row"><span class="pda-key">Версия</span><span class="pda-value" id="iVer"></span></div>
<div class="pda-row"><span class="pda-key">Создан</span><span class="pda-value" id="iCreated"></span></div>
<div class="pda-row"><span class="pda-key">Обновлён</span><span class="pda-value" id="iUpdated"></span></div>
<div class="pda-row"><span class="pda-key">Адрес сервера</span><span class="pda-value" id="iSrvAddr"></span></div>
<div class="pda-row"><span class="pda-key">sync_servers</span><span class="pda-value" id="iSync"></span></div>
<div class="pda-row"><span class="pda-key">Blockchain</span><span class="pda-value" id="iBch"></span></div>
<div class="pda-row"><span class="pda-key">Paid limit</span><span class="pda-value" id="iLimit"></span></div>
</div>
</div>
<div id="updateForm" style="display:none">
<div class="card">
<h2>Новые параметры сервера</h2>
<div class="field">
<label>Новый адрес сервера (URL)</label>
<input type="text" id="serverAddress" placeholder="https://shineup.me/ws" />
</div>
<div class="field">
<label>Новые серверы синхронизации (sync_servers)</label>
<textarea id="syncServers" placeholder="По одному логину на строку (можно оставить пустым)"></textarea>
</div>
</div>
<div class="card">
<h2>Ключи для подписи и оплаты</h2>
<p class="muted">Root-ключ подписывает новую PDA-запись. Device-ключ оплачивает транзакцию.<br/>Blockchain-ключ не нужен — подпись LastBlockState из PDA переиспользуется автоматически.</p>
<div class="field">
<label>Пароль</label>
<div class="pwd-wrap">
<input type="password" id="password" placeholder="Пароль аккаунта сервера" autocomplete="current-password" />
<button class="btn-eye" id="btnEye" type="button">Показать</button>
</div>
<div class="hint">Нажмите «Сгенерировать» — поля ниже заполнятся из логина + пароля.<br/>Или введите ключи вручную.</div>
</div>
<div class="btn-row">
<button class="btn-secondary" id="btnGen" type="button">Сгенерировать ключи</button>
</div>
<div class="gen-msg" id="genMsg"></div>
<div class="sec-lbl">Секрет (master secret, base58)</div>
<div class="field" style="margin-bottom:0">
<input type="text" id="masterSecret" placeholder="32-байтовый master secret в base58 (~44 символа)" />
</div>
<div class="sec-lbl">Ключевые пары (base58)</div>
<div class="kp-block">
<div class="kp-title">Root Key — подпись PDA-записи</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="rootPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="rootPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block">
<div class="kp-title">Blockchain Key — справочно, при обновлении не используется</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="bchPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="bchPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block">
<div class="kp-title">Device Key — оплата транзакции Solana</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="devPub" placeholder="base58, ~44 символа (= Solana-адрес)" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="devPriv" placeholder="seed base58, ~44 символа" /></div>
<div class="sol-box" id="solBox">
<div class="sol-ttl">Положите SOL на этот адрес перед обновлением:</div>
<div class="sol-adr" id="solAdr"></div>
<div class="sol-ht">Это Solana-адрес (base58) device-ключа. С него оплачивается транзакция.</div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn-primary" id="btnUpdate">Обновить PDA</button>
</div>
</div>
<div class="status" id="status"></div>
</div>
<script type="module" src="./js/update-server-pda-page.js"></script>
</body>
</html>