diff --git a/package.json b/package.json
index e39e3fc..3a5b7c3 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
},
"dependencies": {
"bcryptjs": "^2.4.3",
+ "cookie-parser": "^1.4.7",
"connect-pg-simple": "^10.0.0",
"ejs": "^3.1.10",
"express": "^4.21.2",
diff --git a/src/middleware/cookieConsent.js b/src/middleware/cookieConsent.js
new file mode 100644
index 0000000..4fb9a79
--- /dev/null
+++ b/src/middleware/cookieConsent.js
@@ -0,0 +1,48 @@
+const CONSENT_COOKIE = 'cookie_consent';
+const CONSENT_VALUE = 'accepted';
+const CONSENT_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000;
+
+function hasCookieConsent(req) {
+ return req.cookies?.[CONSENT_COOKIE] === CONSENT_VALUE;
+}
+
+function loadCookieConsent(req, res, next) {
+ res.locals.cookieConsent = hasCookieConsent(req);
+ res.locals.returnTo = req.originalUrl;
+ next();
+}
+
+function requireCookieConsent(req, res, next) {
+ if (hasCookieConsent(req)) {
+ return next();
+ }
+
+ if (req.method === 'GET') {
+ return res.status(403).render('cookies-required', {
+ title: 'Согласие на cookies',
+ returnTo: req.originalUrl,
+ });
+ }
+
+ return res.redirect(
+ '/?error=' + encodeURIComponent('Примите согласие на использование cookies')
+ );
+}
+
+function setConsentCookie(res, isProduction) {
+ res.cookie(CONSENT_COOKIE, CONSENT_VALUE, {
+ maxAge: CONSENT_MAX_AGE_MS,
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: isProduction,
+ path: '/',
+ });
+}
+
+module.exports = {
+ CONSENT_COOKIE,
+ hasCookieConsent,
+ loadCookieConsent,
+ requireCookieConsent,
+ setConsentCookie,
+};
diff --git a/src/public/css/style.css b/src/public/css/style.css
index f846193..b8f0386 100644
--- a/src/public/css/style.css
+++ b/src/public/css/style.css
@@ -730,3 +730,70 @@ a:hover {
.profile-dl dd {
margin: 0;
}
+
+.cookie-banner {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 1000;
+ background: var(--surface);
+ border-top: 1px solid var(--border);
+ box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.35);
+ padding: 1rem 0;
+}
+
+.cookie-banner__inner {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.cookie-banner__text {
+ flex: 1;
+ min-width: 240px;
+}
+
+.cookie-banner__text p {
+ margin: 0 0 0.35rem;
+}
+
+.cookie-banner__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.nav__link--disabled {
+ color: var(--muted);
+ opacity: 0.6;
+ cursor: not-allowed;
+ font-size: 0.9rem;
+}
+
+.cookies-required {
+ max-width: 520px;
+ margin: 2rem auto;
+}
+
+.cookies-required h1 {
+ margin-top: 0;
+}
+
+.legal-page {
+ max-width: 640px;
+ padding: 1.5rem;
+ margin: 0 auto 2rem;
+}
+
+.legal-page h2 {
+ font-size: 1rem;
+ margin: 1.25rem 0 0.5rem;
+}
+
+body:has(.cookie-banner) .main {
+ padding-bottom: 7rem;
+}
diff --git a/src/routes/account.js b/src/routes/account.js
index 6e19568..8521b24 100644
--- a/src/routes/account.js
+++ b/src/routes/account.js
@@ -3,11 +3,14 @@ const bcrypt = require('bcryptjs');
const { query, formatPrice } = require('../db');
const { getCart, cartCount } = require('../cart');
const { requireAuth } = require('../middleware/auth');
+const { requireCookieConsent } = require('../middleware/cookieConsent');
const { ROLE_LABELS } = require('../constants/roles');
const { asyncHandler } = require('../utils/asyncHandler');
const router = express.Router();
+router.use(requireCookieConsent);
+
router.use((req, res, next) => {
const cart = getCart(req);
res.locals.cartCount = cartCount(cart);
diff --git a/src/routes/auth.js b/src/routes/auth.js
index 8e4fc74..4d7adc6 100644
--- a/src/routes/auth.js
+++ b/src/routes/auth.js
@@ -3,6 +3,7 @@ const bcrypt = require('bcryptjs');
const { query, formatPrice } = require('../db');
const { getCart, cartCount } = require('../cart');
const { requireAuth } = require('../middleware/auth');
+const { requireCookieConsent } = require('../middleware/cookieConsent');
const { ROLES } = require('../constants/roles');
const { asyncHandler } = require('../utils/asyncHandler');
@@ -15,13 +16,14 @@ router.use((req, res, next) => {
next();
});
-router.get('/register', (req, res) => {
+router.get('/register', requireCookieConsent, (req, res) => {
if (req.session.userId) return res.redirect('/account');
res.render('register', { title: 'Регистрация', error: null, values: {} });
});
router.post(
'/register',
+ requireCookieConsent,
asyncHandler(async (req, res) => {
const { name, email, password, password2 } = req.body;
const values = { name, email };
@@ -70,7 +72,7 @@ router.post(
})
);
-router.get('/login', (req, res) => {
+router.get('/login', requireCookieConsent, (req, res) => {
if (req.session.userId) return res.redirect('/account');
res.render('login', {
title: 'Вход',
@@ -82,6 +84,7 @@ router.get('/login', (req, res) => {
router.post(
'/login',
+ requireCookieConsent,
asyncHandler(async (req, res) => {
const { email, password } = req.body;
const next = req.body.next || '/';
diff --git a/src/routes/cookies.js b/src/routes/cookies.js
new file mode 100644
index 0000000..4404876
--- /dev/null
+++ b/src/routes/cookies.js
@@ -0,0 +1,24 @@
+const express = require('express');
+const { setConsentCookie } = require('../middleware/cookieConsent');
+
+const router = express.Router();
+const isProduction = process.env.NODE_ENV === 'production';
+
+router.get('/policy', (req, res) => {
+ res.render('cookies-policy', {
+ title: 'Политика cookies',
+ cookieConsent: res.locals.cookieConsent,
+ });
+});
+
+router.post('/accept', (req, res) => {
+ setConsentCookie(res, isProduction);
+ const returnTo = req.body.return_to || req.query.return_to || '/';
+ const safe =
+ typeof returnTo === 'string' && returnTo.startsWith('/') && !returnTo.startsWith('//')
+ ? returnTo
+ : '/';
+ res.redirect(safe);
+});
+
+module.exports = router;
diff --git a/src/routes/shop.js b/src/routes/shop.js
index 1f43a43..52b0cc1 100644
--- a/src/routes/shop.js
+++ b/src/routes/shop.js
@@ -2,6 +2,7 @@ const express = require('express');
const { query, pool, formatPrice } = require('../db');
const { getCart, cartCount, cartItems, cartTotal } = require('../cart');
const { requireAuth } = require('../middleware/auth');
+const { requireCookieConsent } = require('../middleware/cookieConsent');
const { asyncHandler } = require('../utils/asyncHandler');
const router = express.Router();
@@ -155,6 +156,7 @@ router.post('/cart/remove/:id', (req, res) => {
router.get(
'/checkout',
+ requireCookieConsent,
requireAuth,
asyncHandler(async (req, res) => {
const cart = getCart(req);
@@ -174,6 +176,7 @@ router.get(
router.post(
'/checkout',
+ requireCookieConsent,
requireAuth,
asyncHandler(async (req, res) => {
const cart = getCart(req);
@@ -249,6 +252,7 @@ router.post(
router.get(
'/orders',
+ requireCookieConsent,
requireAuth,
asyncHandler(async (req, res) => {
const { rows: orders } = await query(
@@ -264,6 +268,7 @@ router.get(
router.get(
'/orders/:id',
+ requireCookieConsent,
requireAuth,
asyncHandler(async (req, res) => {
const { rows } = await query(
diff --git a/src/server.js b/src/server.js
index cc1f2a7..91e1004 100644
--- a/src/server.js
+++ b/src/server.js
@@ -1,5 +1,6 @@
const path = require('path');
const express = require('express');
+const cookieParser = require('cookie-parser');
const session = require('express-session');
const pgSession = require('connect-pg-simple')(session);
@@ -7,11 +8,13 @@ const { pool, initSchema, checkConnection } = require('./db');
const { runSeed } = require('./seed');
const { seedAdmin } = require('./seed-admin');
const { loadUser } = require('./middleware/auth');
+const { loadCookieConsent } = require('./middleware/cookieConsent');
const healthRoutes = require('./routes/health');
const shopRoutes = require('./routes/shop');
const authRoutes = require('./routes/auth');
const accountRoutes = require('./routes/account');
const adminRoutes = require('./routes/admin');
+const cookiesRoutes = require('./routes/cookies');
const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0';
@@ -35,6 +38,7 @@ async function start() {
app.use(healthRoutes);
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.urlencoded({ extended: true }));
+ app.use(cookieParser());
app.use(
session({
@@ -55,7 +59,9 @@ async function start() {
})
);
+ app.use(loadCookieConsent);
app.use(loadUser);
+ app.use('/cookies', cookiesRoutes);
app.use('/', shopRoutes);
app.use('/', authRoutes);
app.use('/account', accountRoutes);
diff --git a/src/views/cookies-policy.ejs b/src/views/cookies-policy.ejs
new file mode 100644
index 0000000..1f325ba
--- /dev/null
+++ b/src/views/cookies-policy.ejs
@@ -0,0 +1,24 @@
+<%- include('partials/layout-start') %>
+
+
+ Политика использования cookies
+
+ Что такое cookies
+ Cookies — небольшие файлы, которые сайт сохраняет в браузере для корректной работы сервиса.
+
+ Какие cookies мы используем
+
+ - cookie_consent — запоминает ваш выбор (принятие политики), срок до 1 года.
+ - connect.sid — сессия: корзина, вход в аккаунт, безопасность. Удаляется после выхода или по истечении срока.
+
+
+ Без согласия
+ Вы можете просматривать каталог. Вход, регистрация и личный кабинет недоступны до нажатия «Принимаю».
+
+ Управление
+ Вы можете удалить cookies в настройках браузера. После этого потребуется снова принять политику и войти в аккаунт.
+
+ ← На главную
+
+
+<%- include('partials/layout-end') %>
diff --git a/src/views/cookies-required.ejs b/src/views/cookies-required.ejs
new file mode 100644
index 0000000..fecc923
--- /dev/null
+++ b/src/views/cookies-required.ejs
@@ -0,0 +1,15 @@
+<%- include('partials/layout-start', { returnTo: returnTo }) %>
+
+
+
Согласие на cookies
+
Для этой страницы (вход, регистрация, личный кабинет) необходимо принять использование cookies.
+
Мы сохраняем только технические cookies для сессии и безопасности.
+
+
Политика cookies
+
+
+<%- include('partials/layout-end') %>
diff --git a/src/views/partials/cookie-banner.ejs b/src/views/partials/cookie-banner.ejs
new file mode 100644
index 0000000..55ba714
--- /dev/null
+++ b/src/views/partials/cookie-banner.ejs
@@ -0,0 +1,18 @@
+<% if (!cookieConsent) { %>
+
+
+
+
Мы используем cookies
+
+ Для входа, регистрации и личного кабинета нужны технические cookies (сессия).
+ Продолжая без согласия, каталог доступен; авторизация и регистрация — нет.
+ Подробнее
+
+
+
+
+
+<% } %>
diff --git a/src/views/partials/layout-end.ejs b/src/views/partials/layout-end.ejs
index b0e1e18..d93baa4 100644
--- a/src/views/partials/layout-end.ejs
+++ b/src/views/partials/layout-end.ejs
@@ -1,8 +1,9 @@
+ <%- include('cookie-banner') %>