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:
NW
2026-06-23 12:32:25 +01:00
parent 935c6df1dc
commit 6db770b96b
12 changed files with 199 additions and 61 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export function escapeHtml(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

View File

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

View File

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

View File

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

View File

@@ -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 &amp; Restart</button>
</div>
</form>
`;
return layout('Settings', content, 'settings');
}
function escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
}

View File

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

View File

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