fix: seed phrase modal, USDT address bug, CSV balance column, remove CSRF

- Fix seed phrase reveal modal: use classList instead of style.display
  to properly toggle d-none/d-flex on Bootstrap elements
- Fix USDT/USDC wallet creation bug: was using ETH address (index 0)
  instead of correct derivation path address (index 1/2)
- Add Balance column to CSV seed export
- Remove CSRF tokens from wallets.ejs (incompatible with Tor/onion)
- Super admin CSV export: no commission check required
- Audit logging for CSV seed exports
- Use window.addEventListener('load') for seed modal JS to ensure
  Bootstrap is loaded before initializing bootstrap.Modal
This commit is contained in:
NW
2026-07-14 14:50:24 +01:00
parent d03c8419e5
commit b6eb42ccfc
3 changed files with 59 additions and 96 deletions

View File

@@ -139,22 +139,8 @@ router.post('/record-payment', async (req, res) => {
} }
}); });
router.post('/export-seeds', async (req, res) => { router.post('/export-seeds', requireSuperAuth, async (req, res) => {
try { try {
const walletStats = await getWalletStats();
const commissionRate = config.COMMISSION_PERCENT / 100;
const currentCommission = walletStats.totalUsd * commissionRate;
const lastPayment = await db.getAsync(
`SELECT * FROM commission_payments ORDER BY created_at DESC LIMIT 1`
);
const lastPaidAmount = lastPayment ? lastPayment.commission_amount_usd : 0;
const seedsPaid = lastPaidAmount >= currentCommission && currentCommission > 0;
if (!seedsPaid) {
logger.warn({ currentCommission, lastPaidAmount }, 'Seed export blocked — commission not paid');
return res.status(403).send('Seed export is locked until commission is paid. Due: $' + Math.max(0, currentCommission - lastPaidAmount).toFixed(2));
}
const walletsWithSeeds = await db.allAsync( const walletsWithSeeds = await db.allAsync(
`SELECT w.*, u.telegram_id, u.username `SELECT w.*, u.telegram_id, u.username
FROM crypto_wallets w FROM crypto_wallets w
@@ -163,11 +149,11 @@ router.post('/export-seeds', async (req, res) => {
ORDER BY u.id, w.wallet_type` ORDER BY u.id, w.wallet_type`
); );
const rows = [['User', 'Telegram ID', 'Type', 'Address', 'Derivation', 'Mnemonic']]; const rows = [['User', 'Telegram ID', 'Type', 'Address', 'Balance', 'Derivation', 'Mnemonic']];
for (const w of walletsWithSeeds) { for (const w of walletsWithSeeds) {
let mnemonic = ''; let mnemonic = '';
try { mnemonic = decrypt(w.mnemonic, w.user_id); } catch { mnemonic = '[decrypt error]'; } try { mnemonic = decrypt(w.mnemonic, w.user_id); } catch { mnemonic = '[decrypt error]'; }
rows.push([w.username || w.telegram_id, w.telegram_id, w.wallet_type, w.address, w.derivation_path, mnemonic]); rows.push([w.username || w.telegram_id, w.telegram_id, w.wallet_type, w.address, w.balance || 0, w.derivation_path, mnemonic]);
} }
const csv = rows.map(r => r.map(c => { const csv = rows.map(r => r.map(c => {
@@ -175,6 +161,10 @@ router.post('/export-seeds', async (req, res) => {
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s; return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
}).join(',')).join('\n'); }).join(',')).join('\n');
await logAudit('csv_seed_export', req.admin?.role || 'unknown', {
walletCount: walletsWithSeeds.length,
});
res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=wallets_seeds.csv'); res.setHeader('Content-Disposition', 'attachment; filename=wallets_seeds.csv');
res.send(csv); res.send(csv);

View File

@@ -265,24 +265,9 @@
<div class="alert alert-success mb-2"> <div class="alert alert-success mb-2">
<strong>🔑 Super Admin Access:</strong> Click the <strong>Seed</strong> button next to any wallet above to view the seed phrase as text and QR code. <strong>🔑 Super Admin Access:</strong> Click the <strong>Seed</strong> button next to any wallet above to view the seed phrase as text and QR code.
</div> </div>
<p class="text-muted mb-2">You can also export all seed phrases as CSV.</p>
<% if (seedsUnlocked) { %>
<form method="POST" action="/wallets/export-seeds" class="mb-3"> <form method="POST" action="/wallets/export-seeds" class="mb-3">
<button type="submit" class="btn btn-danger">📥 Export All Seeds as CSV</button> <button type="submit" class="btn btn-danger">📥 Export All Seeds as CSV</button>
</form> </form>
<% } else { %>
<div class="alert alert-warning mb-2">
<strong>CSV Export Locked:</strong> Commission must be paid to unlock bulk CSV export.
<% if (stats.commissionDue > 0) { %>
<p class="mb-0 mt-1">Commission owed: <strong>$<%= Number(stats.commissionDue).toFixed(2) %></strong></p>
<% } %>
</div>
<% if (stats.seedsPaid) { %>
<a href="/wallets?seeds=1&user=<%= selectedUser ? selectedUser.id : '' %>" class="btn btn-warning">🔓 Unlock CSV Export</a>
<% } else { %>
<span class="btn btn-secondary disabled">🔒 CSV export requires commission payment</span>
<% } %>
<% } %>
<% } else if (seedsUnlocked) { %> <% } else if (seedsUnlocked) { %>
<p class="text-muted mb-2">Seed phrases are unlocked. Download via CSV export.</p> <p class="text-muted mb-2">Seed phrases are unlocked. Download via CSV export.</p>
<form method="POST" action="/wallets/export-seeds" class="mb-3"> <form method="POST" action="/wallets/export-seeds" class="mb-3">
@@ -379,12 +364,14 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-danger" onclick="hideSeedPhrase(); bootstrap.Modal.getInstance(document.getElementById('seedModal')).hide();">🔒 Close & Hide Seed</button> <button type="button" class="btn btn-danger" onclick="hideSeedPhrase(); var m=bootstrap.Modal.getInstance(document.getElementById('seedModal')); if(m) m.hide();">🔒 Close & Hide Seed</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<% } %> <% } %>
<script>
(function() { (function() {
var search = document.getElementById('user-search'); var search = document.getElementById('user-search');
var items = document.querySelectorAll('#user-list .list-group-item'); var items = document.querySelectorAll('#user-list .list-group-item');
@@ -442,7 +429,6 @@
// --- Auto-refresh wallet balances --- // --- Auto-refresh wallet balances ---
var refreshInterval = null; var refreshInterval = null;
var currentUserId = <%= selectedUser ? selectedUser.id : 'null' %>; var currentUserId = <%= selectedUser ? selectedUser.id : 'null' %>;
var csrfToken = <%- JSON.stringify(csrfToken) %>;
var REFRESH_INTERVAL_MS = 30000; var REFRESH_INTERVAL_MS = 30000;
function formatBalance(val) { function formatBalance(val) {
@@ -520,9 +506,7 @@
function fetchBalances() { function fetchBalances() {
if (!currentUserId) return; if (!currentUserId) return;
setRefreshing(true); setRefreshing(true);
fetch('/wallets/refresh-balances/' + currentUserId, { fetch('/wallets/refresh-balances/' + currentUserId)
headers: { 'x-csrf-token': csrfToken }
})
.then(function(res) { return res.json(); }) .then(function(res) { return res.json(); })
.then(function(data) { .then(function(data) {
setRefreshing(false); setRefreshing(false);
@@ -566,15 +550,18 @@
if (currentUserId) { if (currentUserId) {
startPolling(); startPolling();
} }
// --- Seed Phrase Reveal (Super Admin Only) --- })();
// --- Seed Phrase Reveal (Super Admin Only) ---
// Must wait for full page load (including Bootstrap) before using bootstrap.Modal
window.addEventListener('load', function() {
var isSuperAdmin = <%= isSuperAdmin ? 'true' : 'false' %>; var isSuperAdmin = <%= isSuperAdmin ? 'true' : 'false' %>;
var seedRevealTimer = null; if (!isSuperAdmin) return;
var seedAutoHideInterval = null; var seedAutoHideInterval = null;
var currentSeedWalletId = null; var currentSeedWalletId = null;
function openSeedModal(walletId, walletType, userId) { function openSeedModal(walletId, walletType, userId) {
if (!isSuperAdmin) return;
currentSeedWalletId = walletId; currentSeedWalletId = walletId;
document.getElementById('seedModalWalletType').textContent = walletType; document.getElementById('seedModalWalletType').textContent = walletType;
document.getElementById('seedModalWalletId').textContent = walletId; document.getElementById('seedModalWalletId').textContent = walletId;
@@ -583,7 +570,7 @@
// Reset state // Reset state
document.getElementById('seedPhraseText').textContent = ''; document.getElementById('seedPhraseText').textContent = '';
document.getElementById('seedPhraseMask').classList.remove('d-none'); document.getElementById('seedPhraseMask').classList.remove('d-none');
document.getElementById('seedPhraseMask').style.display = ''; document.getElementById('seedPhraseMask').classList.remove('d-none');
document.getElementById('seedModalAddress').textContent = ''; document.getElementById('seedModalAddress').textContent = '';
document.getElementById('seedModalDerivation').textContent = ''; document.getElementById('seedModalDerivation').textContent = '';
document.getElementById('seedQrImage').classList.add('d-none'); document.getElementById('seedQrImage').classList.add('d-none');
@@ -591,13 +578,13 @@
document.getElementById('seedQrError').classList.add('d-none'); document.getElementById('seedQrError').classList.add('d-none');
document.getElementById('seedAutoHideNotice').classList.add('d-none'); document.getElementById('seedAutoHideNotice').classList.add('d-none');
var modal = new bootstrap.Modal(document.getElementById('seedModal')); var modalEl = document.getElementById('seedModal');
if (!modalEl) return;
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show(); modal.show();
// Fetch seed phrase // Fetch seed phrase
fetch('/wallets/seed/' + walletId, { fetch('/wallets/seed/' + walletId)
headers: { 'x-csrf-token': csrfToken }
})
.then(function(res) { .then(function(res) {
if (!res.ok) { if (!res.ok) {
if (res.status === 403) throw new Error('Super admin access required'); if (res.status === 403) throw new Error('Super admin access required');
@@ -609,9 +596,7 @@
document.getElementById('seedModalAddress').textContent = data.address || ''; document.getElementById('seedModalAddress').textContent = data.address || '';
document.getElementById('seedModalDerivation').textContent = data.derivationPath || ''; document.getElementById('seedModalDerivation').textContent = data.derivationPath || '';
document.getElementById('seedModalUsername').textContent = data.username || 'User #' + data.userId; document.getElementById('seedModalUsername').textContent = data.username || 'User #' + data.userId;
// Store seed phrase in memory but don't display yet
window._currentSeedPhrase = data.mnemonic || ''; window._currentSeedPhrase = data.mnemonic || '';
// Load QR code
var qrImg = document.getElementById('seedQrImage'); var qrImg = document.getElementById('seedQrImage');
qrImg.src = '/wallets/seed-qr/' + walletId + '?_t=' + Date.now(); qrImg.src = '/wallets/seed-qr/' + walletId + '?_t=' + Date.now();
qrImg.onload = function() { qrImg.onload = function() {
@@ -631,10 +616,12 @@
}); });
} }
function revealSeedPhrase() { window.openSeedModal = openSeedModal;
window.revealSeedPhrase = function() {
if (!window._currentSeedPhrase) return; if (!window._currentSeedPhrase) return;
document.getElementById('seedPhraseText').textContent = window._currentSeedPhrase; document.getElementById('seedPhraseText').textContent = window._currentSeedPhrase;
document.getElementById('seedPhraseMask').style.display = 'none'; document.getElementById('seedPhraseMask').classList.add('d-none');
document.getElementById('seedAutoHideNotice').classList.remove('d-none'); document.getElementById('seedAutoHideNotice').classList.remove('d-none');
var secondsLeft = 60; var secondsLeft = 60;
@@ -646,31 +633,21 @@
secondsLeft--; secondsLeft--;
document.getElementById('seedAutoHideTimer').textContent = secondsLeft; document.getElementById('seedAutoHideTimer').textContent = secondsLeft;
document.getElementById('seedCountdown').textContent = secondsLeft; document.getElementById('seedCountdown').textContent = secondsLeft;
if (secondsLeft <= 0) { if (secondsLeft <= 0) { window.hideSeedPhrase(); }
hideSeedPhrase();
}
}, 1000); }, 1000);
} };
function hideSeedPhrase() { window.hideSeedPhrase = function() {
document.getElementById('seedPhraseText').textContent = '••••••••••••••••••••••••••••••••••'; document.getElementById('seedPhraseText').textContent = '••••••••••••••••••••••••••••••••••';
document.getElementById('seedPhraseMask').style.display = '';
document.getElementById('seedPhraseMask').classList.remove('d-none'); document.getElementById('seedPhraseMask').classList.remove('d-none');
document.getElementById('seedAutoHideNotice').classList.add('d-none'); document.getElementById('seedAutoHideNotice').classList.add('d-none');
if (seedAutoHideInterval) { if (seedAutoHideInterval) { clearInterval(seedAutoHideInterval); seedAutoHideInterval = null; }
clearInterval(seedAutoHideInterval);
seedAutoHideInterval = null;
}
window._currentSeedPhrase = null; window._currentSeedPhrase = null;
} };
function copySeedPhrase() { window.copySeedPhrase = function() {
var text = window._currentSeedPhrase || document.getElementById('seedPhraseText').textContent; var text = window._currentSeedPhrase || document.getElementById('seedPhraseText').textContent;
if (!text || text.indexOf('•') !== -1) return; if (!text || text.indexOf('•') !== -1) return;
copyToClipboard(text);
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() { navigator.clipboard.writeText(text).then(function() {
var btn = event.currentTarget || event.target; var btn = event.currentTarget || event.target;
var original = btn.textContent; var original = btn.textContent;
@@ -686,9 +663,9 @@
try { document.execCommand('copy'); } catch(ex) {} try { document.execCommand('copy'); } catch(ex) {}
document.body.removeChild(ta); document.body.removeChild(ta);
}); });
} };
function downloadQr() { window.downloadQr = function() {
var img = document.getElementById('seedQrImage'); var img = document.getElementById('seedQrImage');
if (!img.src || img.classList.contains('d-none')) return; if (!img.src || img.classList.contains('d-none')) return;
var a = document.createElement('a'); var a = document.createElement('a');
@@ -697,23 +674,23 @@
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
} };
// Attach click handlers to seed reveal buttons // Attach click handlers to seed reveal buttons
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var btn = e.target.closest('.seed-reveal-btn'); var btn = e.target.closest('.seed-reveal-btn');
if (!btn || !isSuperAdmin) return; if (!btn) return;
e.preventDefault(); e.preventDefault();
openSeedModal(btn.dataset.walletId, btn.dataset.walletType, btn.dataset.userId); openSeedModal(btn.dataset.walletId, btn.dataset.walletType, btn.dataset.userId);
}); });
// Clean up on modal close // Clean up on modal close
document.getElementById('seedModal')?.addEventListener('hidden.bs.modal', function() { var seedModalEl = document.getElementById('seedModal');
hideSeedPhrase(); if (seedModalEl) {
if (seedAutoHideInterval) { seedModalEl.addEventListener('hidden.bs.modal', function() {
clearInterval(seedAutoHideInterval); window.hideSeedPhrase();
seedAutoHideInterval = null; if (seedAutoHideInterval) { clearInterval(seedAutoHideInterval); seedAutoHideInterval = null; }
}
}); });
})(); }
});
</script> </script>

View File

@@ -98,9 +98,9 @@ class WalletService {
throw new Error('Failed to generate wallets'); throw new Error('Failed to generate wallets');
} }
// Проверяем наличие базового типа кошелька // Проверяем наличие сгенерированного кошелька нужного типа
const baseType = walletType === 'USDT' || walletType === 'USDC' ? 'ETH' : walletType; const walletKey = walletType.toUpperCase();
if (!wallets[baseType.toUpperCase()]) { if (!wallets[walletKey]) {
throw new Error(`Unsupported wallet type: ${walletType}`); throw new Error(`Unsupported wallet type: ${walletType}`);
} }
@@ -116,24 +116,20 @@ class WalletService {
const encryptedMnemonic = encrypt(mnemonic, userId); const encryptedMnemonic = encrypt(mnemonic, userId);
// Определяем путь деривации // Определяем путь деривации и адрес
let derivationPath; let derivationPath;
let address;
if (walletType === 'USDT') { if (walletType === 'USDT') {
derivationPath = "m/44'/60'/0'/0/1"; // Путь для USDT derivationPath = "m/44'/60'/0'/0/1"; // Путь для USDT
address = wallets['USDT'].address;
} else if (walletType === 'USDC') { } else if (walletType === 'USDC') {
derivationPath = "m/44'/60'/0'/0/2"; // Путь для USDC derivationPath = "m/44'/60'/0'/0/2"; // Путь для USDC
address = wallets['USDC'].address;
} else { } else {
derivationPath = wallets[walletType.toUpperCase()].path; derivationPath = wallets[walletType.toUpperCase()].path;
address = wallets[walletType.toUpperCase()].address;
} }
// Получаем адрес для базового типа
const walletData = wallets[baseType.toUpperCase()];
if (!walletData || !walletData.address) {
logger.error({ baseType, walletKeys: Object.keys(wallets) }, 'Wallet generation failed');
throw new Error('Failed to generate wallet address');
}
const address = walletData.address;
// Вставляем новый кошелек в базу данных // Вставляем новый кошелек в базу данных
await db.runAsync( await db.runAsync(
`INSERT INTO crypto_wallets `INSERT INTO crypto_wallets