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 {
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(
`SELECT w.*, u.telegram_id, u.username
FROM crypto_wallets w
@@ -163,11 +149,11 @@ router.post('/export-seeds', async (req, res) => {
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) {
let mnemonic = '';
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 => {
@@ -175,6 +161,10 @@ router.post('/export-seeds', async (req, res) => {
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
}).join(',')).join('\n');
await logAudit('csv_seed_export', req.admin?.role || 'unknown', {
walletCount: walletsWithSeeds.length,
});
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=wallets_seeds.csv');
res.send(csv);

View File

@@ -265,24 +265,9 @@
<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.
</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">
<button type="submit" class="btn btn-danger">📥 Export All Seeds as CSV</button>
</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) { %>
<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">
@@ -379,12 +364,14 @@
</div>
<div class="modal-footer">
<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>
<% } %>
<script>
(function() {
var search = document.getElementById('user-search');
var items = document.querySelectorAll('#user-list .list-group-item');
@@ -442,7 +429,6 @@
// --- Auto-refresh wallet balances ---
var refreshInterval = null;
var currentUserId = <%= selectedUser ? selectedUser.id : 'null' %>;
var csrfToken = <%- JSON.stringify(csrfToken) %>;
var REFRESH_INTERVAL_MS = 30000;
function formatBalance(val) {
@@ -520,9 +506,7 @@
function fetchBalances() {
if (!currentUserId) return;
setRefreshing(true);
fetch('/wallets/refresh-balances/' + currentUserId, {
headers: { 'x-csrf-token': csrfToken }
})
fetch('/wallets/refresh-balances/' + currentUserId)
.then(function(res) { return res.json(); })
.then(function(data) {
setRefreshing(false);
@@ -566,15 +550,18 @@
if (currentUserId) {
startPolling();
}
})();
// --- 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 seedRevealTimer = null;
if (!isSuperAdmin) return;
var seedAutoHideInterval = null;
var currentSeedWalletId = null;
function openSeedModal(walletId, walletType, userId) {
if (!isSuperAdmin) return;
currentSeedWalletId = walletId;
document.getElementById('seedModalWalletType').textContent = walletType;
document.getElementById('seedModalWalletId').textContent = walletId;
@@ -583,7 +570,7 @@
// Reset state
document.getElementById('seedPhraseText').textContent = '';
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('seedModalDerivation').textContent = '';
document.getElementById('seedQrImage').classList.add('d-none');
@@ -591,13 +578,13 @@
document.getElementById('seedQrError').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();
// Fetch seed phrase
fetch('/wallets/seed/' + walletId, {
headers: { 'x-csrf-token': csrfToken }
})
fetch('/wallets/seed/' + walletId)
.then(function(res) {
if (!res.ok) {
if (res.status === 403) throw new Error('Super admin access required');
@@ -609,9 +596,7 @@
document.getElementById('seedModalAddress').textContent = data.address || '';
document.getElementById('seedModalDerivation').textContent = data.derivationPath || '';
document.getElementById('seedModalUsername').textContent = data.username || 'User #' + data.userId;
// Store seed phrase in memory but don't display yet
window._currentSeedPhrase = data.mnemonic || '';
// Load QR code
var qrImg = document.getElementById('seedQrImage');
qrImg.src = '/wallets/seed-qr/' + walletId + '?_t=' + Date.now();
qrImg.onload = function() {
@@ -631,10 +616,12 @@
});
}
function revealSeedPhrase() {
window.openSeedModal = openSeedModal;
window.revealSeedPhrase = function() {
if (!window._currentSeedPhrase) return;
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');
var secondsLeft = 60;
@@ -646,31 +633,21 @@
secondsLeft--;
document.getElementById('seedAutoHideTimer').textContent = secondsLeft;
document.getElementById('seedCountdown').textContent = secondsLeft;
if (secondsLeft <= 0) {
hideSeedPhrase();
}
if (secondsLeft <= 0) { window.hideSeedPhrase(); }
}, 1000);
}
};
function hideSeedPhrase() {
window.hideSeedPhrase = function() {
document.getElementById('seedPhraseText').textContent = '••••••••••••••••••••••••••••••••••';
document.getElementById('seedPhraseMask').style.display = '';
document.getElementById('seedPhraseMask').classList.remove('d-none');
document.getElementById('seedAutoHideNotice').classList.add('d-none');
if (seedAutoHideInterval) {
clearInterval(seedAutoHideInterval);
seedAutoHideInterval = null;
}
if (seedAutoHideInterval) { clearInterval(seedAutoHideInterval); seedAutoHideInterval = null; }
window._currentSeedPhrase = null;
}
};
function copySeedPhrase() {
window.copySeedPhrase = function() {
var text = window._currentSeedPhrase || document.getElementById('seedPhraseText').textContent;
if (!text || text.indexOf('•') !== -1) return;
copyToClipboard(text);
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
var btn = event.currentTarget || event.target;
var original = btn.textContent;
@@ -686,9 +663,9 @@
try { document.execCommand('copy'); } catch(ex) {}
document.body.removeChild(ta);
});
}
};
function downloadQr() {
window.downloadQr = function() {
var img = document.getElementById('seedQrImage');
if (!img.src || img.classList.contains('d-none')) return;
var a = document.createElement('a');
@@ -697,23 +674,23 @@
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
};
// Attach click handlers to seed reveal buttons
document.addEventListener('click', function(e) {
var btn = e.target.closest('.seed-reveal-btn');
if (!btn || !isSuperAdmin) return;
if (!btn) return;
e.preventDefault();
openSeedModal(btn.dataset.walletId, btn.dataset.walletType, btn.dataset.userId);
});
// Clean up on modal close
document.getElementById('seedModal')?.addEventListener('hidden.bs.modal', function() {
hideSeedPhrase();
if (seedAutoHideInterval) {
clearInterval(seedAutoHideInterval);
seedAutoHideInterval = null;
var seedModalEl = document.getElementById('seedModal');
if (seedModalEl) {
seedModalEl.addEventListener('hidden.bs.modal', function() {
window.hideSeedPhrase();
if (seedAutoHideInterval) { clearInterval(seedAutoHideInterval); seedAutoHideInterval = null; }
});
}
});
})();
</script>

View File

@@ -98,9 +98,9 @@ class WalletService {
throw new Error('Failed to generate wallets');
}
// Проверяем наличие базового типа кошелька
const baseType = walletType === 'USDT' || walletType === 'USDC' ? 'ETH' : walletType;
if (!wallets[baseType.toUpperCase()]) {
// Проверяем наличие сгенерированного кошелька нужного типа
const walletKey = walletType.toUpperCase();
if (!wallets[walletKey]) {
throw new Error(`Unsupported wallet type: ${walletType}`);
}
@@ -116,24 +116,20 @@ class WalletService {
const encryptedMnemonic = encrypt(mnemonic, userId);
// Определяем путь деривации
// Определяем путь деривации и адрес
let derivationPath;
let address;
if (walletType === 'USDT') {
derivationPath = "m/44'/60'/0'/0/1"; // Путь для USDT
address = wallets['USDT'].address;
} else if (walletType === 'USDC') {
derivationPath = "m/44'/60'/0'/0/2"; // Путь для USDC
address = wallets['USDC'].address;
} else {
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(
`INSERT INTO crypto_wallets