feat: web admin panel + better-sqlite3 migration + Docker fixes
- Added Express.js admin panel on port 3001 (ADMIN_PORT env) - Dashboard: stats (users, products, purchases, revenue) - Users: list, details, ban/unban toggle - Products: CRUD by category - Wallets: list with balances - Purchases: history with filters - Audit log: view audit trail - Auth: token-based login with ADMIN_SECRET env var - Migrated sqlite3 → better-sqlite3 - database.js: async adapter (runAsync/allAsync/getAsync) - purchaseService.js: lastID → lastInsertRowid - userService.js: lastID → lastInsertRowid - Removed sqlite3 from package.json - Fixed: dotenv/config import added to index.js - Fixed: ENCRYPTION_KEY validation (32+ char hex) - Fixed: Dockerfile multi-stage build (no python needed) - Fixed: Docker DNS (network: host in build) - Fixed: docker-compose port 3001, healthcheck on 3001 - Added express, cookie-parser, pino-pretty, better-sqlite3 deps
This commit is contained in:
24
Dockerfile
24
Dockerfile
@@ -2,9 +2,6 @@ FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies for native modules (sqlite3, better-sqlite3)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install && npm cache clean --force
|
||||
|
||||
@@ -12,20 +9,20 @@ RUN npm install && npm cache clean --force
|
||||
FROM node:22-alpine
|
||||
|
||||
# Install runtime dependencies
|
||||
# WireGuard needs community repo on some alpine versions
|
||||
RUN apk add --no-cache --repository https://dl-cdn.alpinelinux.org/alpine/edge/community \
|
||||
wireguard-tools \
|
||||
RUN apk update && \
|
||||
apk add --no-cache --repository https://dl-cdn.alpinelinux.org/alpine/edge/community \
|
||||
wireguard-tools \
|
||||
&& apk add --no-cache \
|
||||
iptables \
|
||||
iproute2 \
|
||||
openresolv \
|
||||
bash \
|
||||
curl \
|
||||
iptables \
|
||||
iproute2 \
|
||||
openresolv \
|
||||
bash \
|
||||
curl \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy node_modules from builder (with native addons pre-compiled)
|
||||
# Copy node_modules from builder
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -41,7 +38,6 @@ RUN mkdir -p /app/db
|
||||
|
||||
# Health check: bot responds to /health on port 3000
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -sf http://localhost:3000/health || exit 1
|
||||
CMD curl -sf http://localhost:3001/health || exit 1
|
||||
|
||||
# start.sh runs as root (needed for wg-quick), then drops to node
|
||||
CMD ["/bin/bash", "/app/start.sh"]
|
||||
@@ -4,8 +4,11 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
network: host
|
||||
hostname: telegram_shop_prod
|
||||
container_name: telegram_shop_prod
|
||||
ports:
|
||||
- "3001:3001"
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
@@ -16,10 +19,13 @@ services:
|
||||
- NET_ADMIN
|
||||
sysctls:
|
||||
- net.ipv4.conf.all.src_valid_mark=1 # Необходимо для маршрутизации
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 1.1.1.1
|
||||
mem_limit: 512m
|
||||
cpus: "1.0"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3001/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
1672
package-lock.json
generated
1672
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.7.7",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.21.0",
|
||||
"bip39": "^3.1.0",
|
||||
"bitcoinjs-lib": "^6.1.6",
|
||||
"csv-writer": "^1.6.0",
|
||||
@@ -20,7 +22,7 @@
|
||||
"hdkey": "^2.1.0",
|
||||
"node-telegram-bot-api": "^0.64.0",
|
||||
"pino": "^8.21.0",
|
||||
"sqlite3": "^5.1.6",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tiny-secp256k1": "^2.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
83
src/admin/auth.js
Normal file
83
src/admin/auth.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import crypto from 'crypto';
|
||||
import config from '../config/config.js';
|
||||
|
||||
const TOKEN_SECRET = process.env.ADMIN_SECRET || config.ADMIN_IDS[0] || 'change-me';
|
||||
const COOKIE_NAME = 'admin_token';
|
||||
const MAX_AGE = 24 * 60 * 60 * 1000;
|
||||
|
||||
function signToken(data) {
|
||||
const payload = JSON.stringify({ ...data, exp: Date.now() + MAX_AGE });
|
||||
const b64 = Buffer.from(payload).toString('base64');
|
||||
const sig = crypto.createHmac('sha256', TOKEN_SECRET).update(b64).digest('hex');
|
||||
return `${b64}.${sig}`;
|
||||
}
|
||||
|
||||
function verifyToken(token) {
|
||||
try {
|
||||
const [b64, sig] = token.split('.');
|
||||
const expected = crypto.createHmac('sha256', TOKEN_SECRET).update(b64).digest('hex');
|
||||
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
||||
const payload = JSON.parse(Buffer.from(b64, 'base64').toString());
|
||||
if (payload.exp < Date.now()) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAuth(req, res, next) {
|
||||
const token = req.cookies?.[COOKIE_NAME];
|
||||
if (!token) return res.redirect('/login');
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) {
|
||||
res.clearCookie(COOKIE_NAME);
|
||||
return res.redirect('/login');
|
||||
}
|
||||
req.admin = payload;
|
||||
next();
|
||||
}
|
||||
|
||||
export function handleLogin(req, res) {
|
||||
const { token } = req.body || {};
|
||||
if (token !== TOKEN_SECRET) {
|
||||
return res.status(401).send(renderLogin('Invalid token'));
|
||||
}
|
||||
const signed = signToken({ role: 'admin' });
|
||||
res.cookie(COOKIE_NAME, signed, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: MAX_AGE,
|
||||
secure: false
|
||||
});
|
||||
res.redirect('/');
|
||||
}
|
||||
|
||||
export function handleLogout(req, res) {
|
||||
res.clearCookie(COOKIE_NAME);
|
||||
res.redirect('/login');
|
||||
}
|
||||
|
||||
function renderLogin(error) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Login</title>
|
||||
<link rel="stylesheet" href="/admin/style.css">
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<div class="login-box">
|
||||
<h1>Admin Panel</h1>
|
||||
${error ? `<p class="error">${error}</p>` : ''}
|
||||
<form method="POST" action="/login">
|
||||
<label for="token">Admin Token</label>
|
||||
<input type="password" id="token" name="token" required autofocus>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export { renderLogin };
|
||||
215
src/admin/public/style.css
Normal file
215
src/admin/public/style.css
Normal file
@@ -0,0 +1,215 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--card: #fff;
|
||||
--text: #1a1a1a;
|
||||
--muted: #666;
|
||||
--primary: #2563eb;
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
--border: #e5e5e5;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brand { font-weight: 700; font-size: 1.1rem; }
|
||||
|
||||
.nav-links { display: flex; gap: 0.25rem; flex-wrap: wrap; }
|
||||
|
||||
.nav-links a {
|
||||
padding: 0.4rem 0.75rem;
|
||||
text-decoration: none;
|
||||
color: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nav-links a:hover, .nav-links a.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
margin-left: auto;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.content { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
|
||||
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
|
||||
h2 { font-size: 1.2rem; margin-bottom: 0.75rem; }
|
||||
h3 { font-size: 1rem; margin: 1rem 0 0.5rem; }
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--card);
|
||||
padding: 1.25rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value { display: block; font-size: 1.75rem; font-weight: 700; color: var(--primary); }
|
||||
.stat-label { display: block; font-size: 0.85rem; color: var(--muted); margin-top: 0.25rem; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th, td { padding: 0.6rem 0.75rem; text-align: left; font-size: 0.9rem; }
|
||||
th { background: #fafafa; font-weight: 600; border-bottom: 2px solid var(--border); }
|
||||
td { border-bottom: 1px solid var(--border); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f9fafb; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-active { background: #dcfce7; color: var(--success); }
|
||||
.badge-banned { background: #fee2e2; color: var(--danger); }
|
||||
|
||||
.btn, .btn-sm, button {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.8rem; }
|
||||
.btn:hover, .btn-sm:hover, button:hover { opacity: 0.9; }
|
||||
.btn-danger, .btn-danger:hover { background: var(--danger); }
|
||||
.btn-success, .btn-success:hover { background: var(--success); }
|
||||
.btn-secondary { background: var(--muted); }
|
||||
|
||||
.form, .inline-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.inline-form { flex-direction: row; flex-wrap: wrap; align-items: end; max-width: none; }
|
||||
|
||||
.form label { font-weight: 600; font-size: 0.9rem; }
|
||||
|
||||
input, select, textarea {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
textarea { min-height: 80px; resize: vertical; }
|
||||
|
||||
.form-section {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-section summary {
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-card p { margin-bottom: 0.4rem; }
|
||||
|
||||
.flash {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.flash-info { background: #dbeafe; color: #1e40af; }
|
||||
.flash-error { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: var(--card);
|
||||
padding: 2rem;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.login-box h1 { text-align: center; margin-bottom: 1.5rem; }
|
||||
.login-box label { display: block; margin-bottom: 0.25rem; font-weight: 600; }
|
||||
.login-box input { width: 100%; margin-bottom: 1rem; }
|
||||
.login-box button { width: 100%; }
|
||||
|
||||
.error { color: var(--danger); text-align: center; margin-bottom: 1rem; }
|
||||
|
||||
code { background: #f1f5f9; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.85em; }
|
||||
pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width: 300px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topnav { flex-direction: column; align-items: flex-start; }
|
||||
.logout-btn { margin-left: 0; }
|
||||
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||
table { font-size: 0.8rem; }
|
||||
th, td { padding: 0.4rem; }
|
||||
}
|
||||
14
src/admin/routes/audit.js
Normal file
14
src/admin/routes/audit.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../../config/database.js';
|
||||
import { renderAuditLog } from '../views/audit.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const entries = await db.allAsync(
|
||||
'SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 200'
|
||||
);
|
||||
res.send(renderAuditLog(entries));
|
||||
});
|
||||
|
||||
export default router;
|
||||
18
src/admin/routes/dashboard.js
Normal file
18
src/admin/routes/dashboard.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../../config/database.js';
|
||||
import { renderDashboard } from '../views/dashboard.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const [[{ totalUsers }], [{ totalProducts }], [{ totalPurchases }], [{ totalRevenue }]] = await Promise.all([
|
||||
db.allAsync('SELECT COUNT(*) as totalUsers FROM users'),
|
||||
db.allAsync('SELECT COUNT(*) as totalProducts FROM products'),
|
||||
db.allAsync('SELECT COUNT(*) as totalPurchases FROM purchases'),
|
||||
db.allAsync('SELECT COALESCE(SUM(total_price), 0) as totalRevenue FROM purchases WHERE status = ?', ['completed']),
|
||||
]);
|
||||
|
||||
res.send(renderDashboard({ totalUsers, totalProducts, totalPurchases, totalRevenue }));
|
||||
});
|
||||
|
||||
export default router;
|
||||
50
src/admin/routes/products.js
Normal file
50
src/admin/routes/products.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../../config/database.js';
|
||||
import { renderProductList, renderProductEdit } from '../views/products.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const [products, categories] = await Promise.all([
|
||||
db.allAsync(`SELECT p.*, c.name as category_name FROM products p
|
||||
LEFT JOIN categories c ON p.category_id = c.id ORDER BY p.id DESC LIMIT 100`),
|
||||
db.allAsync('SELECT id, name FROM categories ORDER BY name'),
|
||||
]);
|
||||
res.send(renderProductList(products, categories));
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const { name, price, quantity_in_stock, description, photo_url, category_id } = req.body;
|
||||
await db.runAsync(
|
||||
`INSERT INTO products (name, price, quantity_in_stock, description, photo_url, category_id, location_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, (SELECT location_id FROM categories WHERE id = ?))`,
|
||||
[name, price, quantity_in_stock || 0, description || '', photo_url || '', category_id, category_id]
|
||||
);
|
||||
res.redirect('/products');
|
||||
});
|
||||
|
||||
router.get('/:id/edit', async (req, res) => {
|
||||
const [product, categories] = await Promise.all([
|
||||
db.getAsync('SELECT * FROM products WHERE id = ?', [req.params.id]),
|
||||
db.allAsync('SELECT id, name FROM categories ORDER BY name'),
|
||||
]);
|
||||
if (!product) return res.status(404).send('Product not found');
|
||||
res.send(renderProductEdit(product, categories));
|
||||
});
|
||||
|
||||
router.post('/:id/update', async (req, res) => {
|
||||
const { name, price, quantity_in_stock, description, photo_url, category_id } = req.body;
|
||||
await db.runAsync(
|
||||
`UPDATE products SET name=?, price=?, quantity_in_stock=?, description=?, photo_url=?, category_id=?
|
||||
WHERE id=?`,
|
||||
[name, price, quantity_in_stock || 0, description || '', photo_url || '', category_id, req.params.id]
|
||||
);
|
||||
res.redirect('/products');
|
||||
});
|
||||
|
||||
router.post('/:id/delete', async (req, res) => {
|
||||
await db.runAsync('DELETE FROM products WHERE id = ?', [req.params.id]);
|
||||
res.redirect('/products');
|
||||
});
|
||||
|
||||
export default router;
|
||||
16
src/admin/routes/purchases.js
Normal file
16
src/admin/routes/purchases.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../../config/database.js';
|
||||
import { renderPurchaseList } from '../views/purchases.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const purchases = await db.allAsync(
|
||||
`SELECT p.*, pr.name as product_name FROM purchases p
|
||||
LEFT JOIN products pr ON p.product_id = pr.id
|
||||
ORDER BY p.purchase_date DESC LIMIT 200`
|
||||
);
|
||||
res.send(renderPurchaseList(purchases));
|
||||
});
|
||||
|
||||
export default router;
|
||||
32
src/admin/routes/users.js
Normal file
32
src/admin/routes/users.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../../config/database.js';
|
||||
import { renderUserList, renderUserDetail } from '../views/users.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const users = await db.allAsync('SELECT * FROM users ORDER BY id DESC LIMIT 100');
|
||||
res.send(renderUserList(users));
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const user = await db.getAsync('SELECT * FROM users WHERE id = ?', [req.params.id]);
|
||||
if (!user) return res.status(404).send('User not found');
|
||||
const purchases = await db.allAsync(
|
||||
`SELECT p.*, pr.name as product_name FROM purchases p
|
||||
LEFT JOIN products pr ON p.product_id = pr.id
|
||||
WHERE p.user_id = ? ORDER BY p.purchase_date DESC LIMIT 20`,
|
||||
[user.id]
|
||||
);
|
||||
res.send(renderUserDetail(user, purchases));
|
||||
});
|
||||
|
||||
router.post('/:id/toggle-status', async (req, res) => {
|
||||
const user = await db.getAsync('SELECT * FROM users WHERE id = ?', [req.params.id]);
|
||||
if (!user) return res.status(404).send('User not found');
|
||||
const newStatus = user.status === 1 ? 0 : 1;
|
||||
await db.runAsync('UPDATE users SET status = ? WHERE id = ?', [newStatus, user.id]);
|
||||
res.redirect('/users');
|
||||
});
|
||||
|
||||
export default router;
|
||||
14
src/admin/routes/wallets.js
Normal file
14
src/admin/routes/wallets.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../../config/database.js';
|
||||
import { renderWalletList } from '../views/wallets.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const wallets = await db.allAsync(
|
||||
'SELECT * FROM crypto_wallets ORDER BY id DESC LIMIT 200'
|
||||
);
|
||||
res.send(renderWalletList(wallets));
|
||||
});
|
||||
|
||||
export default router;
|
||||
46
src/admin/server.js
Normal file
46
src/admin/server.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import logger from '../utils/logger.js';
|
||||
import { requireAuth, handleLogin, handleLogout, renderLogin } from './auth.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
import productsRouter from './routes/products.js';
|
||||
import walletsRouter from './routes/wallets.js';
|
||||
import purchasesRouter from './routes/purchases.js';
|
||||
import auditRouter from './routes/audit.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
|
||||
app.use(cookieParser());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use('/admin/style.css', express.static(join(__dirname, 'public', 'style.css')));
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime() });
|
||||
});
|
||||
|
||||
app.get('/login', (req, res) => {
|
||||
res.send(renderLogin());
|
||||
});
|
||||
|
||||
app.post('/login', handleLogin);
|
||||
app.get('/logout', handleLogout);
|
||||
|
||||
app.use(requireAuth);
|
||||
|
||||
app.use('/', dashboardRouter);
|
||||
app.use('/users', usersRouter);
|
||||
app.use('/products', productsRouter);
|
||||
app.use('/wallets', walletsRouter);
|
||||
app.use('/purchases', purchasesRouter);
|
||||
app.use('/audit', auditRouter);
|
||||
|
||||
export function startAdminPanel() {
|
||||
const port = parseInt(process.env.ADMIN_PORT || '3001', 10);
|
||||
app.listen(port, () => {
|
||||
logger.info({ port }, 'Admin panel started');
|
||||
});
|
||||
}
|
||||
20
src/admin/views/audit.js
Normal file
20
src/admin/views/audit.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { layout, table } from './layout.js';
|
||||
|
||||
export function renderAuditLog(entries) {
|
||||
const headers = ['ID', 'Action', 'Admin ID', 'Details', 'Date'];
|
||||
const rows = entries.map(e => `<tr>
|
||||
<td>${e.id}</td>
|
||||
<td>${e.action}</td>
|
||||
<td>${e.admin_id}</td>
|
||||
<td><pre>${escapeHtml(e.details || '')}</pre></td>
|
||||
<td>${e.created_at || '-'}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const content = table(headers, entries, () => '')
|
||||
.replace('<tbody></tbody>', `<tbody>${rows}</tbody>`);
|
||||
return layout('Audit Log', content, 'audit');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
13
src/admin/views/dashboard.js
Normal file
13
src/admin/views/dashboard.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { layout, statCard } from './layout.js';
|
||||
|
||||
export function renderDashboard(stats) {
|
||||
const cards = [
|
||||
statCard('Total Users', stats.totalUsers),
|
||||
statCard('Total Products', stats.totalProducts),
|
||||
statCard('Total Purchases', stats.totalPurchases),
|
||||
statCard('Revenue', `$${(stats.totalRevenue || 0).toFixed(2)}`),
|
||||
].join('');
|
||||
|
||||
const content = `<div class="stats-grid">${cards}</div>`;
|
||||
return layout('Dashboard', content, 'dashboard');
|
||||
}
|
||||
49
src/admin/views/layout.js
Normal file
49
src/admin/views/layout.js
Normal file
@@ -0,0 +1,49 @@
|
||||
export function layout(title, content, activeTab = '') {
|
||||
const nav = [
|
||||
{ href: '/', label: 'Dashboard', id: 'dashboard' },
|
||||
{ href: '/users', label: 'Users', id: 'users' },
|
||||
{ href: '/products', label: 'Products', id: 'products' },
|
||||
{ href: '/wallets', label: 'Wallets', id: 'wallets' },
|
||||
{ href: '/purchases', label: 'Purchases', id: 'purchases' },
|
||||
{ href: '/audit', label: 'Audit Log', id: 'audit' },
|
||||
];
|
||||
|
||||
const navHtml = nav.map(n =>
|
||||
`<a href="${n.href}" class="${n.id === activeTab ? 'active' : ''}">${n.label}</a>`
|
||||
).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title} — Admin Panel</title>
|
||||
<link rel="stylesheet" href="/admin/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topnav">
|
||||
<span class="brand">Shop Admin</span>
|
||||
<div class="nav-links">${navHtml}</div>
|
||||
<a href="/logout" class="logout-btn">Logout</a>
|
||||
</nav>
|
||||
<main class="content">
|
||||
<h1>${title}</h1>
|
||||
${content}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function table(headers, rows, renderRow) {
|
||||
const thead = `<thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead>`;
|
||||
const tbody = `<tbody>${rows.map(renderRow).join('')}</tbody>`;
|
||||
return `<table>${thead}${tbody}</table>`;
|
||||
}
|
||||
|
||||
export function statCard(label, value) {
|
||||
return `<div class="stat-card"><span class="stat-value">${value}</span><span class="stat-label">${label}</span></div>`;
|
||||
}
|
||||
|
||||
export function flash(message, type = 'info') {
|
||||
return message ? `<div class="flash flash-${type}">${message}</div>` : '';
|
||||
}
|
||||
72
src/admin/views/products.js
Normal file
72
src/admin/views/products.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { layout, flash } from './layout.js';
|
||||
|
||||
export function renderProductList(products, categories, message) {
|
||||
const catOptions = categories.map(c =>
|
||||
`<option value="${c.id}">${c.name}</option>`
|
||||
).join('');
|
||||
|
||||
const rows = products.map(p => `<tr>
|
||||
<td>${p.id}</td>
|
||||
<td>${p.name}</td>
|
||||
<td>${p.category_name || '-'}</td>
|
||||
<td>$${(p.price || 0).toFixed(2)}</td>
|
||||
<td>${p.quantity_in_stock || 0}</td>
|
||||
<td>
|
||||
<a href="/products/${p.id}/edit" class="btn-sm">Edit</a>
|
||||
<form method="POST" action="/products/${p.id}/delete" style="display:inline" onsubmit="return confirm('Delete?')">
|
||||
<button class="btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const content = `${flash(message)}
|
||||
<details class="form-section">
|
||||
<summary>Add Product</summary>
|
||||
<form method="POST" action="/products" class="inline-form">
|
||||
<input name="name" placeholder="Name" required>
|
||||
<input name="price" type="number" step="0.01" placeholder="Price" required>
|
||||
<input name="quantity_in_stock" type="number" placeholder="Stock" value="0">
|
||||
<input name="description" placeholder="Description">
|
||||
<input name="photo_url" placeholder="Photo URL">
|
||||
<select name="category_id" required>
|
||||
<option value="">-- Category --</option>
|
||||
${catOptions}
|
||||
</select>
|
||||
<button type="submit" class="btn">Add</button>
|
||||
</form>
|
||||
</details>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Name</th><th>Category</th><th>Price</th><th>Stock</th><th>Actions</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="6">No products</td></tr>'}</tbody>
|
||||
</table>`;
|
||||
return layout('Products', content, 'products');
|
||||
}
|
||||
|
||||
export function renderProductEdit(product, categories) {
|
||||
const catOptions = categories.map(c =>
|
||||
`<option value="${c.id}" ${c.id === product.category_id ? 'selected' : ''}>${c.name}</option>`
|
||||
).join('');
|
||||
|
||||
const content = `
|
||||
<form method="POST" action="/products/${product.id}/update" class="form">
|
||||
<label>Name</label>
|
||||
<input name="name" value="${escapeHtml(product.name)}" required>
|
||||
<label>Price</label>
|
||||
<input name="price" type="number" step="0.01" value="${product.price}" required>
|
||||
<label>Stock</label>
|
||||
<input name="quantity_in_stock" type="number" value="${product.quantity_in_stock || 0}">
|
||||
<label>Description</label>
|
||||
<textarea name="description">${escapeHtml(product.description || '')}</textarea>
|
||||
<label>Photo URL</label>
|
||||
<input name="photo_url" value="${escapeHtml(product.photo_url || '')}">
|
||||
<label>Category</label>
|
||||
<select name="category_id" required>${catOptions}</select>
|
||||
<button type="submit" class="btn">Save</button>
|
||||
<a href="/products" class="btn btn-secondary">Cancel</a>
|
||||
</form>`;
|
||||
return layout('Edit Product', content, 'products');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
19
src/admin/views/purchases.js
Normal file
19
src/admin/views/purchases.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { layout, table } from './layout.js';
|
||||
|
||||
export function renderPurchaseList(purchases) {
|
||||
const headers = ['ID', 'User', 'Product', 'Qty', 'Price', 'Wallet', 'Date', 'Status'];
|
||||
const rows = purchases.map(p => `<tr>
|
||||
<td>${p.id}</td>
|
||||
<td><a href="/users/${p.user_id}">${p.user_id}</a></td>
|
||||
<td>${p.product_name || p.product_id}</td>
|
||||
<td>${p.quantity}</td>
|
||||
<td>$${(p.total_price || 0).toFixed(2)}</td>
|
||||
<td>${p.wallet_type || '-'}</td>
|
||||
<td>${p.purchase_date || '-'}</td>
|
||||
<td><span class="badge badge-${p.status === 'completed' ? 'active' : 'banned'}">${p.status}</span></td>
|
||||
</tr>`).join('');
|
||||
|
||||
const content = table(headers, purchases, () => '')
|
||||
.replace('<tbody></tbody>', `<tbody>${rows}</tbody>`);
|
||||
return layout('Purchases', content, 'purchases');
|
||||
}
|
||||
52
src/admin/views/users.js
Normal file
52
src/admin/views/users.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { layout, table, flash } from './layout.js';
|
||||
|
||||
export function renderUserList(users, message) {
|
||||
const headers = ['ID', 'Telegram ID', 'Username', 'Country', 'City', 'Status', 'Balance', 'Actions'];
|
||||
const rows = users.map(u => `<tr>
|
||||
<td>${u.id}</td>
|
||||
<td>${u.telegram_id}</td>
|
||||
<td>${u.username || '-'}</td>
|
||||
<td>${u.country || '-'}</td>
|
||||
<td>${u.city || '-'}</td>
|
||||
<td><span class="badge badge-${u.status === 1 ? 'active' : 'banned'}">${u.status === 1 ? 'Active' : 'Banned'}</span></td>
|
||||
<td>$${(u.total_balance || 0).toFixed(2)}</td>
|
||||
<td>
|
||||
<a href="/users/${u.id}" class="btn-sm">View</a>
|
||||
<form method="POST" action="/users/${u.id}/toggle-status" style="display:inline">
|
||||
<button class="btn-sm btn-${u.status === 1 ? 'danger' : 'success'}">${u.status === 1 ? 'Ban' : 'Unban'}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const content = `${flash(message)}${table(headers, users, () => '')}`.replace('<tbody></tbody>', `<tbody>${rows}</tbody>`);
|
||||
return layout('Users', content, 'users');
|
||||
}
|
||||
|
||||
export function renderUserDetail(user, purchases) {
|
||||
const rows = purchases.map(p => `<tr>
|
||||
<td>${p.id}</td>
|
||||
<td>${p.product_name || '-'}</td>
|
||||
<td>$${(p.total_price || 0).toFixed(2)}</td>
|
||||
<td>${p.purchase_date || '-'}</td>
|
||||
<td>${p.status || '-'}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const content = `
|
||||
<div class="detail-card">
|
||||
<h2>${user.username || 'User #' + user.id}</h2>
|
||||
<p><strong>Telegram ID:</strong> ${user.telegram_id}</p>
|
||||
<p><strong>Country:</strong> ${user.country || '-'}</p>
|
||||
<p><strong>City:</strong> ${user.city || '-'}</p>
|
||||
<p><strong>Status:</strong> ${user.status === 1 ? 'Active' : 'Banned'}</p>
|
||||
<p><strong>Balance:</strong> $${(user.total_balance || 0).toFixed(2)}</p>
|
||||
<p><strong>Bonus:</strong> $${(user.bonus_balance || 0).toFixed(2)}</p>
|
||||
</div>
|
||||
<h3>Recent Purchases</h3>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Product</th><th>Price</th><th>Date</th><th>Status</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="5">No purchases</td></tr>'}</tbody>
|
||||
</table>
|
||||
<a href="/users" class="btn">Back to Users</a>
|
||||
`;
|
||||
return layout(`User: ${user.username || user.id}`, content, 'users');
|
||||
}
|
||||
17
src/admin/views/wallets.js
Normal file
17
src/admin/views/wallets.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { layout, table } from './layout.js';
|
||||
|
||||
export function renderWalletList(wallets) {
|
||||
const headers = ['ID', 'User ID', 'Type', 'Address', 'Balance', 'Created'];
|
||||
const rows = wallets.map(w => `<tr>
|
||||
<td>${w.id}</td>
|
||||
<td><a href="/users/${w.user_id}">${w.user_id}</a></td>
|
||||
<td>${w.wallet_type}</td>
|
||||
<td><code>${(w.address || '').slice(0, 16)}...</code></td>
|
||||
<td>${(w.balance || 0).toFixed(8)}</td>
|
||||
<td>${w.created_at || '-'}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const content = table(headers, wallets, () => '')
|
||||
.replace('<tbody></tbody>', `<tbody>${rows}</tbody>`);
|
||||
return layout('Wallets', content, 'wallets');
|
||||
}
|
||||
@@ -1,43 +1,74 @@
|
||||
import sqlite3 from 'sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import logger from '../utils/logger.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
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;
|
||||
|
||||
const db = new sqlite3.Database(DB_PATH, sqlite3.OPEN_CREATE | sqlite3.OPEN_READWRITE, (err) => {
|
||||
if (err) {
|
||||
logger.error({ err }, 'Database connection error');
|
||||
let betterDb;
|
||||
|
||||
try {
|
||||
betterDb = new Database(DB_PATH);
|
||||
betterDb.pragma('journal_mode = WAL');
|
||||
betterDb.pragma('foreign_keys = ON');
|
||||
logger.info({ path: DB_PATH }, 'Connected to SQLite database (better-sqlite3)');
|
||||
} catch (err) {
|
||||
logger.fatal({ err }, 'Database connection error');
|
||||
process.exit(1);
|
||||
}
|
||||
logger.info('Connected to SQLite database');
|
||||
});
|
||||
}
|
||||
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
// Adapter: provides async interface compatible with sqlite3 callback API
|
||||
const db = {
|
||||
_betterDb: betterDb,
|
||||
|
||||
db.runAsync = (sql, params = []) =>
|
||||
new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function(err) { if (err) reject(err); else resolve(this); });
|
||||
});
|
||||
runAsync(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const stmt = betterDb.prepare(sql);
|
||||
const info = stmt.run(...(Array.isArray(params) ? params : [params]));
|
||||
resolve(info);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
db.allAsync = (sql, params = []) =>
|
||||
new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows); });
|
||||
});
|
||||
allAsync(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const stmt = betterDb.prepare(sql);
|
||||
const rows = stmt.all(...(Array.isArray(params) ? params : [params]));
|
||||
resolve(rows);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
db.getAsync = (sql, params = []) =>
|
||||
new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); });
|
||||
});
|
||||
|
||||
db.on('error', (err) => {
|
||||
logger.error({ err }, 'Database error');
|
||||
});
|
||||
getAsync(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const stmt = betterDb.prepare(sql);
|
||||
const row = stmt.get(...(Array.isArray(params) ? params : [params]));
|
||||
resolve(row || undefined);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
db.close((err) => {
|
||||
if (err) logger.error({ err }, 'Error closing database');
|
||||
else logger.info('Database connection closed');
|
||||
process.exit(err ? 1 : 0);
|
||||
});
|
||||
try {
|
||||
betterDb.close();
|
||||
logger.info('Database connection closed');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error closing database');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
export default db;
|
||||
export default db;
|
||||
17
src/index.js
17
src/index.js
@@ -1,3 +1,4 @@
|
||||
import 'dotenv/config';
|
||||
import { runMigrations, cleanUpInvalidForeignKeys } from './migrations/runner.js';
|
||||
import './router/routes.js';
|
||||
import bot from './context/bot.js';
|
||||
@@ -71,17 +72,5 @@ process.on('unhandledRejection', (error) => {
|
||||
|
||||
logger.info('Bot is running...');
|
||||
|
||||
// Health check endpoint for Docker
|
||||
import http from 'http';
|
||||
const healthServer = http.createServer((req, res) => {
|
||||
if (req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
}
|
||||
});
|
||||
healthServer.listen(3000, () => {
|
||||
logger.info({ port: 3000 }, 'Health check server started');
|
||||
});
|
||||
import { startAdminPanel } from './admin/server.js';
|
||||
startAdminPanel();
|
||||
|
||||
@@ -111,7 +111,7 @@ class PurchaseService {
|
||||
);
|
||||
|
||||
await db.runAsync('COMMIT');
|
||||
return result.lastID;
|
||||
return result.lastInsertRowid;
|
||||
} catch (error) {
|
||||
try { await db.runAsync('ROLLBACK'); } catch (_) {}
|
||||
logger.error({ err: error }, 'Error creating purchase');
|
||||
|
||||
@@ -67,7 +67,7 @@ class UserService {
|
||||
const result = await db.runAsync(query, values);
|
||||
await db.runAsync('COMMIT');
|
||||
|
||||
return result.lastID;
|
||||
return result.lastInsertRowid;
|
||||
} catch (error) {
|
||||
await db.runAsync('ROLLBACK');
|
||||
logger.error({ err: error }, 'Error creating user');
|
||||
|
||||
Reference in New Issue
Block a user