diff --git a/docker-compose.yml b/docker-compose.yml index 232fc4b..523aa8e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/src/admin/public/style.css b/src/admin/public/style.css index c20484d..6b90e8f 100644 --- a/src/admin/public/style.css +++ b/src/admin/public/style.css @@ -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)); diff --git a/src/admin/routes/settings.js b/src/admin/routes/settings.js index 4ecf7cd..034c0f5 100644 --- a/src/admin/routes/settings.js +++ b/src/admin/routes/settings.js @@ -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; \ No newline at end of file diff --git a/src/admin/views/audit.js b/src/admin/views/audit.js index 350d563..6efbaa4 100644 --- a/src/admin/views/audit.js +++ b/src/admin/views/audit.js @@ -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('', `${rows}`); return layout('Audit Log', content, 'audit'); } - -function escapeHtml(str) { - return str.replace(/&/g, '&').replace(//g, '>'); -} diff --git a/src/admin/views/categories.js b/src/admin/views/categories.js index 6ca15b9..a93368f 100644 --- a/src/admin/views/categories.js +++ b/src/admin/views/categories.js @@ -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) { `; return layout('Categories', content, 'categories'); } - -function escapeHtml(str) { - return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} diff --git a/src/admin/views/escape.js b/src/admin/views/escape.js new file mode 100644 index 0000000..f48939b --- /dev/null +++ b/src/admin/views/escape.js @@ -0,0 +1,3 @@ +export function escapeHtml(str) { + return String(str || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} \ No newline at end of file diff --git a/src/admin/views/locations.js b/src/admin/views/locations.js index 80004ba..99e905c 100644 --- a/src/admin/views/locations.js +++ b/src/admin/views/locations.js @@ -1,4 +1,5 @@ import { layout, flash } from './layout.js'; +import { escapeHtml } from './escape.js'; export function renderLocationList(locations) { const rows = locations.map(l => ` @@ -32,7 +33,3 @@ export function renderLocationList(locations) { `; return layout('Locations', content, 'locations'); } - -function escapeHtml(str) { - return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} diff --git a/src/admin/views/paymentWallets.js b/src/admin/views/paymentWallets.js index 43b6b42..6627819 100644 --- a/src/admin/views/paymentWallets.js +++ b/src/admin/views/paymentWallets.js @@ -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) {

Commission Wallet Addresses

-

These are the shop's payment wallets. Edit them in the .env file.

+

Edit wallet addresses on the Settings page.

${walletRows} @@ -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, '"'); -} diff --git a/src/admin/views/products.js b/src/admin/views/products.js index 0b0bff2..3769c08 100644 --- a/src/admin/views/products.js +++ b/src/admin/views/products.js @@ -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) { `; return layout('Edit Product', content, 'products'); } - -function escapeHtml(str) { - return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} diff --git a/src/admin/views/settings.js b/src/admin/views/settings.js index a3fbcf9..fde0729 100644 --- a/src/admin/views/settings.js +++ b/src/admin/views/settings.js @@ -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 => `${escapeHtml(id)}`).join(', ') - : 'None'; - - const walletRows = Object.entries(data.commissionWallets).map(([type, addr]) => - `` - ).join(''); - const content = ` -
-

Bot Configuration

-

Bot Token: ${maskToken(data.botToken)}

-

Admin IDs: ${idList(data.adminIds)}

-

Super Admin IDs: ${idList(data.superAdminIds)}

-
+ ${data.message ? flash(data.message, data.saved ? 'info' : 'error') : ''} + +
+
+

Bot Configuration

+
+ + + + + + + + +
+
-
-

Commission Settings

-

Commission: ${data.commissionEnabled ? 'ON' : 'OFF'}

-

Percentage: ${data.commissionPercent}%

-

Commission Wallets

-
CurrencyAddress
${type}${escapeHtml(addr || 'Not set')}
- - ${walletRows} -
TypeAddress
-
+
+

Commission Settings

+
+ + + +
+
-
-

WireGuard

-

Enabled: ${data.wgEnabled ? 'ON' : 'OFF'}

-

Endpoint: ${escapeHtml(data.wgEndpoint || 'Not set')}

-

Address: ${escapeHtml(data.wgAddress || 'Not set')}

-
+
+

Commission Wallets

+
+ + + + + + + + + + +
+
+ +
+

WireGuard

+
+ + + + + + + + + + + + + +
+
+ + + + + + + + + + +
+ +
+ `; return layout('Settings', content, 'settings'); -} - -function escapeHtml(str) { - return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} +} \ No newline at end of file diff --git a/src/index.js b/src/index.js index 155958f..f1572d5 100644 --- a/src/index.js +++ b/src/index.js @@ -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'); diff --git a/wg/start.sh b/wg/start.sh index 28a4e34..0309f1d 100644 --- a/wg/start.sh +++ b/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 "════════════════════════════════════════════════════════════════════════════════"