diff --git a/.env.example b/.env.example index d362411..4dba4e8 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,11 @@ SMTP_USER= SMTP_PASS= SMTP_FROM=shop@example.com +# Обновление из админки (/admin/system) +# SHOP_ROOT=/opt/shop +# ADMIN_UPDATE_ENABLED=1 +# ADMIN_UPDATE_USE_SUDO=1 + # PostgreSQL 17 (одна строка или отдельные переменные) DATABASE_URL=postgresql://shop:shop@127.0.0.1:5432/shop # PGHOST=127.0.0.1 diff --git a/scripts/admin-web-update.sh b/scripts/admin-web-update.sh new file mode 100644 index 0000000..cbe68d9 --- /dev/null +++ b/scripts/admin-web-update.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Обновление кода из админки (git pull + npm + перезапуск shop) +# Запуск: bash scripts/admin-web-update.sh +# С www-data часто нужен sudoers: NOPASSWD на этот скрипт (ADMIN_UPDATE_USE_SUDO=1) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=shop-root.sh +source "$SCRIPT_DIR/shop-root.sh" + +RUN_USER="${SHOP_RUN_USER:-www-data}" +PORT="${PORT:-3000}" + +run_in_shop() { + local cmd="$1" + if [ "$(id -u)" -eq 0 ] && [ "$(whoami)" != "$RUN_USER" ]; then + sudo -u "$RUN_USER" env SHOP_ROOT="$SHOP_ROOT" bash -c "cd \"$SHOP_ROOT\" && $cmd" + else + bash -c "cd \"$SHOP_ROOT\" && $cmd" + fi +} + +git config --global --add safe.directory "$SHOP_ROOT" 2>/dev/null || true + +echo "=== Обновление Shop (админка) ===" +echo "Каталог: $SHOP_ROOT" +echo "Пользователь для git/npm: $(id -un 2>/dev/null || echo ?)" + +if [ ! -d .git ]; then + echo "Ошибка: нет .git в $SHOP_ROOT" + exit 1 +fi + +echo "" +echo "Текущая версия:" +git log -1 --oneline || true + +echo "" +echo "--- git sync ---" +run_in_shop "bash scripts/git-sync.sh" + +echo "" +echo "--- npm install ---" +run_in_shop "npm install --omit=dev" + +echo "" +echo "Новая версия:" +git log -1 --oneline + +echo "" +echo "--- перезапуск shop ---" +if command -v systemctl >/dev/null 2>&1 && systemctl cat shop.service >/dev/null 2>&1; then + if systemctl restart shop; then + sleep 2 + if curl -sf "http://127.0.0.1:${PORT}/health" >/dev/null; then + echo "OK: служба shop перезапущена, /health отвечает" + else + echo "WARN: shop перезапущен, но /health не ответил — journalctl -u shop -n 40" + fi + else + echo "WARN: systemctl restart shop не удался. Выполните от root: systemctl restart shop" + exit 1 + fi +else + echo "INFO: служба shop не найдена — перезапустите Node вручную (pm2/npm start)" +fi + +echo "" +echo "Готово." diff --git a/src/public/css/style.css b/src/public/css/style.css index 4a71822..a9f51b8 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -1257,3 +1257,53 @@ body:has(.cookie-banner) .main { font-size: 0.85rem; color: var(--muted); } + +.badge--warn { + background: rgba(253, 203, 110, 0.15); + color: var(--warn); +} + +.admin-system__meta { + margin-bottom: 1rem; +} + +.admin-system__path { + font-size: 0.85rem; + word-break: break-all; +} + +.admin-system__actions { + margin-top: 1rem; +} + +.admin-system__hr { + border: none; + border-top: 1px solid var(--border); + margin: 1.5rem 0; +} + +.admin-system__form { + max-width: 360px; +} + +.admin-system__pre { + margin: 0; + padding: 1rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 0.8rem; + line-height: 1.45; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.admin-system__log { + margin-top: 1.25rem; +} + +.admin-system__help { + margin-top: 1.25rem; + font-size: 0.9rem; +} diff --git a/src/routes/admin.js b/src/routes/admin.js index 6f8d347..f4cb789 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -4,6 +4,7 @@ const { requireAdmin } = require('../middleware/auth'); const { asyncHandler } = require('../utils/asyncHandler'); const { ROLE_LABELS } = require('../constants/roles'); const { notifyIfBackInStock } = require('../services/stock-alerts'); +const gitDeploy = require('../services/git-deploy'); const router = express.Router(); @@ -349,4 +350,69 @@ router.post( }) ); +router.get( + '/system', + asyncHandler(async (req, res) => { + const fetchRemote = + req.query.checked === '1' || req.query.done === '1' || req.query.failed === '1'; + + let updateLog = null; + let updateOk = false; + let updateFail = false; + let updateCode = null; + + if (req.query.done === '1' || req.query.failed === '1') { + updateLog = req.session.adminUpdateLog || null; + updateOk = req.session.adminUpdateOk === true; + updateFail = req.session.adminUpdateOk === false; + updateCode = req.session.adminUpdateCode ?? null; + delete req.session.adminUpdateLog; + delete req.session.adminUpdateOk; + delete req.session.adminUpdateCode; + } + + const git = await gitDeploy.getGitInfo({ fetchRemote: !!fetchRemote }); + res.render('admin/system', { + title: 'Обновление', + git, + updateLog, + updateOk, + updateFail, + updateCode, + confirmError: req.query.error === 'confirm', + disabledError: req.query.error === 'disabled', + }); + }) +); + +router.post( + '/system/check', + asyncHandler(async (req, res) => { + res.redirect('/admin/system?checked=1'); + }) +); + +router.post( + '/system/update', + asyncHandler(async (req, res) => { + if (!gitDeploy.isUpdateEnabled()) { + return res.redirect('/admin/system?error=disabled'); + } + const confirm = (req.body.confirm || '').trim().toLowerCase(); + if (confirm !== 'update') { + return res.redirect('/admin/system?error=confirm'); + } + + const result = await gitDeploy.runDeployUpdate(); + req.session.adminUpdateLog = result.output; + req.session.adminUpdateOk = result.ok; + req.session.adminUpdateCode = result.code; + + if (result.ok) { + return res.redirect('/admin/system?done=1'); + } + return res.redirect('/admin/system?failed=1'); + }) +); + module.exports = router; diff --git a/src/services/git-deploy.js b/src/services/git-deploy.js new file mode 100644 index 0000000..3f5ccaa --- /dev/null +++ b/src/services/git-deploy.js @@ -0,0 +1,151 @@ +const { execFile, spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { promisify } = require('util'); + +const execFileAsync = promisify(execFile); + +function resolveShopRoot() { + const candidates = [ + process.env.SHOP_ROOT, + path.resolve(__dirname, '../..'), + '/opt/shop', + ].filter(Boolean); + + for (const dir of candidates) { + const resolved = path.resolve(dir); + if (fs.existsSync(path.join(resolved, 'package.json'))) { + return resolved; + } + } + return null; +} + +function isUpdateEnabled() { + if (process.env.ADMIN_UPDATE_ENABLED === '0') return false; + const root = resolveShopRoot(); + if (!root) return false; + if (!fs.existsSync(path.join(root, '.git'))) return false; + if (process.platform === 'win32') return false; + return fs.existsSync(path.join(root, 'scripts', 'admin-web-update.sh')); +} + +async function gitCmd(args, cwd) { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd, + maxBuffer: 1024 * 1024, + timeout: 90000, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + return `${stdout}${stderr}`.trim(); +} + +async function getGitInfo({ fetchRemote = false } = {}) { + const root = resolveShopRoot(); + const pkg = root ? JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) : null; + + if (!root || !fs.existsSync(path.join(root, '.git'))) { + return { + available: false, + packageVersion: pkg?.version || null, + shopRoot: root, + reason: 'Каталог не является git-репозиторием. Задайте SHOP_ROOT в .env.', + }; + } + + const info = { + available: true, + shopRoot: root, + packageVersion: pkg?.version || null, + branch: null, + commit: null, + commitShort: null, + commitSubject: null, + commitDate: null, + dirty: false, + behind: null, + updateEnabled: isUpdateEnabled(), + platform: process.platform, + }; + + try { + info.branch = await gitCmd(['branch', '--show-current'], root); + if (!info.branch) { + info.branch = '(detached)'; + info.commitShort = await gitCmd(['rev-parse', '--short', 'HEAD'], root); + } + info.commit = await gitCmd(['rev-parse', 'HEAD'], root); + info.commitShort = info.commitShort || (await gitCmd(['rev-parse', '--short', 'HEAD'], root)); + info.commitSubject = await gitCmd(['log', '-1', '--pretty=%s'], root); + info.commitDate = await gitCmd(['log', '-1', '--pretty=%ci'], root); + const status = await gitCmd(['status', '--porcelain'], root); + info.dirty = status.length > 0; + } catch (err) { + info.available = false; + info.reason = err.message; + return info; + } + + if (fetchRemote) { + try { + await gitCmd(['fetch', 'origin'], root); + const behind = await gitCmd( + ['rev-list', '--count', 'HEAD..origin/main'], + root + ); + info.behind = parseInt(behind, 10) || 0; + } catch (err) { + info.fetchError = err.message; + } + } + + return info; +} + +function runDeployUpdate() { + const root = resolveShopRoot(); + const scriptPath = path.join(root, 'scripts', 'admin-web-update.sh'); + + return new Promise((resolve) => { + const useSudo = process.env.ADMIN_UPDATE_USE_SUDO === '1'; + const cmd = useSudo ? 'sudo' : 'bash'; + const args = useSudo ? ['-n', scriptPath] : [scriptPath]; + + const child = spawn(cmd, args, { + cwd: root, + env: { ...process.env, SHOP_ROOT: root }, + timeout: 300000, + }); + + let output = ''; + child.stdout.on('data', (chunk) => { + output += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + output += chunk.toString(); + }); + + child.on('error', (err) => { + resolve({ + ok: false, + code: -1, + output: `${output}\n${err.message}`.trim(), + }); + }); + + child.on('close', (code) => { + resolve({ + ok: code === 0, + code: code ?? 1, + output: output.trim() || '(нет вывода)', + }); + }); + }); +} + +module.exports = { + resolveShopRoot, + isUpdateEnabled, + getGitInfo, + runDeployUpdate, +}; diff --git a/src/views/admin/dashboard.ejs b/src/views/admin/dashboard.ejs index 9105320..4fd38d2 100644 --- a/src/views/admin/dashboard.ejs +++ b/src/views/admin/dashboard.ejs @@ -2,17 +2,15 @@

Админ-панель

- + <%- include('../partials/admin-nav', { adminNav: 'dashboard' }) %>
+
+

Обновление с Git

+

Подтянуть новую версию и перезапустить магазин без SSH.

+ Перейти к обновлению → +
+
Пользователи diff --git a/src/views/admin/orders.ejs b/src/views/admin/orders.ejs index 516a4c7..6ba2d4e 100644 --- a/src/views/admin/orders.ejs +++ b/src/views/admin/orders.ejs @@ -2,15 +2,7 @@

Заказы

- + <%- include('../partials/admin-nav', { adminNav: 'orders' }) %>
<% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %> diff --git a/src/views/admin/products.ejs b/src/views/admin/products.ejs index cdcd298..9f597b0 100644 --- a/src/views/admin/products.ejs +++ b/src/views/admin/products.ejs @@ -2,15 +2,7 @@

Товары — цены и скидки

- + <%- include('../partials/admin-nav', { adminNav: 'products' }) %>
<% if (stockUpdated) { %> diff --git a/src/views/admin/promo-codes.ejs b/src/views/admin/promo-codes.ejs index 2a371e1..3855035 100644 --- a/src/views/admin/promo-codes.ejs +++ b/src/views/admin/promo-codes.ejs @@ -2,15 +2,7 @@

Промокоды и скидки

- + <%- include('../partials/admin-nav', { adminNav: 'promo' }) %>
<% if (created) { %>

Промокод создан

<% } %> diff --git a/src/views/admin/reservations.ejs b/src/views/admin/reservations.ejs index 8920da2..acea90a 100644 --- a/src/views/admin/reservations.ejs +++ b/src/views/admin/reservations.ejs @@ -2,15 +2,7 @@

Бронирования

- + <%- include('../partials/admin-nav', { adminNav: 'reservations' }) %>
<% const resStatus = { active: 'Активна', fulfilled: 'Выполнена', cancelled: 'Отменена', expired: 'Истекла' }; %> diff --git a/src/views/admin/system.ejs b/src/views/admin/system.ejs new file mode 100644 index 0000000..5df360d --- /dev/null +++ b/src/views/admin/system.ejs @@ -0,0 +1,109 @@ +<%- include('../partials/layout-start') %> + +
+

Обновление с Git

+ <%- include('../partials/admin-nav', { adminNav: 'system' }) %> +
+ +<% if (updateOk) { %> +

Обновление выполнено успешно. Если сайт не открывается — подождите 10–20 сек и обновите страницу.

+<% } %> +<% if (updateFail) { %> +

Обновление завершилось с ошибкой (код <%= updateCode %>).

+<% } %> +<% if (confirmError) { %> +

Введите update для подтверждения.

+<% } %> +<% if (disabledError) { %> +

Обновление из админки отключено на этом сервере.

+<% } %> + +
+

Текущая версия

+ <% if (!git.available) { %> +

<%= git.reason || 'Git недоступен' %>

+ <% } else { %> +
+
Версия приложения
+
v<%= git.packageVersion || '?' %>
+
Каталог
+
<%= git.shopRoot %>
+
Ветка
+
<%= git.branch %>
+
Коммит
+
+ <%= git.commitShort %> + — <%= git.commitSubject %> + (<%= git.commitDate %>) +
+ <% if (git.dirty) { %> +
Состояние
+
Есть незакоммиченные изменения
+ <% } %> + <% if (git.behind != null) { %> +
На origin/main
+
+ <% if (git.behind > 0) { %> + Доступно обновлений: <%= git.behind %> + <% } else { %> + Актуально + <% } %> +
+ <% } %> + <% if (git.fetchError) { %> +
origin
+
Не удалось проверить: <%= git.fetchError %>
+ <% } %> +
+ <% } %> + +
+
+ +
+
+ + <% if (git.updateEnabled) { %> +
+

Обновить сейчас

+

+ Выполняется git pull, npm install и перезапуск службы shop. + Страница может оборваться на несколько секунд — это нормально. +

+
+ + +
+ <% } else if (git.available) { %> +

+ Обновление из админки отключено (Windows, нет .git или ADMIN_UPDATE_ENABLED=0). + На сервере: bash "$SHOP_ROOT/scripts/server-update.sh" +

+ <% } %> +
+ +<% if (updateLog) { %> +
+

Журнал обновления

+
<%= updateLog %>
+
+<% } %> + +
+

Настройка сервера

+

В .env: SHOP_ROOT=/opt/shop, ADMIN_UPDATE_ENABLED=1.

+

Если служба работает от www-data, добавьте в sudoers (от root):

+
www-data ALL=(root) NOPASSWD: <%= git.shopRoot || '/opt/shop' %>/scripts/admin-web-update.sh
+

И в .env: ADMIN_UPDATE_USE_SUDO=1

+
+ +<%- include('../partials/layout-end') %> diff --git a/src/views/admin/users.ejs b/src/views/admin/users.ejs index a1dbacd..1b0044f 100644 --- a/src/views/admin/users.ejs +++ b/src/views/admin/users.ejs @@ -2,15 +2,7 @@

Пользователи

- + <%- include('../partials/admin-nav', { adminNav: 'users' }) %>

diff --git a/src/views/partials/admin-nav.ejs b/src/views/partials/admin-nav.ejs new file mode 100644 index 0000000..a8e612a --- /dev/null +++ b/src/views/partials/admin-nav.ejs @@ -0,0 +1,16 @@ +<% + const nav = typeof adminNav !== 'undefined' ? adminNav : ''; + function navClass(id) { + return 'admin-nav__link' + (nav === id ? ' admin-nav__link--active' : ''); + } +%> +

diff --git a/src/views/partials/icon.ejs b/src/views/partials/icon.ejs index be3045e..586b490 100644 --- a/src/views/partials/icon.ejs +++ b/src/views/partials/icon.ejs @@ -20,6 +20,10 @@ <% } else if (name === 'arrow-left') { %> +<% } else if (name === 'refresh') { %> + +<% } else if (name === 'download') { %> + <% } else if (name === 'package') { %> <% } %>