From 980b31df068e15a78e631cd53dd51ccf13201f28 Mon Sep 17 00:00:00 2001 From: shop Date: Sun, 17 May 2026 14:58:11 +0300 Subject: [PATCH] =?UTF-8?q?release:=20v1.2.0=20=E2=80=94=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=BE=D0=B3,=20email=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=B0,=20SEO,=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=20C?= =?UTF-8?q?SV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .release-notes/v1.2.0.md | 31 +++++++++ CHANGELOG.md | 30 ++++++++ README.md | 2 +- package.json | 2 +- src/middleware/rateLimit.js | 25 +++++++ src/middleware/securityHeaders.js | 9 +++ src/public/css/style.css | 102 ++++++++++++++++++++++++++++ src/routes/account.js | 10 +++ src/routes/admin.js | 55 +++++++++++++-- src/routes/auth.js | 4 ++ src/routes/seo.js | 49 +++++++++++++ src/routes/shop.js | 62 ++++++++++++++++- src/server.js | 4 ++ src/services/mail.js | 23 +++++++ src/services/recentlyViewed.js | 33 +++++++++ src/views/account/index.ejs | 35 ++++++++++ src/views/admin/orders.ejs | 18 ++++- src/views/home.ejs | 68 ++++++++++++++++++- src/views/partials/layout-end.ejs | 6 +- src/views/partials/layout-start.ejs | 3 + 20 files changed, 553 insertions(+), 18 deletions(-) create mode 100644 .release-notes/v1.2.0.md create mode 100644 src/middleware/rateLimit.js create mode 100644 src/middleware/securityHeaders.js create mode 100644 src/routes/seo.js create mode 100644 src/services/recentlyViewed.js diff --git a/.release-notes/v1.2.0.md b/.release-notes/v1.2.0.md new file mode 100644 index 0000000..678c33e --- /dev/null +++ b/.release-notes/v1.2.0.md @@ -0,0 +1,31 @@ +# v1.2.0 + +**Дата:** 2026-05-16 + +## Каталог + +- Сортировка: название, цена, новинки +- Фильтр «только со скидкой» и показ товаров без остатка +- Бейдж низкого остатка и блок «Вы недавно смотрели» + +## Заказы + +- Email-подтверждение заказа (нужен `SMTP_*` и `SITE_URL`) +- Вкладка «Заказы» в `/account` + +## Прочее + +- `robots.txt`, `sitemap.xml` +- Защита от перебора на login/register +- Админ: фильтр заказов, экспорт CSV + +## Обновление + +```bash +cd /opt/shop/shop10 # или ваш SHOP_ROOT +git pull +bash scripts/server-update.sh +# или: npm install --omit=dev && systemctl restart shop +``` + +Переменные для писем и sitemap: `SITE_URL`, `SMTP_HOST`, `SMTP_FROM`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b7586..dc3728c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [1.2.0] — 2026-05-16 + +Улучшения каталога, уведомлений и админки. + +### Каталог и UX + +- **Сортировка:** по названию, цене (↑/↓), дате добавления +- **Фильтры:** только товары со скидкой; показ позиций «нет в наличии» +- **Бейдж «Осталось N»** при остатке ≤ 5 +- **Недавно просмотренные** товары на главной (сессия, до 8 позиций) +- **Meta description** на странице товара + +### Заказы и почта + +- **Письмо после оформления** заказа (SMTP или лог в консоль) +- Вкладка **«Заказы»** в личном кабинете + +### SEO и безопасность + +- **`/robots.txt`** и **`/sitemap.xml`** +- Заголовки **X-Content-Type-Options**, **X-Frame-Options**, **Referrer-Policy** +- **Rate limit** на вход и регистрацию (429 при превышении) + +### Админка + +- **Фильтр заказов** по статусу +- **Экспорт заказов в CSV** + +[1.2.0]: https://git.evilfox.cc/test/shop10/releases/tag/v1.2.0 + ## [1.0.1] — 2026-05-17 Патч после **v1.0.0**: капча, доработка обновления из админки. diff --git a/README.md b/README.md index 174a186..956345c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Shop -**v1.0.1** — интернет-магазин на **Node.js** и **PostgreSQL 17**. +**v1.2.0** — интернет-магазин на **Node.js** и **PostgreSQL 17**. Два способа установки: [Docker Compose](#docker-compose-рекомендуется-для-теста) | [без Docker (Ubuntu)](#postgresql-17-без-docker) diff --git a/package.json b/package.json index ff2e251..c09df8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shop", - "version": "1.0.1", + "version": "1.2.0", "description": "Интернет-магазин на Node.js с PostgreSQL 17", "main": "src/server.js", "scripts": { diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js new file mode 100644 index 0000000..a1fcc56 --- /dev/null +++ b/src/middleware/rateLimit.js @@ -0,0 +1,25 @@ +const buckets = new Map(); + +function rateLimit({ windowMs = 15 * 60 * 1000, max = 20, keyPrefix = '' }) { + return (req, res, next) => { + const ip = req.ip || req.socket?.remoteAddress || 'unknown'; + const key = `${keyPrefix}:${ip}`; + const now = Date.now(); + let entry = buckets.get(key); + if (!entry || now > entry.resetAt) { + entry = { count: 0, resetAt: now + windowMs }; + buckets.set(key, entry); + } + entry.count += 1; + if (entry.count > max) { + return res.status(429).render('error', { + title: 'Слишком много запросов', + message: 'Подождите несколько минут и попробуйте снова.', + code: 429, + }); + } + next(); + }; +} + +module.exports = { rateLimit }; diff --git a/src/middleware/securityHeaders.js b/src/middleware/securityHeaders.js new file mode 100644 index 0000000..fef44c9 --- /dev/null +++ b/src/middleware/securityHeaders.js @@ -0,0 +1,9 @@ +function securityHeaders(_req, res, next) { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'SAMEORIGIN'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + next(); +} + +module.exports = { securityHeaders }; diff --git a/src/public/css/style.css b/src/public/css/style.css index e2c8b40..12ab5cd 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -1336,3 +1336,105 @@ body:has(.cookie-banner) .main { margin: 0.5rem 0 0; font-size: 0.75rem; } + +.catalog-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem 1.5rem; + margin: 0 0 1.5rem; + padding: 0.75rem 1rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 10px; +} + +.catalog-toolbar__field { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.catalog-toolbar__label { + font-size: 0.85rem; + color: var(--muted); + white-space: nowrap; +} + +.catalog-toolbar__check { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.9rem; + cursor: pointer; +} + +.card__stock-badge { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 2; + padding: 0.2rem 0.5rem; + font-size: 0.7rem; + font-weight: 600; + border-radius: 4px; + background: rgba(253, 203, 110, 0.95); + color: #2d3436; +} + +.card__stock-badge--out { + background: rgba(99, 110, 114, 0.9); + color: #fff; +} + +.card--out-of-stock { + opacity: 0.85; +} + +.card--out-of-stock .card__image { + filter: grayscale(0.4); +} + +.recently-viewed { + margin-bottom: 2rem; +} + +.recently-viewed__title { + margin: 0 0 0.75rem; + font-size: 1.1rem; +} + +.recently-viewed__grid { + display: flex; + gap: 0.75rem; + overflow-x: auto; + padding-bottom: 0.25rem; +} + +.recently-viewed__card { + flex: 0 0 120px; + padding: 0.5rem; + text-decoration: none; + color: inherit; +} + +.recently-viewed__img { + width: 100%; + height: 72px; + object-fit: cover; + border-radius: 6px; + margin-bottom: 0.35rem; +} + +.recently-viewed__name { + display: block; + font-size: 0.8rem; + line-height: 1.25; +} + +.admin-header__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} diff --git a/src/routes/account.js b/src/routes/account.js index 8e225da..caee211 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -47,11 +47,13 @@ function accountRender(res, options) { formatPrice, passkeys, isAdmin, + recentOrders, } = options; res.render('account/index', { title: 'Личный кабинет', user, orderCount, + recentOrders: recentOrders || [], reservations: reservations || [], passkeys: passkeys || [], isAdmin: Boolean(isAdmin), @@ -85,9 +87,17 @@ router.get( const passkeys = await webauthn.getCredentialsForUser(user.id); + const { rows: recentOrders } = await query( + `SELECT id, status, total_cents, created_at + FROM orders WHERE user_id = $1 + ORDER BY created_at DESC LIMIT 10`, + [user.id] + ); + accountRender(res, { user, orderCount: countResult.rows[0].n, + recentOrders, reservations, passkeys, isAdmin: user.role === ROLES.ADMIN, diff --git a/src/routes/admin.js b/src/routes/admin.js index f4cb789..22585cf 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -61,21 +61,62 @@ router.get( router.get( '/orders', asyncHandler(async (req, res) => { - const { rows: orders } = await query( - `SELECT o.id, o.status, o.total_cents, o.created_at, o.customer_name, o.customer_email, - u.email AS account_email - FROM orders o - JOIN users u ON u.id = o.user_id - ORDER BY o.created_at DESC` - ); + const statusFilter = req.query.status || ''; + const allowed = ['pending', 'paid', 'shipped', 'cancelled']; + let sql = ` + SELECT o.id, o.status, o.total_cents, o.created_at, o.customer_name, o.customer_email, + u.email AS account_email + FROM orders o + JOIN users u ON u.id = o.user_id + `; + const params = []; + if (statusFilter && allowed.includes(statusFilter)) { + sql += ' WHERE o.status = $1'; + params.push(statusFilter); + } + sql += ' ORDER BY o.created_at DESC'; + + const { rows: orders } = await query(sql, params); res.render('admin/orders', { title: 'Заказы', orders, formatPrice, + statusFilter, }); }) ); +router.get( + '/orders/export.csv', + asyncHandler(async (req, res) => { + const { rows } = await query( + `SELECT o.id, o.status, o.total_cents, o.created_at, + o.customer_name, o.customer_email, o.customer_phone, o.address + FROM orders o + ORDER BY o.created_at DESC` + ); + const esc = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; + const lines = [ + 'id;status;total_rub;customer;email;phone;address;created_at', + ...rows.map((o) => + [ + o.id, + o.status, + (o.total_cents / 100).toFixed(2), + esc(o.customer_name), + esc(o.customer_email), + esc(o.customer_phone), + esc(o.address), + new Date(o.created_at).toISOString(), + ].join(';') + ), + ]; + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename="orders.csv"'); + res.send('\uFEFF' + lines.join('\n')); + }) +); + router.post( '/orders/:id/status', asyncHandler(async (req, res) => { diff --git a/src/routes/auth.js b/src/routes/auth.js index 0d4f553..d18b28b 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -7,8 +7,10 @@ const { requireCookieConsent } = require('../middleware/cookieConsent'); const { ROLES } = require('../constants/roles'); const { asyncHandler } = require('../utils/asyncHandler'); const { verifyCaptcha } = require('../services/captcha'); +const { rateLimit } = require('../middleware/rateLimit'); const router = express.Router(); +const authRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, max: 30, keyPrefix: 'auth' }); router.use((req, res, next) => { const cart = getCart(req); @@ -25,6 +27,7 @@ router.get('/register', requireCookieConsent, (req, res) => { router.post( '/register', requireCookieConsent, + authRateLimit, asyncHandler(async (req, res) => { const { name, email, password, password2 } = req.body; const values = { name, email }; @@ -95,6 +98,7 @@ router.get('/login', requireCookieConsent, (req, res) => { router.post( '/login', requireCookieConsent, + authRateLimit, asyncHandler(async (req, res) => { const { email, password } = req.body; const next = req.body.next || '/'; diff --git a/src/routes/seo.js b/src/routes/seo.js new file mode 100644 index 0000000..99ac9f9 --- /dev/null +++ b/src/routes/seo.js @@ -0,0 +1,49 @@ +const express = require('express'); +const { query } = require('../db'); +const { siteUrl } = require('../services/mail'); +const { asyncHandler } = require('../utils/asyncHandler'); + +const router = express.Router(); + +router.get('/robots.txt', (_req, res) => { + const base = siteUrl(); + res.type('text/plain').send( + `User-agent: *\nAllow: /\nDisallow: /admin\nDisallow: /account\nSitemap: ${base}/sitemap.xml\n` + ); +}); + +router.get( + '/sitemap.xml', + asyncHandler(async (_req, res) => { + const base = siteUrl(); + const { rows: products } = await query( + `SELECT slug, created_at FROM products ORDER BY id` + ); + const urls = [ + { loc: `${base}/`, priority: '1.0' }, + { loc: `${base}/cart`, priority: '0.5' }, + ]; + for (const p of products) { + urls.push({ + loc: `${base}/product/${p.slug}`, + lastmod: new Date(p.created_at).toISOString().slice(0, 10), + priority: '0.8', + }); + } + const xml = ` + +${urls + .map( + (u) => ` + ${u.loc} + ${u.lastmod ? `${u.lastmod}` : ''} + ${u.priority} + ` + ) + .join('\n')} +`; + res.type('application/xml').send(xml); + }) +); + +module.exports = router; diff --git a/src/routes/shop.js b/src/routes/shop.js index 5fa6c75..8f429a3 100644 --- a/src/routes/shop.js +++ b/src/routes/shop.js @@ -8,6 +8,8 @@ const { buildCartPricing } = require('../services/pricing'); const productPrice = require('../utils/productPrice'); const promoService = require('../services/promo'); const loyaltyService = require('../services/loyalty'); +const recentlyViewed = require('../services/recentlyViewed'); +const { sendOrderConfirmationEmail } = require('../services/mail'); const router = express.Router(); @@ -25,21 +27,36 @@ router.use((req, res, next) => { next(); }); +const EFFECTIVE_PRICE_SQL = `CASE + WHEN p.sale_price_cents IS NOT NULL + AND p.sale_price_cents < p.price_cents + AND (p.sale_ends_at IS NULL OR p.sale_ends_at > NOW()) + THEN p.sale_price_cents + ELSE p.price_cents +END`; + router.get( '/', asyncHandler(async (req, res) => { const category = req.query.category || ''; const q = (req.query.q || '').trim(); + const sort = req.query.sort || 'name'; + const saleOnly = req.query.sale === '1'; + const showAll = req.query.all === '1'; let sql = ` - SELECT p.*, c.name AS category_name, c.slug AS category_slug + SELECT p.*, c.name AS category_name, c.slug AS category_slug, + (${EFFECTIVE_PRICE_SQL}) AS catalog_price_cents FROM products p LEFT JOIN categories c ON c.id = p.category_id - WHERE p.stock > 0 + WHERE 1=1 `; const params = []; let n = 1; + if (!showAll) { + sql += ' AND p.stock > 0'; + } if (category) { sql += ` AND c.slug = $${n++}`; params.push(category); @@ -49,10 +66,23 @@ router.get( params.push(`%${q}%`); n++; } - sql += ' ORDER BY p.name'; + if (saleOnly) { + sql += ` AND p.sale_price_cents IS NOT NULL + AND p.sale_price_cents < p.price_cents + AND (p.sale_ends_at IS NULL OR p.sale_ends_at > NOW())`; + } + + const orderMap = { + name: 'p.name ASC', + price_asc: 'catalog_price_cents ASC, p.name ASC', + price_desc: 'catalog_price_cents DESC, p.name ASC', + newest: 'p.created_at DESC', + }; + sql += ` ORDER BY ${orderMap[sort] || orderMap.name}`; const { rows: products } = await query(sql, params); const { rows: categories } = await query('SELECT * FROM categories ORDER BY name'); + const recentProducts = await recentlyViewed.loadProducts(query, req.session); res.render('home', { title: 'Каталог', @@ -60,6 +90,10 @@ router.get( categories, activeCategory: category, searchQuery: q, + sort, + saleOnly, + showAll, + recentProducts, }); }) ); @@ -84,6 +118,8 @@ router.get( }); } + recentlyViewed.pushProduct(req.session, product.id); + let userReservation = null; if (req.session.userId) { const { rows: resRows } = await query( @@ -116,8 +152,13 @@ router.get( } } + const metaDescription = + (product.description || product.name).replace(/\s+/g, ' ').trim().slice(0, 160) || + product.name; + res.render('product', { title: product.name, + metaDescription, product, userReservation, error: errorMsg, @@ -343,6 +384,21 @@ router.post( req.session.cart = {}; delete req.session.appliedPromoCode; delete req.session.loyaltyPointsToUse; + + const emailItems = items.map((item) => ({ + name: item.name, + quantity: item.quantity, + lineFormatted: formatPrice( + (item.effective_price_cents ?? item.price_cents) * item.quantity + ), + })); + sendOrderConfirmationEmail( + email.trim(), + orderId, + formatPrice(pricing.total), + emailItems + ).catch((err) => console.error('order email:', err.message)); + res.redirect(`/orders/${orderId}?success=1`); } catch (err) { await client.query('ROLLBACK'); diff --git a/src/server.js b/src/server.js index 14bd084..6fec176 100644 --- a/src/server.js +++ b/src/server.js @@ -22,6 +22,8 @@ const reservationsRoutes = require('./routes/reservations'); const passkeyRoutes = require('./routes/passkey'); const stockAlertsRoutes = require('./routes/stock-alerts'); const promoRoutes = require('./routes/promo'); +const seoRoutes = require('./routes/seo'); +const { securityHeaders } = require('./middleware/securityHeaders'); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || '0.0.0.0'; @@ -44,6 +46,8 @@ async function start() { app.set('views', path.join(__dirname, 'views')); app.use(healthRoutes); + app.use(securityHeaders); + app.use(seoRoutes); app.use(express.static(path.join(__dirname, 'public'))); app.use(express.urlencoded({ extended: true })); app.use(express.json({ limit: '64kb' })); diff --git a/src/services/mail.js b/src/services/mail.js index 1ba7e92..e2149df 100644 --- a/src/services/mail.js +++ b/src/services/mail.js @@ -68,6 +68,28 @@ async function sendReservationEmail(to, productName, quantity, expiresAt) { return sendMail({ to, subject, text, html }); } +async function sendOrderConfirmationEmail(to, orderId, totalFormatted, items) { + const orderUrl = `${siteUrl()}/orders/${orderId}`; + const subject = `Заказ #${orderId} оформлен — Shop`; + const lines = items + .map((i) => `• ${i.name} × ${i.quantity} — ${i.lineFormatted}`) + .join('\n'); + const text = `Спасибо за заказ #${orderId}!\n\n${lines}\n\nИтого: ${totalFormatted}\n\nСтатус: ${orderUrl}`; + const htmlItems = items + .map( + (i) => + `
  • ${i.name} × ${i.quantity} — ${i.lineFormatted}
  • ` + ) + .join(''); + const html = ` +

    Спасибо за покупку! Заказ #${orderId} принят.

    + +

    Итого: ${totalFormatted}

    +

    Открыть заказ в личном кабинете

    + `; + return sendMail({ to, subject, text, html }); +} + async function sendStockAvailableEmail(to, productName, productUrl) { const subject = `Снова в наличии: ${productName}`; const text = `Товар «${productName}» снова в наличии.\n\nПерейти: ${productUrl}`; @@ -85,5 +107,6 @@ module.exports = { sendPasswordResetEmail, sendReservationEmail, sendStockAvailableEmail, + sendOrderConfirmationEmail, siteUrl, }; diff --git a/src/services/recentlyViewed.js b/src/services/recentlyViewed.js new file mode 100644 index 0000000..d5bc2f2 --- /dev/null +++ b/src/services/recentlyViewed.js @@ -0,0 +1,33 @@ +const MAX = 8; + +function getList(session) { + if (!session.recentlyViewed || !Array.isArray(session.recentlyViewed)) { + session.recentlyViewed = []; + } + return session.recentlyViewed; +} + +function pushProduct(session, productId) { + const id = parseInt(productId, 10); + if (!id) return; + const list = getList(session).filter((x) => x !== id); + list.unshift(id); + session.recentlyViewed = list.slice(0, MAX); +} + +async function loadProducts(query, session) { + const ids = getList(session); + if (!ids.length) return []; + const placeholders = ids.map((_, i) => `$${i + 1}`).join(','); + const { rows } = await query( + `SELECT p.*, c.name AS category_name, c.slug AS category_slug + FROM products p + LEFT JOIN categories c ON c.id = p.category_id + WHERE p.id IN (${placeholders})`, + ids + ); + const byId = new Map(rows.map((p) => [p.id, p])); + return ids.map((id) => byId.get(id)).filter(Boolean); +} + +module.exports = { pushProduct, loadProducts, MAX }; diff --git a/src/views/account/index.ejs b/src/views/account/index.ejs index df3bee3..78e0c83 100644 --- a/src/views/account/index.ejs +++ b/src/views/account/index.ejs @@ -12,6 +12,7 @@ + <% if (activeTab === 'profile') { %> @@ -71,6 +72,40 @@ <% } %> + <% if (activeTab === 'orders') { %> + + <% } %> + <% if (activeTab === 'reservations') { %>