6 Commits
v1.2.9 ... main

Author SHA1 Message Date
NW
c8d8aad8c3 feat(bot): SHOP_DB_PATH env — путь к БД настраиваемый (единая DB с админкой)
All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 11m54s
По умолчанию ../db/shop.db; если задана SHOP_DB_PATH (например общий файл
с админкой shop) — используется она. Позволяет боту и Next.js-админке
работать с одной базой (иначе товары из custom.db админки не видны боту).
2026-08-24 22:50:13 +01:00
NW
1c17501476 fix(bot): единый источник активности — env SHOP_ACTIVATED приоритет над БД
All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 20m10s
Рассинхрон: панель Bbox пишет SHOP_ACTIVATED в env, но бот читал активность
из БД site_settings (первично) → даже при true в панели бот отвечал
'Shop is not activated'. Теперь env — единый источник; БД — фоллбэк
(для админки магазина, если env не задан). Кэш 30с сохранён.
Версия v1.0.2.
2026-08-24 21:08:08 +01:00
NW
d2324db105 fix(bot): проверка дубликата BOT_TOKEN при старте (409 Conflict → exit 1)
All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 5m53s
Перед запуском polling делаем пробный getUpdates(timeout:0). Если Telegram
возвращает 409 'Conflict: terminated by other getUpdates request' (токен уже
используется другой нодой/процессом) — печатаем '🚫 ОБНАРУЖЕН ДУБЛИКАТ ТОКЕНА
ДЛЯ ЗАПУСКА' и завершаемся с кодом 1: контейнер не стартует, рестарт-цикл
останавливается (вместо молчаливого конфликта long poll между нодами).
Версия v1.0.1.
2026-08-23 01:57:16 +01:00
NW
1d083e564c feat(install): автоустановка --auto/--clean + arch-скрипты
- install.sh: режим --auto (неинтерактивный: placeholder BOT_TOKEN, ADMIN_IDS=0,
  секреты генерируются автоматически — для CI/SSH/установки на машины без TTY),
  --clean (очистка старых контейнеров/образов), авто-детект не-TTY stdin
- package.json: arch:build/arch:index/arch:index:full/incremental/arch:status
2026-08-23 00:30:48 +01:00
NW
e90dcb60c9 feat: shop activation + DB commission wallets + onion display (deploy pipeline)
All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 11m28s
- src/services/commissionService.js: read commission wallets and shop_activated from site_settings (DB) with env fallback, 30s cache
- src/handlers/adminHandlers/adminWalletsHandler.js: commission wallets from CommissionService instead of static config
- src/handlers/userHandlers/userHandler.js: canUseBot blocks all users when shop not activated
- src/migrations/015_shop_activation.js: seed shop_activated + commission_wallet_* into site_settings; register in runner
- admin-next: /api/system/info (onion hosts), /api/commission-wallets GET/PUT (super admin), /api/activation GET/PUT (super admin); lib/commission-wallets.ts shared helper; onion badge in header; shop activation card in settings; wallet editor in wallets page
- test: commissionService.test.js (10 cases: DB/env fallback, caching)
2026-08-11 18:59:18 +01:00
NW
b3d3018057 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
2026-08-11 18:13:09 +01:00
19 changed files with 1122 additions and 93 deletions

View 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 });
}
}

View 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 });
}
}

View 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 });
}

View File

@@ -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,

View File

@@ -22,8 +22,9 @@ import {
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Moon, Sun, LogOut, User, Search } from "lucide-react";
import { Moon, Sun, LogOut, User, Search, Globe, Copy } from "lucide-react";
import { useTheme } from "next-themes";
import { toast } from "sonner";
import { useAuthStore } from "@/stores/auth-store";
import { CommandPalette, openCommandPalette } from "@/components/layout/command-palette";
import { AppBreadcrumbs } from "@/components/layout/breadcrumbs";
@@ -79,6 +80,7 @@ export function AdminHeader() {
const { theme, setTheme } = useTheme();
const { role, logout } = useAuthStore();
const [logoutOpen, setLogoutOpen] = useState(false);
const [onion, setOnion] = useState("");
useEffect(() => {
const update = () => setHash(window.location.hash.slice(1) || "/");
@@ -87,6 +89,35 @@ export function AdminHeader() {
return () => window.removeEventListener("hashchange", update);
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/system/info");
if (!res.ok) throw new Error("Failed to fetch system info");
const data = await res.json();
if (cancelled) return;
setOnion(data.adminOnion || "");
} catch {
if (cancelled) return;
setOnion("");
}
})();
return () => {
cancelled = true;
};
}, []);
const copyOnion = async () => {
if (!onion) return;
try {
await navigator.clipboard.writeText(onion);
toast.success("Onion address copied");
} catch {
toast.error("Failed to copy onion address");
}
};
const title =
pageTitles[hash] ||
(hash.startsWith("/users/")
@@ -105,6 +136,18 @@ export function AdminHeader() {
</h1>
{/* 1. Clock (hidden on mobile) */}
<RealtimeClock />
{/* 1b. Onion host badge (hidden on mobile) */}
<button
type="button"
onClick={copyOnion}
disabled={!onion}
title={onion ? "Copy onion address" : "Onion address not ready yet"}
className="hidden md:flex items-center gap-1.5 rounded-md border border-border bg-muted/50 px-2 py-1 text-xs font-mono text-muted-foreground hover:bg-muted transition-colors shrink-0 max-w-[220px]"
>
<Globe className="size-3.5 shrink-0" />
<span className="truncate">{onion || "onion: —"}</span>
{onion && <Copy className="size-3 shrink-0 opacity-60" />}
</button>
{/* 2. Command palette search button */}
<Button
variant="ghost"

View File

@@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
@@ -22,7 +23,7 @@ import {
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { useAuthStore } from "@/stores/auth-store";
import { Bot, Shield, Wrench, Save, AlertTriangle, Download, Upload, Database, Info, Eye, EyeOff } from "lucide-react";
import { Bot, Shield, Wrench, Save, AlertTriangle, Download, Upload, Database, Info, Eye, EyeOff, ShieldCheck, Power, Copy, Globe, Link2 } from "lucide-react";
const MASKED_PLACEHOLDER = "\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF";
@@ -88,6 +89,83 @@ export function SettingsPage() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [revealed, setRevealed] = useState<Record<string, string>>({});
const [revealing, setRevealing] = useState<string | null>(null);
const [activated, setActivated] = useState<boolean | null>(null);
const [activationLoading, setActivationLoading] = useState(false);
const [activationDialogOpen, setActivationDialogOpen] = useState(false);
const [onion, setOnion] = useState("");
const [lanUrl, setLanUrl] = useState("");
useEffect(() => {
if (!isSuperAdmin) return;
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/activation");
if (!res.ok) throw new Error("Failed to fetch activation");
const data = await res.json();
if (!cancelled) setActivated(!!data.activated);
} catch {
if (!cancelled) setActivated(null);
}
})();
return () => {
cancelled = true;
};
}, [isSuperAdmin]);
useEffect(() => {
if (!isSuperAdmin) return;
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/system/info");
if (!res.ok) throw new Error("Failed to fetch system info");
const data = await res.json();
if (cancelled) return;
setOnion(data.adminOnion || "");
setLanUrl(data.lanUrl || "");
} catch {
if (cancelled) return;
setOnion("");
setLanUrl("");
}
})();
return () => {
cancelled = true;
};
}, [isSuperAdmin]);
const copyText = async (text: string, label: string) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
toast.success(`${label} copied`);
} catch {
toast.error(`Failed to copy ${label}`);
}
};
const handleToggleActivation = async () => {
if (activated === null) return;
const next = !activated;
setActivationLoading(true);
try {
const res = await fetch("/api/activation", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ activated: next }),
});
if (!res.ok) throw new Error("Failed to update activation");
const data = await res.json();
setActivated(!!data.activated);
toast.success(next ? "Shop activated" : "Shop blocked");
} catch {
toast.error("Failed to update shop activation");
} finally {
setActivationLoading(false);
setActivationDialogOpen(false);
}
};
useEffect(() => {
fetch("/api/settings")
@@ -431,6 +509,110 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
{/* Shop Activation (super admin only) */}
{isSuperAdmin && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4 text-emerald-500" />
Shop Activation
</CardTitle>
<CardDescription>
Control whether the shop is active and accepting orders. Blocking the shop
immediately disables purchasing for all users.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
{activated === null ? (
<Badge variant="secondary">Checking...</Badge>
) : activated ? (
<Badge className="bg-emerald-500/15 text-emerald-400">Activated</Badge>
) : (
<Badge className="bg-red-500/15 text-red-400">Blocked</Badge>
)}
<span className="text-sm text-muted-foreground">
{activated === null
? "Loading activation status..."
: activated
? "The shop is currently active and accepting orders."
: "The shop is currently blocked. Users cannot place orders."}
</span>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Onion:</span>
<button
type="button"
onClick={() => copyText(onion, "Onion address")}
disabled={!onion}
title={onion ? "Copy onion address" : "Onion address not ready yet"}
className="inline-flex items-center gap-1.5 rounded border border-border bg-muted/50 px-2 py-0.5 font-mono text-xs text-muted-foreground hover:bg-muted transition-colors disabled:opacity-50"
>
{onion || "—"}
{onion && <Copy className="h-3 w-3 opacity-60" />}
</button>
</div>
<div className="flex items-center gap-2 text-sm">
<Link2 className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">LAN:</span>
<button
type="button"
onClick={() => copyText(lanUrl, "LAN URL")}
disabled={!lanUrl}
title={lanUrl ? "Copy LAN URL" : "LAN URL not ready yet"}
className="inline-flex items-center gap-1.5 rounded border border-border bg-muted/50 px-2 py-0.5 font-mono text-xs text-muted-foreground hover:bg-muted transition-colors disabled:opacity-50"
>
{lanUrl || "—"}
{lanUrl && <Copy className="h-3 w-3 opacity-60" />}
</button>
</div>
</div>
<AlertDialog open={activationDialogOpen} onOpenChange={setActivationDialogOpen}>
<AlertDialogTrigger asChild>
<Button
variant={activated ? "destructive" : "default"}
className="gap-2"
disabled={activated === null || activationLoading}
>
<Power className="h-4 w-4" />
{activated ? "Block Shop" : "Activate Shop"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{activated ? "Block Shop" : "Activate Shop"}
</AlertDialogTitle>
<AlertDialogDescription>
{activated
? "Blocking the shop will immediately prevent all users from placing orders. This action can be reversed at any time."
: "Activating the shop will allow users to place orders again. This action can be reversed at any time."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleToggleActivation}
disabled={activationLoading}
className={activated ? "bg-destructive text-white hover:bg-destructive/90" : ""}
>
{activationLoading
? "Updating..."
: activated
? "Block Shop"
: "Activate Shop"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -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,42 +1281,92 @@ export function WalletsPage() {
{overview && overview.commissionWallets && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg">Pay to Commission Wallet</CardTitle>
<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">
<div className="space-y-2">
<Label htmlFor="commission-wallet-type">Currency</Label>
<select
id="commission-wallet-type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={selectedCommissionWallet}
onChange={(e) => setSelectedCommissionWallet(e.target.value)}
>
{currencyTypes.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div className="space-y-2">
<Label>Address</Label>
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-muted px-3 py-2 text-xs font-mono break-all">
{overview.commissionWallets[selectedCommissionWallet] || 'Not configured'}
</code>
<Button
variant="outline"
size="icon"
className="shrink-0"
onClick={() => copyToClipboard(
overview.commissionWallets[selectedCommissionWallet] || '',
'Address'
)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
{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
id="commission-wallet-type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={selectedCommissionWallet}
onChange={(e) => setSelectedCommissionWallet(e.target.value)}
>
{currencyTypes.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div className="space-y-2">
<Label>Address</Label>
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-muted px-3 py-2 text-xs font-mono break-all">
{overview.commissionWallets[selectedCommissionWallet] || 'Not configured'}
</code>
<Button
variant="outline"
size="icon"
className="shrink-0"
onClick={() => copyToClipboard(
overview.commissionWallets[selectedCommissionWallet] || '',
'Address'
)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
)}

View File

@@ -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;
}

View File

@@ -0,0 +1,27 @@
import { db } from '@/lib/db';
const WALLET_TYPES = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH'] as const;
const ENV_KEYS: Record<string, string> = {
BTC: 'COMMISSION_WALLET_BTC',
LTC: 'COMMISSION_WALLET_LTC',
USDT: 'COMMISSION_WALLET_USDT',
USDC: 'COMMISSION_WALLET_USDC',
ETH: 'COMMISSION_WALLET_ETH',
};
export async function getCommissionWallets(): Promise<Record<string, string>> {
const keys = WALLET_TYPES.map((t) => `commission_wallet_${t}`);
const rows = await db.siteSetting.findMany({
where: { key: { in: keys } },
});
const wallets: Record<string, string> = {};
for (const type of WALLET_TYPES) {
const dbKey = `commission_wallet_${type}`;
const row = rows.find((r) => r.key === dbKey);
wallets[type] = row?.value || process.env[ENV_KEYS[type]] || '';
}
return wallets;
}

View File

@@ -5,8 +5,28 @@ set -euo pipefail
# Telegram Shop — установщик
# Скрипт для развёртывания на x86_64 и ARM64 (Orange Pi, RPi)
# Совместим с POSIX sh (Alpine ash)
#
# Режимы:
# install.sh — интерактивный (запрашивает BOT_TOKEN/ADMIN_IDS)
# install.sh --auto — неинтерактивный: placeholder BOT_TOKEN, ADMIN_IDS=0,
# секреты генерируются автоматически (для CI/SSH)
# install.sh --clean — очистка старых контейнеров/образов перед установкой
# ============================================================
AUTO=0
CLEAN=0
for ARG in "$@"; do
case "$ARG" in
--auto) AUTO=1 ;;
--clean) CLEAN=1 ;;
esac
done
# Неинтерактивный режим также включается, если stdin не терминал
if [ ! -t 0 ]; then
AUTO=1
fi
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@@ -51,6 +71,27 @@ esac
print_ok "Архитектура: $ARCH_NAME"
print_info "Docker автоматически соберёт образ под текущую архитектуру"
# ============================================================
# 0.5. Очистка (--clean): удалить старые контейнеры/образы/тома
# ============================================================
if [ "$CLEAN" -eq 1 ]; then
print_step "Очистка старых контейнеров и образов"
if [ -f docker-compose.yml ]; then
docker compose down --rmi all --volumes 2>/dev/null || true
fi
# Удалить осиротевшие контейнеры/образы/тома проекта telegram-shop
docker ps -aq --filter "name=telegram_shop_prod" | xargs -r docker rm -f 2>/dev/null || true
docker ps -aq --filter "name=tg_shop_admin" | xargs -r docker rm -f 2>/dev/null || true
docker ps -aq --filter "name=tor-proxy" | xargs -r docker rm -f 2>/dev/null || true
docker images -q "git.softuniq.eu/telegram-market/telegram-shop*" | xargs -r docker rmi -f 2>/dev/null || true
docker images -q "telegram-shop-*" | xargs -r docker rmi -f 2>/dev/null || true
docker volume rm tor_proxy_data 2>/dev/null || true
docker network rm tor_proxy_net telegram-shop_default 2>/dev/null || true
# Удалить локальную БД и загрузки (чистая переустановка)
rm -rf db uploads
print_ok "Очистка завершена (tor-ssh-client не тронут)"
fi
# ============================================================
# 1. Проверка / установка Docker
# ============================================================
@@ -200,10 +241,16 @@ generate_random_hex() {
# BOT_TOKEN
current_bot_token=$(grep -E "^BOT_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
if [ -z "$current_bot_token" ] || [ "$current_bot_token" = "your_bot_token_here" ]; then
print_warn "BOT_TOKEN не задан или содержит placeholder."
read_secret "Введите BOT_TOKEN (от @BotFather): "
update_env_var "BOT_TOKEN" "$input"
bot_token="$input"
if [ "$AUTO" -eq 1 ]; then
bot_token="REPLACE_ME_LATER"
update_env_var "BOT_TOKEN" "$bot_token"
print_warn "BOT_TOKEN не задан — установлен placeholder (внесите реальный токен в админке)"
else
print_warn "BOT_TOKEN не задан или содержит placeholder."
read_secret "Введите BOT_TOKEN (от @BotFather): "
update_env_var "BOT_TOKEN" "$input"
bot_token="$input"
fi
else
bot_token="$current_bot_token"
fi
@@ -211,11 +258,17 @@ fi
# ADMIN_IDS
current_admin_ids=$(grep -E "^ADMIN_IDS=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
if [ -z "$current_admin_ids" ] || [ "$current_admin_ids" = "123456789,987654321" ]; then
print_warn "ADMIN_IDS не задан или содержит placeholder."
printf "Введите ADMIN_IDS (через запятую, например: 123456789,987654321): "
read -r input
update_env_var "ADMIN_IDS" "$input"
admin_ids="$input"
if [ "$AUTO" -eq 1 ]; then
admin_ids="0"
update_env_var "ADMIN_IDS" "$admin_ids"
print_warn "ADMIN_IDS не задан — установлен 0 (настройте в админке)"
else
print_warn "ADMIN_IDS не задан или содержит placeholder."
printf "Введите ADMIN_IDS (через запятую, например: 123456789,987654321): "
read -r input
update_env_var "ADMIN_IDS" "$input"
admin_ids="$input"
fi
else
admin_ids="$current_admin_ids"
fi
@@ -232,7 +285,7 @@ fi
# ADMIN_SECRET
current_admin_secret=$(grep -E "^ADMIN_SECRET=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
if [ -z "$current_admin_secret" ] || [ "$current_admin_secret" = "changeme" ]; then
if [ -z "$current_admin_secret" ] || [ "$current_admin_secret" = "changeme" ] || [ "$current_admin_secret" = "your_admin_token_here" ]; then
admin_secret=$(generate_random_hex)
update_env_var "ADMIN_SECRET" "$admin_secret"
print_ok "ADMIN_SECRET сгенерирован автоматически"
@@ -242,7 +295,7 @@ fi
# SUPER_ADMIN_SECRET
current_super_admin_secret=$(grep -E "^SUPER_ADMIN_SECRET=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
if [ -z "$current_super_admin_secret" ] || [ "$current_super_admin_secret" = "changeme_super" ]; then
if [ -z "$current_super_admin_secret" ] || [ "$current_super_admin_secret" = "changeme_super" ] || [ "$current_super_admin_secret" = "your_super_admin_token_here" ]; then
# Генерация до тех пор, пока не получим значение, отличное от ADMIN_SECRET
super_admin_secret=""
while [ -z "$super_admin_secret" ] || [ "$super_admin_secret" = "$admin_secret" ]; do
@@ -266,7 +319,7 @@ fi
# ADMIN_URL
current_admin_url=$(grep -E "^ADMIN_URL=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"') || true
if [ -z "$current_admin_url" ]; then
if [ -z "$current_admin_url" ] || [ "$current_admin_url" = "http://your-host:3000" ] || [ "$current_admin_url" = "your-host" ]; then
# Попытка определить LAN IP
lan_ip=$(hostname -I | awk '{print $1}')
if [ -z "$lan_ip" ]; then
@@ -307,6 +360,13 @@ print_info "Образы собираются в CI (Gitea Actions, buildx multi
IMAGE_TAG="${IMAGE_TAG:-latest}"
print_info "Используемая версия образа: $IMAGE_TAG"
# Логин в приватный Gitea Container Registry (если задан токен)
if [ -n "${GITEA_TOKEN:-}" ]; then
echo "${GITEA_TOKEN}" | docker login git.softuniq.eu -u oauth2 --password-stdin >/dev/null 2>&1 \
&& print_ok "Авторизация в Gitea Container Registry" \
|| print_warn "Не удалось авторизоваться в registry (GITEA_TOKEN)"
fi
$COMPOSE_CMD pull telegram_shop_prod tg_shop_admin
print_ok "Образы telegram-shop:${IMAGE_TAG} и telegram-shop-admin:${IMAGE_TAG} загружены"

View File

@@ -1,13 +1,18 @@
{
"name": "telegram-shop-bot",
"version": "1.0.0",
"version": "1.0.2",
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"lint": "find src -name '*.js' -print0 | xargs -0 -n1 node --check"
"lint": "find src -name '*.js' -print0 | xargs -0 -n1 node --check",
"arch:build": "docker compose -f docker/docker-compose.architect.yml build",
"arch:index": "docker compose -f docker/docker-compose.architect.yml run --rm architect-indexer",
"arch:index:full": "docker compose -f docker/docker-compose.architect.yml run --rm architect-indexer --mode full",
"arch:index:incremental": "docker compose -f docker/docker-compose.architect.yml run --rm architect-indexer --mode incremental",
"arch:status": "docker compose -f docker/docker-compose.architect.yml ps"
},
"dependencies": {
"archiver": "^7.0.1",

View File

@@ -0,0 +1,240 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock config
vi.mock('../config/config.js', () => ({
__esModule: true,
default: {
BOT_TOKEN: 'test-token',
ADMIN_IDS: ['123456789'],
SUPER_ADMIN_IDS: ['123456789'],
SUPPORT_LINK: 'https://t.me/support',
DEFAULT_LANGUAGE: 'en',
ENCRYPTION_KEY: 'x'.repeat(64),
COMMISSION_ENABLED: true,
COMMISSION_PERCENT: 5,
COMMISSION_WALLETS: {
BTC: 'btc-env-address',
LTC: 'ltc-env-address',
USDT: 'usdt-env-address',
USDC: 'usdc-env-address',
ETH: 'eth-env-address'
}
}
}));
// Mock logger
vi.mock('../utils/logger.js', () => ({
__esModule: true,
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
fatal: vi.fn()
}
}));
// Track DB calls
const dbCalls = {
allAsync: [],
getAsync: [],
runAsync: []
};
// Mock database
vi.mock('../config/database.js', () => {
const db = {
allAsync: vi.fn(async (sql, params = []) => {
dbCalls.allAsync.push({ sql, params });
return [];
}),
getAsync: vi.fn(async (sql, params = []) => {
dbCalls.getAsync.push({ sql, params });
return undefined;
}),
runAsync: vi.fn(async (sql, params = []) => {
dbCalls.runAsync.push({ sql, params });
return {};
})
};
return { __esModule: true, default: db };
});
// We need to import after mocks are set up
let CommissionService;
describe('CommissionService', () => {
beforeEach(async () => {
dbCalls.allAsync = [];
dbCalls.getAsync = [];
dbCalls.runAsync = [];
vi.clearAllMocks();
// Dynamic import to get fresh module with mocks
CommissionService = (await import('../services/commissionService.js')).default;
// Invalidate cache before each test
CommissionService.invalidateCache();
});
describe('getCommissionWallets', () => {
it('returns wallets from DB when all are set', async () => {
const { default: dbMock } = await import('../config/database.js');
dbMock.allAsync.mockResolvedValueOnce([
{ key: 'commission_wallet_BTC', value: 'btc-db-address' },
{ key: 'commission_wallet_LTC', value: 'ltc-db-address' },
{ key: 'commission_wallet_USDT', value: 'usdt-db-address' },
{ key: 'commission_wallet_USDC', value: 'usdc-db-address' },
{ key: 'commission_wallet_ETH', value: 'eth-db-address' }
]);
const wallets = await CommissionService.getCommissionWallets();
expect(wallets).toEqual({
BTC: 'btc-db-address',
LTC: 'ltc-db-address',
USDT: 'usdt-db-address',
USDC: 'usdc-db-address',
ETH: 'eth-db-address'
});
});
it('falls back to env when DB value is empty string', async () => {
const { default: dbMock } = await import('../config/database.js');
dbMock.allAsync.mockResolvedValueOnce([
{ key: 'commission_wallet_BTC', value: '' },
{ key: 'commission_wallet_LTC', value: 'ltc-db-address' },
{ key: 'commission_wallet_USDT', value: '' },
{ key: 'commission_wallet_USDC', value: 'usdc-db-address' },
{ key: 'commission_wallet_ETH', value: '' }
]);
const wallets = await CommissionService.getCommissionWallets();
expect(wallets.BTC).toBe('btc-env-address');
expect(wallets.LTC).toBe('ltc-db-address');
expect(wallets.USDT).toBe('usdt-env-address');
expect(wallets.USDC).toBe('usdc-db-address');
expect(wallets.ETH).toBe('eth-env-address');
});
it('falls back to env when DB key is missing', async () => {
const { default: dbMock } = await import('../config/database.js');
dbMock.allAsync.mockResolvedValueOnce([
{ key: 'commission_wallet_BTC', value: 'btc-db-address' }
]);
const wallets = await CommissionService.getCommissionWallets();
expect(wallets.BTC).toBe('btc-db-address');
expect(wallets.LTC).toBe('ltc-env-address');
expect(wallets.USDT).toBe('usdt-env-address');
expect(wallets.USDC).toBe('usdc-env-address');
expect(wallets.ETH).toBe('eth-env-address');
});
it('caches result: second call does not hit DB', async () => {
const { default: dbMock } = await import('../config/database.js');
dbMock.allAsync.mockResolvedValueOnce([
{ key: 'commission_wallet_BTC', value: 'btc-db-address' },
{ key: 'commission_wallet_LTC', value: 'ltc-db-address' },
{ key: 'commission_wallet_USDT', value: 'usdt-db-address' },
{ key: 'commission_wallet_USDC', value: 'usdc-db-address' },
{ key: 'commission_wallet_ETH', value: 'eth-db-address' }
]);
await CommissionService.getCommissionWallets();
const callCount = dbCalls.allAsync.length;
await CommissionService.getCommissionWallets();
expect(dbCalls.allAsync.length).toBe(callCount);
});
});
describe('getShopActivated', () => {
it('returns false when DB has "false"', async () => {
const { default: dbMock } = await import('../config/database.js');
dbMock.getAsync.mockResolvedValueOnce({ key: 'shop_activated', value: 'false' });
const result = await CommissionService.getShopActivated();
expect(result).toBe(false);
});
it('returns true when DB has "true"', async () => {
const { default: dbMock } = await import('../config/database.js');
dbMock.getAsync.mockResolvedValueOnce({ key: 'shop_activated', value: 'true' });
const result = await CommissionService.getShopActivated();
expect(result).toBe(true);
});
it('returns false when DB key missing and env SHOP_ACTIVATED is "false"', async () => {
const { default: dbMock } = await import('../config/database.js');
dbMock.getAsync.mockResolvedValueOnce(undefined);
const original = process.env.SHOP_ACTIVATED;
process.env.SHOP_ACTIVATED = 'false';
try {
const result = await CommissionService.getShopActivated();
expect(result).toBe(false);
} finally {
process.env.SHOP_ACTIVATED = original;
}
});
it('returns true when DB key missing and env SHOP_ACTIVATED is not set', async () => {
const { default: dbMock } = await import('../config/database.js');
dbMock.getAsync.mockResolvedValueOnce(undefined);
const original = process.env.SHOP_ACTIVATED;
delete process.env.SHOP_ACTIVATED;
try {
const result = await CommissionService.getShopActivated();
expect(result).toBe(true);
} finally {
if (original !== undefined) {
process.env.SHOP_ACTIVATED = original;
}
}
});
it('caches result: second call does not hit DB', async () => {
const { default: dbMock } = await import('../config/database.js');
dbMock.getAsync.mockResolvedValueOnce({ key: 'shop_activated', value: 'true' });
await CommissionService.getShopActivated();
const callCount = dbCalls.getAsync.length;
await CommissionService.getShopActivated();
expect(dbCalls.getAsync.length).toBe(callCount);
});
});
describe('invalidateCache', () => {
it('forces re-fetch after invalidation', async () => {
const { default: dbMock } = await import('../config/database.js');
dbMock.allAsync.mockResolvedValueOnce([
{ key: 'commission_wallet_BTC', value: 'btc-db-address' },
{ key: 'commission_wallet_LTC', value: 'ltc-db-address' },
{ key: 'commission_wallet_USDT', value: 'usdt-db-address' },
{ key: 'commission_wallet_USDC', value: 'usdc-db-address' },
{ key: 'commission_wallet_ETH', value: 'eth-db-address' }
]);
dbMock.allAsync.mockResolvedValueOnce([
{ key: 'commission_wallet_BTC', value: 'btc-db-address-v2' },
{ key: 'commission_wallet_LTC', value: 'ltc-db-address' },
{ key: 'commission_wallet_USDT', value: 'usdt-db-address' },
{ key: 'commission_wallet_USDC', value: 'usdc-db-address' },
{ key: 'commission_wallet_ETH', value: 'eth-db-address' }
]);
const first = await CommissionService.getCommissionWallets();
expect(first.BTC).toBe('btc-db-address');
CommissionService.invalidateCache();
const second = await CommissionService.getCommissionWallets();
expect(second.BTC).toBe('btc-db-address-v2');
});
});
});

View File

@@ -5,7 +5,13 @@ import { dirname } from 'path';
import { pathToFileURL } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = new URL('../../db/shop.db', import.meta.url).pathname;
// Путь к БД: env SHOP_DB_PATH (для единой БД с админкой) → по умолчанию ../db/shop.db.
// Рассинхрон: админка (Next.js/Prisma) писала в custom.db, бот — в shop.db → товары,
// добавленные в админке, не были видны боту. Оба должны использовать ОДИН файл.
const DB_PATH =
process.env.SHOP_DB_PATH && process.env.SHOP_DB_PATH !== 'false'
? process.env.SHOP_DB_PATH
: new URL('../../db/shop.db', import.meta.url).pathname;
let betterDb;

View File

@@ -15,7 +15,53 @@ const initBot = async () => {
}
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const instance = new TelegramBot(config.BOT_TOKEN, {polling: true});
const instance = new TelegramBot(config.BOT_TOKEN, {polling: false});
// ── Проверка на ДУБЛИКАТ токена ─────────────────────────────
// Перед запуском polling делаем разовый getUpdates. Если токен уже
// используется другой нодой/процессом — Telegram возвращает 409
// "Conflict: terminated by other getUpdates request" (или webhook).
// Такой бот НЕ должен стартовать: он будет конфликтовать, терять
// обновления и «прыгать» между серверами (issue: дубликат BotFather
// токена на нескольких нодах).
const dupCheck = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => resolve(null), 10000);
instance
.getUpdates({ timeout: 0, allowed_updates: [] })
.then((updates) => {
clearTimeout(timeout);
// допустимые значения — любой успешный ответ ([] или список)
resolve({ ok: true, updates });
})
.catch((error) => {
clearTimeout(timeout);
const status = error?.response?.statusCode ?? error?.response?.status;
const description = error?.response?.body?.description ?? error?.message ?? String(error);
const is409 = status === 409 || /conflict/i.test(description);
resolve({ ok: false, status, description, is409 });
});
});
if (!dupCheck?.ok) {
if (dupCheck?.is409) {
console.error(
'\n====================================================\n' +
'🚫 ОБНАРУЖЕН ДУБЛИКАТ ТОКЕНА ДЛЯ ЗАПУСКА\n' +
' Telegram вернул 409 Conflict — этот BOT_TOKEN уже\n' +
' используется другим экземпляром/нодой.\n' +
` (${dupCheck.description})\n` +
' Запуск бота остановлен. Создайте новый бот в @BotFather\n' +
' или замените BOT_TOKEN в настройках сервиса.\n' +
'====================================================\n'
);
process.exit(1);
}
if (dupCheck?.status === 404 || (dupCheck?.description || '').includes('Not Found')) {
// неверный токен — пробуем дальше; ниже полноценная валидация
logger.warn('validate: getUpdates 404 (invalid token?)');
}
}
// Реальная инициализация: включаем polling и проверяем getMe
await instance.startPolling();
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
instance.stopPolling();

View File

@@ -14,20 +14,11 @@ 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 CommissionService from '../../services/commissionService.js';
import fs from 'fs';
import csvWriter from 'csv-writer';
export default class AdminWalletsHandler {
static {
if (config.COMMISSION_ENABLED) {
const requiredWallets = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH'];
const missingWallets = requiredWallets.filter(wallet => !config.COMMISSION_WALLETS[wallet]);
if (missingWallets.length > 0) {
logger.warn({ missingWallets }, `Commission enabled but wallet addresses missing for: ${missingWallets.join(', ')}. Commission features will be limited.`);
}
}
}
// Метод для проверки, является ли пользователь администратором
// (используется общая функция из middleware/auth.js)
@@ -218,7 +209,8 @@ export default class AdminWalletsHandler {
try {
logger.info({ walletType, requiredAmount: requiredAmount.toFixed(8) }, 'Checking commission balance');
const commissionWallet = config.COMMISSION_WALLETS[walletType];
const wallets = await CommissionService.getCommissionWallets();
const commissionWallet = wallets[walletType];
if (!commissionWallet) {
throw new Error(`Commission wallet not configured for ${walletType}`);
}
@@ -396,8 +388,9 @@ export default class AdminWalletsHandler {
logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance');
if (commissionCheck.difference < 0) {
const commissionWallets = await CommissionService.getCommissionWallets();
const message = `⚠️ Insufficient balance in commission wallet!\n` +
`Wallet: ${config.COMMISSION_WALLETS[walletType]}\n` +
`Wallet: ${commissionWallets[walletType]}\n` +
`Required: ${commissionAmount.toFixed(8)} ${walletType}\n` +
`Current balance: ${commissionCheck.balance.toFixed(8)} ${walletType}\n` +
`Difference: ${Math.abs(commissionCheck.difference).toFixed(8)} ${walletType}`;
@@ -534,8 +527,9 @@ export default class AdminWalletsHandler {
logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance');
if (commissionCheck.difference < 0) {
const commissionWallets = await CommissionService.getCommissionWallets();
const message = `⚠️ Insufficient balance in commission wallet!\n` +
`Wallet: ${config.COMMISSION_WALLETS[walletType]}\n` +
`Wallet: ${commissionWallets[walletType]}\n` +
`Required: ${commissionAmount.toFixed(8)} ${walletType}\n` +
`Current balance: ${commissionCheck.balance.toFixed(8)} ${walletType}\n` +
`Difference: ${Math.abs(commissionCheck.difference).toFixed(8)} ${walletType}`;

View File

@@ -8,6 +8,7 @@ import logger from "../../utils/logger.js";
import { resetUserContext } from "../../utils/messageUtils.js";
import userStates from "../../context/userStates.js";
import chatbotService from "../../services/chatbotService.js";
import CommissionService from "../../services/commissionService.js";
import leadService from "../../services/leadService.js";
import { tForUser, LANGUAGE_NAMES, AVAILABLE_LANGUAGES } from '../../i18n/index.js';
@@ -19,6 +20,11 @@ export default class UserHandler {
const lang = user?.language || 'en';
const t = tForUser(lang);
if (!(await CommissionService.getShopActivated())) {
await bot.sendMessage(telegramId, '⛔ Shop is not activated yet. Please contact support.');
return false;
}
const keyboard = {
inline_keyboard: [
[{text: t('bot.contact_support'), url: config.SUPPORT_LINK}]

View 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;
}
}

View File

@@ -49,6 +49,7 @@ export async function runMigrations() {
(await import('./012_fix_typos.js')).default,
(await import('./013_ai_features.js')).default,
(await import('./014_user_notes.js')).default,
(await import('./015_shop_activation.js')).default,
];
for (let i = currentVersion; i < migrations.length; i++) {

View File

@@ -0,0 +1,106 @@
import db from '../config/database.js';
import config from '../config/config.js';
import logger from '../utils/logger.js';
const CACHE_TTL_MS = 30 * 1000; // 30 sec
let commissionCache = null;
let commissionCachedAt = 0;
let shopActivatedCache = null;
let shopActivatedCachedAt = 0;
const WALLET_TYPES = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH'];
function isCacheValid(cachedAt) {
return Date.now() - cachedAt < CACHE_TTL_MS;
}
/**
* Get commission wallet addresses from site_settings with env fallback.
* @returns {Promise<{BTC: string, LTC: string, USDT: string, USDC: string, ETH: string}>}
*/
export async function getCommissionWallets() {
const now = Date.now();
if (commissionCache && isCacheValid(commissionCachedAt)) {
return commissionCache;
}
try {
const rows = await db.allAsync(
`SELECT key, value FROM site_settings WHERE key IN (${WALLET_TYPES.map(() => '?').join(',')})`,
WALLET_TYPES.map(t => `commission_wallet_${t}`)
);
const dbValues = {};
for (const row of rows) {
const type = row.key.replace('commission_wallet_', '');
dbValues[type] = row.value;
}
const wallets = {};
for (const type of WALLET_TYPES) {
const dbVal = dbValues[type];
wallets[type] = (dbVal && dbVal !== '') ? dbVal : (config.COMMISSION_WALLETS[type] || '');
}
commissionCache = wallets;
commissionCachedAt = now;
return wallets;
} catch (err) {
logger.error({ err }, 'Failed to load commission wallets from site_settings');
return { ...config.COMMISSION_WALLETS };
}
}
/**
* Check if the shop is activated.
* Приоритет: env SHOP_ACTIVATED (единый источник — панель Bbox) → БД site_settings.
* Раньше БД была первичной, и смена «Магазин активирован» в панели (env) не
* влияла на бота (в БД оставалось старое значение) — рассинхрон «пишет true,
* а бот говорит not activated». Теперь env главный; БД — зеркало-фоллбэк.
* @returns {Promise<boolean>}
*/
export async function getShopActivated() {
const now = Date.now();
if (shopActivatedCache !== null && isCacheValid(shopActivatedCachedAt)) {
return shopActivatedCache;
}
let activated;
// 1. env (панель Bbox / compose) — единый источник правды
if (process.env.SHOP_ACTIVATED !== undefined) {
activated = process.env.SHOP_ACTIVATED !== 'false';
} else {
// 2. БД site_settings (админка магазина может вести туда же)
try {
const row = await db.getAsync(
"SELECT value FROM site_settings WHERE key = 'shop_activated'"
);
activated = row ? row.value !== 'false' : true;
} catch (err) {
logger.error({ err }, 'Failed to read shop_activated from site_settings');
activated = true;
}
}
shopActivatedCache = activated;
shopActivatedCachedAt = now;
return activated;
}
/**
* Invalidate all caches (useful after settings update).
*/
export function invalidateCache() {
commissionCache = null;
commissionCachedAt = 0;
shopActivatedCache = null;
shopActivatedCachedAt = 0;
}
export default {
getCommissionWallets,
getShopActivated,
invalidateCache,
};