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:
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 };
|
||||
Reference in New Issue
Block a user