fix(admin): review findings — auth, wallet overview math, commission PUT, activation
- 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
This commit is contained in:
42
admin-next/src/app/api/activation/route.ts
Normal file
42
admin-next/src/app/api/activation/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
80
admin-next/src/app/api/commission-wallets/route.ts
Normal file
80
admin-next/src/app/api/commission-wallets/route.ts
Normal file
@@ -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<string, string> = body.wallets || {};
|
||||
|
||||
// Валидация: только известные типы и корректный формат адресов
|
||||
const operations: Prisma.PrismaPromise<unknown>[] = [];
|
||||
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 });
|
||||
}
|
||||
}
|
||||
26
admin-next/src/app/api/system/info/route.ts
Normal file
26
admin-next/src/app/api/system/info/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
@@ -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<Record<string, number>> {
|
||||
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<string, number> = { BTC: 0, LTC: 0, ETH: 0, USDT: 0, USDC: 0 };
|
||||
const walletCounts: Record<string, number> = { BTC: 0, LTC: 0, ETH: 0, USDT: 0, USDC: 0 };
|
||||
const userIdSet = new Set<number>();
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<Record<string, string>>({});
|
||||
const [walletsSaving, setWalletsSaving] = useState(false);
|
||||
|
||||
// Tab 4 state
|
||||
const [txData, setTxData] = useState<TransactionRow[]>([]);
|
||||
@@ -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() {
|
||||
<p className="text-sm text-muted-foreground">Active Wallets</p>
|
||||
<Wallet className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{overview.totalWallets}</p>
|
||||
<p className="text-2xl font-bold mt-1">{overview.activeWallets ?? 0}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -1236,10 +1281,58 @@ export function WalletsPage() {
|
||||
{overview && overview.commissionWallets && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-lg">Pay to Commission Wallet</CardTitle>
|
||||
{isSuperAdmin && !editingWallets && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={startEditWallets}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit Wallets
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>Select currency and send payment to the address below</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{editingWallets ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{(['BTC', 'LTC', 'USDT', 'USDC', 'ETH'] as const).map((t) => (
|
||||
<div key={t} className="space-y-1.5">
|
||||
<Label htmlFor={`wallet-${t}`} className="text-sm">{t}</Label>
|
||||
<Input
|
||||
id={`wallet-${t}`}
|
||||
value={walletDraft[t] || ''}
|
||||
onChange={(e) => setWalletDraft((prev) => ({ ...prev, [t]: e.target.value }))}
|
||||
className="font-mono"
|
||||
placeholder={`${t} address`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSaveWallets}
|
||||
disabled={walletsSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{walletsSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={cancelEditWallets}
|
||||
disabled={walletsSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="commission-wallet-type">Currency</Label>
|
||||
<select
|
||||
@@ -1272,6 +1365,8 @@ export function WalletsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
36
src/migrations/015_shop_activation.js
Normal file
36
src/migrations/015_shop_activation.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user