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 = `
+
Спасибо за покупку! Заказ #${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 @@ Смена пароля Passkey Бронирования + Заказы <% if (activeTab === 'profile') { %> @@ -71,6 +72,40 @@ <% } %> + <% if (activeTab === 'orders') { %> +Заказов пока нет. Перейти в каталог
+ <% } else { %> +| № | +Дата | +Статус | +Сумма | ++ |
|---|---|---|---|---|
| #<%= o.id %> | +<%= new Date(o.created_at).toLocaleString('ru-RU') %> | +<%= statusLabels[o.status] || o.status %> | +<%= formatPrice(o.total_cents) %> | +Подробнее | +