feat: editable settings page with .env write and container restart
- Add settings form with all config fields (Bot, Commission, Wallets, WireGuard) - POST handler writes .env file and restarts container via process.exit(0) - Secrets (ENCRYPTION_KEY, ADMIN_SECRET, GITEA_TOKEN, WG_PRIVATE_KEY, WG_PRESHARED_KEY) are never sent to browser - masked placeholders used instead - PRESERVE_KEYS enforced: secret keys cannot be overwritten via form - Values sanitized: newlines stripped before writing to .env - start.sh loads .env file before node to override Docker env_file cache - Extract shared escapeHtml utility to escape.js (used by 6 view files) - Update paymentWallets view to link to Settings page instead of .env - Add .env volume mount for settings panel read/write - Fix registerRoutes() not being called in index.js (bot menu buttons)
This commit is contained in:
@@ -16,6 +16,7 @@ services:
|
||||
- ./db:/app/db/ # Синхронизация базы данных (persistence)
|
||||
- ./uploads:/app/uploads/ # Uploaded product photos
|
||||
- ./wg/start.sh:/app/start.sh # Монтируем start.sh (генерирует wg0.conf из env)
|
||||
- ./.env:/app/.env:rw # Settings panel read/write
|
||||
cap_add: # Минимальные привилегии, необходимые только для WireGuard
|
||||
- NET_ADMIN
|
||||
sysctls:
|
||||
|
||||
@@ -208,6 +208,20 @@ pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width
|
||||
|
||||
.muted { color: var(--muted); font-size: 0.85rem; }
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
|
||||
@@ -1,22 +1,95 @@
|
||||
import { Router } from 'express';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import config from '../../config/config.js';
|
||||
import { renderSettings } from '../views/settings.js';
|
||||
|
||||
const router = Router();
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = join(__dirname, '..', '..', '..');
|
||||
const envPath = join(projectRoot, '.env');
|
||||
|
||||
const PLACEHOLDER = '••••••••';
|
||||
|
||||
const PRESERVE_KEYS = new Set([
|
||||
'ENCRYPTION_KEY', 'ADMIN_SECRET', 'GITEA_TOKEN',
|
||||
'WG_PRIVATE_KEY', 'WG_PRESHARED_KEY'
|
||||
]);
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const saved = req.query.saved === '1';
|
||||
const error = req.query.error === '1';
|
||||
const message = saved ? 'Settings saved. Container restarting...' :
|
||||
error ? 'Failed to save settings.' : null;
|
||||
|
||||
const data = {
|
||||
botToken: config.BOT_TOKEN,
|
||||
adminIds: config.ADMIN_IDS,
|
||||
superAdminIds: config.SUPER_ADMIN_IDS,
|
||||
supportLink: config.SUPPORT_LINK || '',
|
||||
commissionEnabled: config.COMMISSION_ENABLED,
|
||||
commissionPercent: config.COMMISSION_PERCENT,
|
||||
commissionWallets: config.COMMISSION_WALLETS,
|
||||
wgEnabled: process.env.WG_ENABLED === 'true',
|
||||
wgEndpoint: process.env.WG_ENDPOINT || '',
|
||||
wgAddress: process.env.WG_ADDRESS || '',
|
||||
wgPublicKey: process.env.WG_PUBLIC_KEY || '',
|
||||
wgDns: process.env.WG_DNS || '',
|
||||
adminPort: process.env.ADMIN_PORT || '',
|
||||
adminUrl: process.env.ADMIN_URL || '',
|
||||
catalogPath: process.env.CATALOG_PATH || '',
|
||||
giteaApiUrl: process.env.GITEA_API_URL || '',
|
||||
encryptionKey: process.env.ENCRYPTION_KEY || '',
|
||||
adminSecret: process.env.ADMIN_SECRET || '',
|
||||
giteaToken: process.env.GITEA_TOKEN || '',
|
||||
saved,
|
||||
message,
|
||||
};
|
||||
res.send(renderSettings(data));
|
||||
});
|
||||
|
||||
export default router;
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
|
||||
const checkboxKeys = ['COMMISSION_ENABLED', 'WG_ENABLED'];
|
||||
for (const k of checkboxKeys) {
|
||||
if (!(k in body)) body[k] = 'false';
|
||||
}
|
||||
|
||||
const original = readFileSync(envPath, 'utf-8');
|
||||
const lines = original.split('\n');
|
||||
|
||||
const updated = lines.map(line => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||
|
||||
const eqIdx = line.indexOf('=');
|
||||
if (eqIdx === -1) return line;
|
||||
|
||||
const key = line.slice(0, eqIdx).trim();
|
||||
if (key in body) {
|
||||
if (PRESERVE_KEYS.has(key)) return line;
|
||||
|
||||
let val = String(body[key]).replace(/[\r\n]+/g, '');
|
||||
if (val === PLACEHOLDER) return line;
|
||||
|
||||
if (val === '') return `${key}=`;
|
||||
return `${key}=${val}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
writeFileSync(envPath, updated.join('\n'), 'utf-8');
|
||||
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
|
||||
res.redirect('/settings?saved=1');
|
||||
} catch (err) {
|
||||
console.error('Settings save error:', err);
|
||||
res.redirect('/settings?error=1');
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { layout, table } from './layout.js';
|
||||
import { escapeHtml } from './escape.js';
|
||||
|
||||
export function renderAuditLog(entries) {
|
||||
const headers = ['ID', 'Action', 'Admin ID', 'Details', 'Date'];
|
||||
@@ -14,7 +15,3 @@ export function renderAuditLog(entries) {
|
||||
.replace('<tbody></tbody>', `<tbody>${rows}</tbody>`);
|
||||
return layout('Audit Log', content, 'audit');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { layout, flash } from './layout.js';
|
||||
import { escapeHtml } from './escape.js';
|
||||
|
||||
export function renderCategoryList(categories, locations, subcategories) {
|
||||
const locOptions = locations.map(l =>
|
||||
@@ -76,7 +77,3 @@ export function renderCategoryList(categories, locations, subcategories) {
|
||||
</table>`;
|
||||
return layout('Categories', content, 'categories');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
3
src/admin/views/escape.js
Normal file
3
src/admin/views/escape.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export function escapeHtml(str) {
|
||||
return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { layout, flash } from './layout.js';
|
||||
import { escapeHtml } from './escape.js';
|
||||
|
||||
export function renderLocationList(locations) {
|
||||
const rows = locations.map(l => `<tr>
|
||||
@@ -32,7 +33,3 @@ export function renderLocationList(locations) {
|
||||
</table>`;
|
||||
return layout('Locations', content, 'locations');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { layout } from './layout.js';
|
||||
import { escapeHtml } from './escape.js';
|
||||
|
||||
export function renderPaymentWallets(data) {
|
||||
const walletRows = Object.entries(data.wallets).map(([type, addr]) =>
|
||||
@@ -17,7 +18,7 @@ export function renderPaymentWallets(data) {
|
||||
|
||||
<div class="detail-card">
|
||||
<h2>Commission Wallet Addresses</h2>
|
||||
<p class="muted">These are the shop's payment wallets. Edit them in the <code>.env</code> file.</p>
|
||||
<p class="muted">Edit wallet addresses on the <a href="/settings">Settings</a> page.</p>
|
||||
<table>
|
||||
<thead><tr><th>Currency</th><th>Address</th></tr></thead>
|
||||
<tbody>${walletRows}</tbody>
|
||||
@@ -26,7 +27,3 @@ export function renderPaymentWallets(data) {
|
||||
`;
|
||||
return layout('Payment Wallets', content, 'payment-wallets');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { layout, flash } from './layout.js';
|
||||
import { escapeHtml } from './escape.js';
|
||||
|
||||
export function renderProductList(products, categories, subcategories) {
|
||||
const catOptions = categories.map(c =>
|
||||
@@ -104,7 +105,3 @@ export function renderProductEdit(product, categories, subcategories) {
|
||||
</script>`;
|
||||
return layout('Edit Product', content, 'products');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
@@ -1,45 +1,95 @@
|
||||
import { layout } from './layout.js';
|
||||
import { layout, flash } from './layout.js';
|
||||
import { escapeHtml } from './escape.js';
|
||||
|
||||
const PLACEHOLDER = '••••••••';
|
||||
const SECRET_KEYS = new Set(['ENCRYPTION_KEY', 'ADMIN_SECRET', 'GITEA_TOKEN']);
|
||||
|
||||
function maskSecret(val) {
|
||||
return val ? PLACEHOLDER : '';
|
||||
}
|
||||
|
||||
export function renderSettings(data) {
|
||||
const maskToken = (t) => t ? t.slice(0, 10) + '***' : 'Not set';
|
||||
|
||||
const idList = (ids) => ids.length
|
||||
? ids.map(id => `<code>${escapeHtml(id)}</code>`).join(', ')
|
||||
: '<em>None</em>';
|
||||
|
||||
const walletRows = Object.entries(data.commissionWallets).map(([type, addr]) =>
|
||||
`<tr><td><strong>${type}</strong></td><td><code>${escapeHtml(addr || 'Not set')}</code></td></tr>`
|
||||
).join('');
|
||||
|
||||
const content = `
|
||||
<div class="detail-card">
|
||||
<h2>Bot Configuration</h2>
|
||||
<p><strong>Bot Token:</strong> <code>${maskToken(data.botToken)}</code></p>
|
||||
<p><strong>Admin IDs:</strong> ${idList(data.adminIds)}</p>
|
||||
<p><strong>Super Admin IDs:</strong> ${idList(data.superAdminIds)}</p>
|
||||
</div>
|
||||
${data.message ? flash(data.message, data.saved ? 'info' : 'error') : ''}
|
||||
<form method="POST" action="/settings">
|
||||
<div class="settings-grid">
|
||||
<div class="detail-card">
|
||||
<h2>Bot Configuration</h2>
|
||||
<div class="form">
|
||||
<label>Bot Token</label>
|
||||
<input type="password" name="BOT_TOKEN" value="${escapeHtml(data.botToken)}" autocomplete="off" placeholder="Enter new token to change">
|
||||
<label>Admin IDs (comma-separated)</label>
|
||||
<input type="text" name="ADMIN_IDS" value="${escapeHtml(data.adminIds.join(','))}">
|
||||
<label>Super Admin IDs (comma-separated)</label>
|
||||
<input type="text" name="SUPER_ADMIN_IDS" value="${escapeHtml(data.superAdminIds.join(','))}">
|
||||
<label>Support Link</label>
|
||||
<input type="text" name="SUPPORT_LINK" value="${escapeHtml(data.supportLink)}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<h2>Commission Settings</h2>
|
||||
<p><strong>Commission:</strong> <span class="badge badge-${data.commissionEnabled ? 'active' : 'banned'}">${data.commissionEnabled ? 'ON' : 'OFF'}</span></p>
|
||||
<p><strong>Percentage:</strong> ${data.commissionPercent}%</p>
|
||||
<h3>Commission Wallets</h3>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Address</th></tr></thead>
|
||||
<tbody>${walletRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<h2>Commission Settings</h2>
|
||||
<div class="form">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="COMMISSION_ENABLED" value="true" ${data.commissionEnabled ? 'checked' : ''}>
|
||||
Commission Enabled
|
||||
</label>
|
||||
<label>Commission Percent</label>
|
||||
<input type="number" name="COMMISSION_PERCENT" value="${data.commissionPercent}" min="0" max="100" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<h2>WireGuard</h2>
|
||||
<p><strong>Enabled:</strong> <span class="badge badge-${data.wgEnabled ? 'active' : 'banned'}">${data.wgEnabled ? 'ON' : 'OFF'}</span></p>
|
||||
<p><strong>Endpoint:</strong> <code>${escapeHtml(data.wgEndpoint || 'Not set')}</code></p>
|
||||
<p><strong>Address:</strong> <code>${escapeHtml(data.wgAddress || 'Not set')}</code></p>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<h2>Commission Wallets</h2>
|
||||
<div class="form">
|
||||
<label>BTC</label>
|
||||
<input type="text" name="COMMISSION_WALLET_BTC" value="${escapeHtml(data.commissionWallets.BTC)}">
|
||||
<label>LTC</label>
|
||||
<input type="text" name="COMMISSION_WALLET_LTC" value="${escapeHtml(data.commissionWallets.LTC)}">
|
||||
<label>USDT</label>
|
||||
<input type="text" name="COMMISSION_WALLET_USDT" value="${escapeHtml(data.commissionWallets.USDT)}">
|
||||
<label>USDC</label>
|
||||
<input type="text" name="COMMISSION_WALLET_USDC" value="${escapeHtml(data.commissionWallets.USDC)}">
|
||||
<label>ETH</label>
|
||||
<input type="text" name="COMMISSION_WALLET_ETH" value="${escapeHtml(data.commissionWallets.ETH)}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<h2>WireGuard</h2>
|
||||
<div class="form">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="WG_ENABLED" value="true" ${data.wgEnabled ? 'checked' : ''}>
|
||||
WireGuard Enabled
|
||||
</label>
|
||||
<label>Endpoint</label>
|
||||
<input type="text" name="WG_ENDPOINT" value="${escapeHtml(data.wgEndpoint)}">
|
||||
<label>Address</label>
|
||||
<input type="text" name="WG_ADDRESS" value="${escapeHtml(data.wgAddress)}">
|
||||
<label>Private Key</label>
|
||||
<input type="password" name="WG_PRIVATE_KEY" placeholder="Enter new key to change" autocomplete="off">
|
||||
<label>Public Key</label>
|
||||
<input type="text" name="WG_PUBLIC_KEY" value="${escapeHtml(data.wgPublicKey)}">
|
||||
<label>Preshared Key</label>
|
||||
<input type="password" name="WG_PRESHARED_KEY" placeholder="Enter new key to change" autocomplete="off">
|
||||
<label>DNS</label>
|
||||
<input type="text" name="WG_DNS" value="${escapeHtml(data.wgDns)}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="ENCRYPTION_KEY" value="${maskSecret(data.encryptionKey)}">
|
||||
<input type="hidden" name="ADMIN_SECRET" value="${maskSecret(data.adminSecret)}">
|
||||
<input type="hidden" name="GITEA_TOKEN" value="${maskSecret(data.giteaToken)}">
|
||||
<input type="hidden" name="ADMIN_PORT" value="${escapeHtml(data.adminPort)}">
|
||||
<input type="hidden" name="ADMIN_URL" value="${escapeHtml(data.adminUrl)}">
|
||||
<input type="hidden" name="CATALOG_PATH" value="${escapeHtml(data.catalogPath)}">
|
||||
<input type="hidden" name="GITEA_API_URL" value="${escapeHtml(data.giteaApiUrl)}">
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
<button type="submit" class="btn btn-success">Save Settings & Restart</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
return layout('Settings', content, 'settings');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dotenv/config';
|
||||
import { runMigrations, cleanUpInvalidForeignKeys } from './migrations/runner.js';
|
||||
import './router/routes.js';
|
||||
import { registerRoutes } from './router/routes.js';
|
||||
import bot from './context/bot.js';
|
||||
import ErrorHandler from './utils/errorHandler.js';
|
||||
import logger from './utils/logger.js';
|
||||
@@ -14,6 +14,7 @@ import { initStates } from './services/stateService.js';
|
||||
await runMigrations();
|
||||
await cleanUpInvalidForeignKeys();
|
||||
await initStates();
|
||||
registerRoutes();
|
||||
|
||||
const logDebug = (action, functionName) => {
|
||||
logger.debug({ action, functionName }, 'Button Press');
|
||||
|
||||
11
wg/start.sh
11
wg/start.sh
@@ -1,5 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Load .env file into environment (overrides Docker env_file cached values)
|
||||
if [ -f /app/.env ]; then
|
||||
while IFS='=' read -r key value; do
|
||||
key=$(echo "$key" | xargs)
|
||||
case "$key" in
|
||||
''|'#'*) continue ;;
|
||||
esac
|
||||
export "$key=$value"
|
||||
done < /app/.env
|
||||
fi
|
||||
|
||||
# Функция для отображения разделителя
|
||||
print_separator() {
|
||||
echo "════════════════════════════════════════════════════════════════════════════════"
|
||||
|
||||
Reference in New Issue
Block a user