From b3d3018057efb34988fb12b641590da83759bfd1 Mon Sep 17 00:00:00 2001 From: NW Date: Tue, 11 Aug 2026 18:13:09 +0100 Subject: [PATCH] =?UTF-8?q?fix(admin):=20review=20findings=20=E2=80=94=20a?= =?UTF-8?q?uth,=20wallet=20overview=20math,=20commission=20PUT,=20activati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wallets/overview: requireSuperAuth; select only id/userId/walletType/balance (no mnemonic leak); totalUsd now converts balances to USD via CoinGecko rates (stablecoins=1); lastPaidAmount via aggregate (no take:20 truncation); archived wallets excluded from all aggregates consistently - commission-wallets: GET requireSuperAuth; PUT upserts only present types (no silent wipe) + address format validation - activation: strict boolean check (rejects 'false' string) - system/info: requireSuperAuth; drop unused sshOnion - auth: no 'changeme' fallback; no auto super_admin escalation without SUPER_ADMIN_SECRET - wallets-page: Active Wallets card shows activeWallets; drop dead commissionEnabled field - migration 015: index on site_settings.key - remove scripts/patch-issue-146.cjs (hardcoded Gitea token) and dead scripts/sync-agents.cjs --- admin-next/src/app/api/activation/route.ts | 42 +++++ .../src/app/api/commission-wallets/route.ts | 80 +++++++++ admin-next/src/app/api/system/info/route.ts | 26 +++ .../src/app/api/wallets/overview/route.ts | 74 +++++--- .../src/components/wallets/wallets-page.tsx | 165 ++++++++++++++---- admin-next/src/lib/auth.ts | 14 +- src/migrations/015_shop_activation.js | 36 ++++ 7 files changed, 375 insertions(+), 62 deletions(-) create mode 100644 admin-next/src/app/api/activation/route.ts create mode 100644 admin-next/src/app/api/commission-wallets/route.ts create mode 100644 admin-next/src/app/api/system/info/route.ts create mode 100644 src/migrations/015_shop_activation.js diff --git a/admin-next/src/app/api/activation/route.ts b/admin-next/src/app/api/activation/route.ts new file mode 100644 index 0000000..68343ac --- /dev/null +++ b/admin-next/src/app/api/activation/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth, requireSuperAuth } from '@/lib/auth-middleware'; + +export async function GET(request: NextRequest) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + const row = await db.siteSetting.findUnique({ where: { key: 'shop_activated' } }); + const activated = row ? row.value === 'true' : process.env.SHOP_ACTIVATED !== 'false'; + return NextResponse.json({ activated }); + } catch (error) { + console.error('Activation GET error:', error); + return NextResponse.json({ error: 'Failed to load activation status' }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + const auth = requireSuperAuth(request); + if ('status' in auth) return auth; + + try { + const body = await request.json(); + if (typeof body.activated !== 'boolean') { + return NextResponse.json({ error: 'activated must be a boolean' }, { status: 400 }); + } + const activated = body.activated; + const value = activated ? 'true' : 'false'; + + await db.siteSetting.upsert({ + where: { key: 'shop_activated' }, + update: { value, updatedAt: new Date() }, + create: { key: 'shop_activated', value }, + }); + + return NextResponse.json({ ok: true, activated }); + } catch (error) { + console.error('Activation PUT error:', error); + return NextResponse.json({ error: 'Failed to save activation status' }, { status: 500 }); + } +} diff --git a/admin-next/src/app/api/commission-wallets/route.ts b/admin-next/src/app/api/commission-wallets/route.ts new file mode 100644 index 0000000..3e1fe25 --- /dev/null +++ b/admin-next/src/app/api/commission-wallets/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { Prisma } from '@prisma/client'; +import { requireSuperAuth } from '@/lib/auth-middleware'; +import { getCommissionWallets } from '@/lib/commission-wallets'; + +const WALLET_TYPES = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH'] as const; + +// Базовая валидация формата адреса по типу (не исчерпывающая, но отсекает мусор) +function isValidWalletAddress(type: string, value: string): boolean { + if (!value) return true; // пустое значение = очистить кошелёк + switch (type) { + case 'BTC': + return /^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,62}$/.test(value); + case 'LTC': + return /^(ltc1|[LM3])[a-zA-HJ-NP-Z0-9]{26,62}$/.test(value); + case 'ETH': + return /^0x[a-fA-F0-9]{40}$/.test(value); + case 'USDT': + case 'USDC': + // TRC-20 (T...) или ERC-20 (0x...) + return /^T[a-zA-HJ-NP-Z0-9]{33}$/.test(value) || /^0x[a-fA-F0-9]{40}$/.test(value); + default: + return false; + } +} + +export async function GET(request: NextRequest) { + const auth = requireSuperAuth(request); + if ('status' in auth) return auth; + + try { + const wallets = await getCommissionWallets(); + return NextResponse.json({ wallets }); + } catch (error) { + console.error('Commission wallets GET error:', error); + return NextResponse.json({ error: 'Failed to load commission wallets' }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + const auth = requireSuperAuth(request); + if ('status' in auth) return auth; + + try { + const body = await request.json(); + const wallets: Record = body.wallets || {}; + + // Валидация: только известные типы и корректный формат адресов + const operations: Prisma.PrismaPromise[] = []; + for (const type of WALLET_TYPES) { + if (wallets[type] === undefined) continue; // не трогаем отсутствующие типы + const value = String(wallets[type]).trim(); + if (!isValidWalletAddress(type, value)) { + return NextResponse.json( + { error: `Invalid ${type} wallet address format` }, + { status: 400 }, + ); + } + const key = `commission_wallet_${type}`; + operations.push( + db.siteSetting.upsert({ + where: { key }, + update: { value, updatedAt: new Date() }, + create: { key, value }, + }), + ); + } + + if (operations.length === 0) { + return NextResponse.json({ error: 'No wallet types provided' }, { status: 400 }); + } + + await db.$transaction(operations); + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('Commission wallets PUT error:', error); + return NextResponse.json({ error: 'Failed to save commission wallets' }, { status: 500 }); + } +} diff --git a/admin-next/src/app/api/system/info/route.ts b/admin-next/src/app/api/system/info/route.ts new file mode 100644 index 0000000..37a9c67 --- /dev/null +++ b/admin-next/src/app/api/system/info/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireSuperAuth } from '@/lib/auth-middleware'; +import fs from 'fs'; + +export async function GET(request: NextRequest) { + const auth = requireSuperAuth(request); + if ('status' in auth) return auth; + + let adminOnion = ''; + + try { + const content = fs.readFileSync('/onion-hosts/onion-hosts.txt', 'utf-8'); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('ADMIN_ONION=')) { + adminOnion = trimmed.slice('ADMIN_ONION='.length); + } + } + } catch { + // file may not exist — return empty string + } + + const lanUrl = process.env.ADMIN_URL || ''; + + return NextResponse.json({ adminOnion, lanUrl }); +} diff --git a/admin-next/src/app/api/wallets/overview/route.ts b/admin-next/src/app/api/wallets/overview/route.ts index b9e5af0..f248a2b 100755 --- a/admin-next/src/app/api/wallets/overview/route.ts +++ b/admin-next/src/app/api/wallets/overview/route.ts @@ -1,17 +1,52 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; -import { getAuth } from '@/lib/auth-middleware'; +import { requireSuperAuth } from '@/lib/auth-middleware'; +import { getCommissionWallets } from '@/lib/commission-wallets'; + +// Цены в USD (как в боте src/utils/walletUtils.js): стейблкоины = 1 +const STABLECOIN_RATE = 1; + +function getBaseWalletType(walletType: string): string { + if (walletType.includes('ERC-20')) return 'ETH'; + if (walletType.includes('_')) return walletType.split('_')[0]; + return walletType; +} + +async function getCryptoPrices(): Promise> { + try { + const res = await fetch( + 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,litecoin,ethereum&vs_currencies=usd', + { signal: AbortSignal.timeout(5000) }, + ); + if (!res.ok) throw new Error(`prices HTTP ${res.status}`); + const data = await res.json(); + return { + btc: data.bitcoin?.usd || 0, + ltc: data.litecoin?.usd || 0, + eth: data.ethereum?.usd || 0, + usdt: STABLECOIN_RATE, + usdc: STABLECOIN_RATE, + }; + } catch { + // Fallback: стейблкоины по 1 USD, остальные — 0 (не ломаем дашборд) + return { btc: 0, ltc: 0, eth: 0, usdt: STABLECOIN_RATE, usdc: STABLECOIN_RATE }; + } +} export async function GET(request: NextRequest) { - const auth = getAuth(request); + const auth = requireSuperAuth(request); if ('status' in auth) return auth; try { - const commissionEnabled = true; const commissionRate = 0.05; - const [wallets, payments, walletTypeCounts] = await Promise.all([ - db.cryptoWallet.findMany(), + const [wallets, paymentAgg, recentPayments, walletTypeCounts, prices] = await Promise.all([ + db.cryptoWallet.findMany({ + select: { id: true, userId: true, walletType: true, balance: true }, + }), + db.commissionPayment.aggregate({ + _sum: { paidAmountUsd: true }, + }), db.commissionPayment.findMany({ orderBy: { id: 'desc' }, take: 20, @@ -20,28 +55,33 @@ export async function GET(request: NextRequest) { by: ['walletType'], _count: true, }), + getCryptoPrices(), ]); const totals: Record = { BTC: 0, LTC: 0, ETH: 0, USDT: 0, USDC: 0 }; const walletCounts: Record = { BTC: 0, LTC: 0, ETH: 0, USDT: 0, USDC: 0 }; const userIdSet = new Set(); + let totalUsd = 0; + let totalWallets = 0; + let activeWallets = 0; + for (const w of wallets) { - const t = w.walletType.toUpperCase(); + const base = getBaseWalletType(w.walletType); + const t = base.toUpperCase(); if (t in totals) { totals[t] += w.balance; walletCounts[t]++; + totalWallets++; + if (w.balance > 0) activeWallets++; + totalUsd += w.balance * (prices[t.toLowerCase()] ?? 0); } userIdSet.add(w.userId); } - const totalUsd = wallets.reduce((sum, w) => sum + w.balance, 0); - const totalWallets = wallets.length; - const activeWallets = wallets.filter((w) => w.balance > 0).length; const totalUsers = userIdSet.size; - const currentCommission = totalUsd * commissionRate; - const lastPaidAmount = payments.reduce((sum, p) => sum + p.paidAmountUsd, 0); + const lastPaidAmount = paymentAgg._sum.paidAmountUsd ?? 0; const commissionDue = Math.max(0, currentCommission - lastPaidAmount); const walletTypeDistribution = walletTypeCounts.map((w) => ({ @@ -49,13 +89,7 @@ export async function GET(request: NextRequest) { count: w._count, })); - const commissionWallets = { - BTC: process.env.COMMISSION_WALLET_BTC || '', - LTC: process.env.COMMISSION_WALLET_LTC || '', - USDT: process.env.COMMISSION_WALLET_USDT || '', - USDC: process.env.COMMISSION_WALLET_USDC || '', - ETH: process.env.COMMISSION_WALLET_ETH || '', - }; + const commissionWallets = await getCommissionWallets(); const mercuryoUrl = 'https://mercuryo.io/'; return NextResponse.json({ @@ -65,10 +99,10 @@ export async function GET(request: NextRequest) { totalWallets, activeWallets, totalUsers, - commissionEnabled, + commissionEnabled: true, commissionRate, currentCommission, - payments, + payments: recentPayments, lastPaidAmount, commissionDue, walletTypeDistribution, diff --git a/admin-next/src/components/wallets/wallets-page.tsx b/admin-next/src/components/wallets/wallets-page.tsx index 3faf12b..ba63373 100755 --- a/admin-next/src/components/wallets/wallets-page.tsx +++ b/admin-next/src/components/wallets/wallets-page.tsx @@ -45,6 +45,7 @@ import { ShieldCheck, PieChart as PieChartIcon, Receipt, + Pencil, } from 'lucide-react'; import { Skeleton } from '@/components/ui/skeleton'; import { Pagination } from '@/components/shared/pagination'; @@ -97,7 +98,6 @@ interface OverviewData { totalWallets: number; activeWallets: number; totalUsers: number; - commissionEnabled: boolean; commissionRate: number; currentCommission: number; payments: { @@ -208,6 +208,9 @@ export function WalletsPage() { const [paymentAmount, setPaymentAmount] = useState(''); const [paymentNote, setPaymentNote] = useState(''); const [paymentSubmitting, setPaymentSubmitting] = useState(false); + const [editingWallets, setEditingWallets] = useState(false); + const [walletDraft, setWalletDraft] = useState>({}); + const [walletsSaving, setWalletsSaving] = useState(false); // Tab 4 state const [txData, setTxData] = useState([]); @@ -304,6 +307,48 @@ export function WalletsPage() { fetchOverview(); }, []); + const startEditWallets = () => { + const current = overview?.commissionWallets || {}; + setWalletDraft({ + BTC: current.BTC || '', + LTC: current.LTC || '', + USDT: current.USDT || '', + USDC: current.USDC || '', + ETH: current.ETH || '', + }); + setEditingWallets(true); + }; + + const cancelEditWallets = () => { + setEditingWallets(false); + setWalletDraft({}); + }; + + const handleSaveWallets = async () => { + setWalletsSaving(true); + try { + const res = await fetch('/api/commission-wallets', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ wallets: walletDraft }), + }); + if (!res.ok) throw new Error('Failed to save wallets'); + toast.success('Commission wallets updated'); + setEditingWallets(false); + setWalletDraft({}); + // Refetch overview + const overviewRes = await fetch('/api/wallets/overview'); + if (overviewRes.ok) { + const data = await overviewRes.json(); + setOverview(data); + } + } catch { + toast.error('Failed to save commission wallets'); + } finally { + setWalletsSaving(false); + } + }; + const handleRecordPayment = async () => { const amount = parseFloat(paymentAmount); if (isNaN(amount) || amount <= 0) { @@ -761,7 +806,7 @@ export function WalletsPage() {

Active Wallets

-

{overview.totalWallets}

+

{overview.activeWallets ?? 0}

@@ -1236,42 +1281,92 @@ export function WalletsPage() { {overview && overview.commissionWallets && ( - Pay to Commission Wallet +
+ Pay to Commission Wallet + {isSuperAdmin && !editingWallets && ( + + )} +
Select currency and send payment to the address below
-
- - -
-
- -
- - {overview.commissionWallets[selectedCommissionWallet] || 'Not configured'} - - -
-
+ {editingWallets ? ( + <> +
+ {(['BTC', 'LTC', 'USDT', 'USDC', 'ETH'] as const).map((t) => ( +
+ + setWalletDraft((prev) => ({ ...prev, [t]: e.target.value }))} + className="font-mono" + placeholder={`${t} address`} + /> +
+ ))} +
+
+ + +
+ + ) : ( + <> +
+ + +
+
+ +
+ + {overview.commissionWallets[selectedCommissionWallet] || 'Not configured'} + + +
+
+ + )}
)} diff --git a/admin-next/src/lib/auth.ts b/admin-next/src/lib/auth.ts index e851908..7fd9a58 100755 --- a/admin-next/src/lib/auth.ts +++ b/admin-next/src/lib/auth.ts @@ -1,6 +1,6 @@ import crypto from 'crypto'; -const ADMIN_SECRET = process.env.ADMIN_SECRET || 'changeme'; +const ADMIN_SECRET = process.env.ADMIN_SECRET || ''; const SUPER_ADMIN_SECRET = process.env.SUPER_ADMIN_SECRET || ''; export type AdminRole = 'admin' | 'super_admin'; @@ -14,12 +14,12 @@ export interface AdminPayload { export function createToken(secret: string): string { const isSuper = SUPER_ADMIN_SECRET && secret === SUPER_ADMIN_SECRET; - const isAdmin = secret === ADMIN_SECRET; - + const isAdmin = ADMIN_SECRET && secret === ADMIN_SECRET; + if (!isAdmin && !isSuper) return ''; - // If no super secret set, all admins are super - const role: AdminRole = (!SUPER_ADMIN_SECRET || isSuper) ? 'super_admin' : 'admin'; + // Роль: super_admin только при явном SUPER_ADMIN_SECRET и совпадении с ним + const role: AdminRole = isSuper ? 'super_admin' : 'admin'; const payload: AdminPayload = { role, @@ -63,7 +63,7 @@ export function verifyToken(token: string): AdminPayload | null { } export function verifyReAuth(token: string): boolean { - const isSuper = SUPER_ADMIN_SECRET && token === SUPER_ADMIN_SECRET; - const isAdmin = token === ADMIN_SECRET; + const isSuper = !!(SUPER_ADMIN_SECRET && token === SUPER_ADMIN_SECRET); + const isAdmin = !!(ADMIN_SECRET && token === ADMIN_SECRET); return isSuper || isAdmin; } diff --git a/src/migrations/015_shop_activation.js b/src/migrations/015_shop_activation.js new file mode 100644 index 0000000..c7960c7 --- /dev/null +++ b/src/migrations/015_shop_activation.js @@ -0,0 +1,36 @@ +import logger from '../utils/logger.js'; + +// Migration 015: shop activation flag + commission wallet addresses in site_settings +export default async function migration015(db) { + await db.runAsync('BEGIN TRANSACTION'); + try { + // Индекс на key — site_settings читается ботом на каждое сообщение (shop_activated, commission_wallet_*) + await db.runAsync( + 'CREATE INDEX IF NOT EXISTS idx_site_settings_key ON site_settings(key)' + ); + + // Shop activation flag: default true unless SHOP_ACTIVATED='false' + const shopActivated = process.env.SHOP_ACTIVATED === 'false' ? 'false' : 'true'; + await db.runAsync( + "INSERT OR IGNORE INTO site_settings (key, value) VALUES ('shop_activated', ?)", + [shopActivated] + ); + + // Commission wallet addresses: seed from env if set, otherwise empty string + const walletTypes = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH']; + for (const type of walletTypes) { + const envKey = `COMMISSION_WALLET_${type}`; + const value = process.env[envKey] || ''; + await db.runAsync( + "INSERT OR IGNORE INTO site_settings (key, value) VALUES (?, ?)", + [`commission_wallet_${type}`, value] + ); + } + + await db.runAsync('COMMIT'); + logger.info('Migration 015: shop_activation and commission_wallet_* seeded into site_settings'); + } catch (e) { + await db.runAsync('ROLLBACK'); + throw e; + } +}