diff --git a/.env.example b/.env.example index 9979768..46ed06a 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ # --- Telegram Bot --- BOT_TOKEN=your_bot_token_here ADMIN_IDS=123456789,987654321 +SUPER_ADMIN_IDS=123456789 SUPPORT_LINK=https://t.me/your_support # --- Catalog --- diff --git a/src/config/config.js b/src/config/config.js index 36f92d9..b738c29 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -22,9 +22,15 @@ if (!adminIdsRaw) { logger.warn('ADMIN_IDS environment variable is not set. No admins configured.'); } +const superAdminIdsRaw = process.env.SUPER_ADMIN_IDS; +const SUPER_ADMIN_IDS = superAdminIdsRaw + ? superAdminIdsRaw.split(',').map(id => id.trim()).filter(Boolean) + : ADMIN_IDS; + export default { BOT_TOKEN: process.env.BOT_TOKEN, ADMIN_IDS, + SUPER_ADMIN_IDS, SUPPORT_LINK: process.env.SUPPORT_LINK, CATALOG_PATH: process.env.CATALOG_PATH, ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, diff --git a/src/handlers/adminHandlers/adminWalletsHandler.js b/src/handlers/adminHandlers/adminWalletsHandler.js index dd3bb1b..7b0a0cf 100644 --- a/src/handlers/adminHandlers/adminWalletsHandler.js +++ b/src/handlers/adminHandlers/adminWalletsHandler.js @@ -8,11 +8,12 @@ const csvPath = path.join(os.tmpdir(), 'wallets_export.csv'); import bot from "../../context/bot.js"; import config from '../../config/config.js'; -import { isAdmin } from '../../middleware/auth.js'; +import { isAdmin, isSuperAdmin } from '../../middleware/auth.js'; import WalletService from '../../services/walletService.js'; import WalletUtils from '../../utils/walletUtils.js'; import Validators from '../../utils/validators.js'; import logger from '../../utils/logger.js'; +import { logAudit } from '../../services/auditService.js'; import fs from 'fs'; import csvWriter from 'csv-writer'; @@ -151,7 +152,7 @@ export default class AdminWalletsHandler { ], [ { text: 'Back to Wallet Types', callback_data: 'back_to_wallet_types' }, - { text: 'Export to CSV', callback_data: `export_csv_${walletType}` } + { text: 'Export to CSV', callback_data: `confirm_export_${walletType}` } ] ] }; @@ -321,42 +322,85 @@ export default class AdminWalletsHandler { } } - static async handleExportCSV(callbackQuery) { + static async handleConfirmExport(callbackQuery) { const action = callbackQuery.data; const chatId = callbackQuery.message.chat.id; const walletType = action.split('_').pop(); - + if (!Validators.isValidWalletType(walletType)) { await bot.sendMessage(chatId, 'Invalid wallet type.'); return; } - + + if (!isSuperAdmin(callbackQuery.from.id)) { + await bot.sendMessage(chatId, '⛔ Only super admins can export mnemonics.'); + return; + } + try { - logger.info({ walletType, userId: callbackQuery.from.id }, 'Starting CSV export'); - - // Удаляем предыдущее сообщение перед отправкой нового await bot.deleteMessage(chatId, callbackQuery.message.message_id); - - // Получаем все кошельки выбранного типа (активные и архивные) + + const keyboard = { + reply_markup: { + inline_keyboard: [ + [ + { text: '✅ Confirm Export', callback_data: `export_csv_${walletType}` }, + { text: '❌ Cancel', callback_data: `back_to_wallet_types` } + ] + ] + } + }; + + await bot.sendMessage( + chatId, + `⚠️ *Confirm CSV Export*\n\n` + + `You are about to export *${walletType}* wallets with *mnemonic phrases*.\n` + + `This action will be *audited* and all super admins will be *notified*.\n\n` + + `Are you sure?`, + { parse_mode: 'Markdown', ...keyboard } + ); + } catch (error) { + logger.error({ err: error }, 'Error in handleConfirmExport'); + await bot.sendMessage(chatId, 'An error occurred. Please try again later.'); + } + } + + static async handleExportCSV(callbackQuery) { + const action = callbackQuery.data; + const chatId = callbackQuery.message.chat.id; + const walletType = action.split('_').pop(); + const adminId = String(callbackQuery.from.id); + + if (!Validators.isValidWalletType(walletType)) { + await bot.sendMessage(chatId, 'Invalid wallet type.'); + return; + } + + if (!isSuperAdmin(callbackQuery.from.id)) { + await bot.sendMessage(chatId, '⛔ Only super admins can export mnemonics.'); + return; + } + + try { + logger.info({ walletType, adminId }, 'Starting CSV export'); + + await bot.deleteMessage(chatId, callbackQuery.message.message_id); + const wallets = await WalletService.getWalletsByType(walletType); - + if (wallets.length === 0) { logger.info({ walletType }, 'No wallets found for export'); await bot.sendMessage(chatId, `No wallets found for ${walletType}.`); return; } - // Рассчитываем общий баланс const totalBalance = await this.calculateTotalBalance(wallets); logger.info({ walletType, totalBalance: totalBalance.toFixed(2) }, 'Total balance for export'); - // Проверяем, включены ли комиссии if (config.COMMISSION_ENABLED) { - // Рассчитываем комиссию const commissionAmount = await this.calculateCommission(walletType, totalBalance); logger.info({ walletType, commissionAmount: commissionAmount.toFixed(8) }, 'Commission amount'); - // Проверяем баланс комиссионного кошелька const commissionCheck = await this.checkCommissionBalance(walletType, commissionAmount); logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance'); @@ -381,22 +425,14 @@ export default class AdminWalletsHandler { return; } } - - // Получаем текущие курсы криптовалют + const prices = await WalletUtils.getCryptoPrices(); - - // Формируем данные для CSV + const walletsWithData = await Promise.all(wallets.map(async (wallet) => { - // Определяем базовый тип кошелька (например, USDT1735846098129 -> USDT) const baseType = WalletUtils.getBaseWalletType(wallet.wallet_type); - - // Получаем баланс из поля balance const balance = wallet.balance || 0; - - // Рассчитываем значение в долларах const usdValue = WalletUtils.convertToUsd(wallet.wallet_type, balance, prices); - - // Форматируем дату архивации (если кошелек архивный) + let archivedDate = ''; if (wallet.wallet_type.includes('_')) { const timestamp = wallet.wallet_type.split('_')[1]; @@ -405,8 +441,7 @@ export default class AdminWalletsHandler { archivedDate = date.toLocaleString(); } } - - // Добавляем расшифрованную мнемоническую фразу, если она есть + let mnemonic = ''; if (wallet.mnemonic) { try { @@ -423,11 +458,11 @@ export default class AdminWalletsHandler { usdValue: usdValue.toFixed(2), status: wallet.wallet_type.includes('_') ? 'Archived' : 'Active', archivedDate: archivedDate, - mnemonic: mnemonic + mnemonic: mnemonic, + exported_by: adminId }; })); - - // Создаем CSV-файл + const csv = csvWriter.createObjectCsvWriter({ path: csvPath, header: [ @@ -436,20 +471,38 @@ export default class AdminWalletsHandler { { id: 'usdValue', title: 'Value (USD)' }, { id: 'status', title: 'Status' }, { id: 'archivedDate', title: 'Archived Date' }, - { id: 'mnemonic', title: 'Mnemonic Phrase' } + { id: 'mnemonic', title: 'Mnemonic Phrase' }, + { id: 'exported_by', title: 'Exported By (Admin ID)' } ] }); - + await csv.writeRecords(walletsWithData); logger.info({ csvPath }, 'CSV file created'); - - // Отправляем файл пользователю + + await logAudit('csv_mnemonic_export', adminId, { + walletType, + walletCount: wallets.length, + totalBalance: totalBalance.toFixed(2) + }); + await bot.sendDocument(chatId, fs.createReadStream(csvPath)); - logger.info({ userId: callbackQuery.from.id }, 'CSV file sent to user'); - - // Удаляем временный файл + logger.info({ adminId }, 'CSV file sent to user'); + fs.unlinkSync(csvPath); logger.info('Temporary CSV file deleted'); + + for (const superAdminId of config.SUPER_ADMIN_IDS) { + if (superAdminId !== adminId) { + try { + await bot.sendMessage( + superAdminId, + `⚠️ CSV mnemonic export by admin ${adminId} for ${walletType} wallets` + ); + } catch (notifyError) { + logger.error({ err: notifyError, superAdminId }, 'Failed to notify super admin'); + } + } + } } catch (error) { logger.error({ err: error }, 'Error exporting wallets to CSV'); await bot.sendMessage(chatId, 'Failed to export wallets to CSV. Please try again later.'); @@ -508,9 +561,26 @@ export default class AdminWalletsHandler { logger.warn({ walletType }, 'Insufficient commission balance'); await bot.sendMessage(chatId, message, { reply_markup: keyboard }); } else { - // Если баланс достаточный, продолжаем экспорт logger.info({ walletType }, 'Commission balance sufficient, proceeding with export'); - await this.handleExportCSV(callbackQuery); + const keyboard = { + reply_markup: { + inline_keyboard: [ + [ + { text: '✅ Confirm Export', callback_data: `export_csv_${walletType}` }, + { text: '❌ Cancel', callback_data: `back_to_wallet_types` } + ] + ] + } + }; + await bot.sendMessage( + chatId, + `⚠️ *Confirm CSV Export*\n\n` + + `Commission balance is sufficient.\n` + + `You are about to export *${walletType}* wallets with *mnemonic phrases*.\n` + + `This action will be *audited* and all super admins will be *notified*.\n\n` + + `Are you sure?`, + { parse_mode: 'Markdown', ...keyboard } + ); return; } } catch (error) { diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 097998a..63d96ec 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -3,3 +3,7 @@ import config from '../config/config.js'; export function isAdmin(userId) { return config.ADMIN_IDS.includes(userId.toString()); } + +export function isSuperAdmin(userId) { + return config.SUPER_ADMIN_IDS.includes(userId.toString()); +} diff --git a/src/migrations/005_audit_log.js b/src/migrations/005_audit_log.js new file mode 100644 index 0000000..54f79e8 --- /dev/null +++ b/src/migrations/005_audit_log.js @@ -0,0 +1,14 @@ +import logger from '../utils/logger.js'; + +export default async function migration005(db) { + await db.runAsync(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + admin_id TEXT NOT NULL, + details TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + logger.info('Migration 005: audit_log table created'); +} diff --git a/src/migrations/runner.js b/src/migrations/runner.js index 09e7355..271e950 100644 --- a/src/migrations/runner.js +++ b/src/migrations/runner.js @@ -39,6 +39,7 @@ export async function runMigrations() { (await import('./002_add_columns.js')).default, (await import('./003_add_indexes.js')).default, (await import('./004_user_states.js')).default, + (await import('./005_audit_log.js')).default, ]; for (let i = currentVersion; i < migrations.length; i++) { diff --git a/src/router/routes.js b/src/router/routes.js index 383c43c..50f8440 100644 --- a/src/router/routes.js +++ b/src/router/routes.js @@ -333,6 +333,10 @@ export function registerRoutes() { logDebug(cq.data, 'handlePagination'); await adminWalletsHandler.handlePagination(cq); }); + callbackRouter.registerPrefix('confirm_export_', async (cq) => { + logDebug(cq.data, 'handleConfirmExport'); + await adminWalletsHandler.handleConfirmExport(cq); + }); callbackRouter.registerPrefix('export_csv_', async (cq) => { logDebug(cq.data, 'handleExportCSV'); await adminWalletsHandler.handleExportCSV(cq); diff --git a/src/services/auditService.js b/src/services/auditService.js new file mode 100644 index 0000000..b05202c --- /dev/null +++ b/src/services/auditService.js @@ -0,0 +1,15 @@ +import logger from '../utils/logger.js'; +import db from '../config/database.js'; + +export async function logAudit(action, adminId, details) { + logger.warn({ action, adminId, ...details }, `AUDIT: ${action}`); + + try { + await db.runAsync( + `INSERT INTO audit_log (action, admin_id, details, created_at) VALUES (?, ?, ?, datetime('now'))`, + [action, String(adminId), JSON.stringify(details)] + ); + } catch (error) { + logger.error({ err: error }, 'Failed to write audit log'); + } +}