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:
NW
2026-06-22 10:54:01 +01:00
parent 25d8507b11
commit 4657b1dfb5
24 changed files with 1619 additions and 931 deletions

View File

@@ -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"]

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

View 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
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

View 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
View 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>` : '';
}

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

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

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

View File

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

View File

@@ -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();

View File

@@ -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');

View File

@@ -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');