feat: согласие на cookies — блокировка входа и регистрации

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shop
2026-05-17 11:32:08 +03:00
parent 14e0e875f1
commit bda73e1662
13 changed files with 222 additions and 4 deletions
+1
View File
@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"connect-pg-simple": "^10.0.0", "connect-pg-simple": "^10.0.0",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.21.2", "express": "^4.21.2",
+48
View File
@@ -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,
};
+67
View File
@@ -730,3 +730,70 @@ a:hover {
.profile-dl dd { .profile-dl dd {
margin: 0; 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;
}
+3
View File
@@ -3,11 +3,14 @@ const bcrypt = require('bcryptjs');
const { query, formatPrice } = require('../db'); const { query, formatPrice } = require('../db');
const { getCart, cartCount } = require('../cart'); const { getCart, cartCount } = require('../cart');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const { requireCookieConsent } = require('../middleware/cookieConsent');
const { ROLE_LABELS } = require('../constants/roles'); const { ROLE_LABELS } = require('../constants/roles');
const { asyncHandler } = require('../utils/asyncHandler'); const { asyncHandler } = require('../utils/asyncHandler');
const router = express.Router(); const router = express.Router();
router.use(requireCookieConsent);
router.use((req, res, next) => { router.use((req, res, next) => {
const cart = getCart(req); const cart = getCart(req);
res.locals.cartCount = cartCount(cart); res.locals.cartCount = cartCount(cart);
+5 -2
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcryptjs');
const { query, formatPrice } = require('../db'); const { query, formatPrice } = require('../db');
const { getCart, cartCount } = require('../cart'); const { getCart, cartCount } = require('../cart');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const { requireCookieConsent } = require('../middleware/cookieConsent');
const { ROLES } = require('../constants/roles'); const { ROLES } = require('../constants/roles');
const { asyncHandler } = require('../utils/asyncHandler'); const { asyncHandler } = require('../utils/asyncHandler');
@@ -15,13 +16,14 @@ router.use((req, res, next) => {
next(); next();
}); });
router.get('/register', (req, res) => { router.get('/register', requireCookieConsent, (req, res) => {
if (req.session.userId) return res.redirect('/account'); if (req.session.userId) return res.redirect('/account');
res.render('register', { title: 'Регистрация', error: null, values: {} }); res.render('register', { title: 'Регистрация', error: null, values: {} });
}); });
router.post( router.post(
'/register', '/register',
requireCookieConsent,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { name, email, password, password2 } = req.body; const { name, email, password, password2 } = req.body;
const values = { name, email }; 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'); if (req.session.userId) return res.redirect('/account');
res.render('login', { res.render('login', {
title: 'Вход', title: 'Вход',
@@ -82,6 +84,7 @@ router.get('/login', (req, res) => {
router.post( router.post(
'/login', '/login',
requireCookieConsent,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { email, password } = req.body; const { email, password } = req.body;
const next = req.body.next || '/'; const next = req.body.next || '/';
+24
View File
@@ -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;
+5
View File
@@ -2,6 +2,7 @@ const express = require('express');
const { query, pool, formatPrice } = require('../db'); const { query, pool, formatPrice } = require('../db');
const { getCart, cartCount, cartItems, cartTotal } = require('../cart'); const { getCart, cartCount, cartItems, cartTotal } = require('../cart');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const { requireCookieConsent } = require('../middleware/cookieConsent');
const { asyncHandler } = require('../utils/asyncHandler'); const { asyncHandler } = require('../utils/asyncHandler');
const router = express.Router(); const router = express.Router();
@@ -155,6 +156,7 @@ router.post('/cart/remove/:id', (req, res) => {
router.get( router.get(
'/checkout', '/checkout',
requireCookieConsent,
requireAuth, requireAuth,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const cart = getCart(req); const cart = getCart(req);
@@ -174,6 +176,7 @@ router.get(
router.post( router.post(
'/checkout', '/checkout',
requireCookieConsent,
requireAuth, requireAuth,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const cart = getCart(req); const cart = getCart(req);
@@ -249,6 +252,7 @@ router.post(
router.get( router.get(
'/orders', '/orders',
requireCookieConsent,
requireAuth, requireAuth,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { rows: orders } = await query( const { rows: orders } = await query(
@@ -264,6 +268,7 @@ router.get(
router.get( router.get(
'/orders/:id', '/orders/:id',
requireCookieConsent,
requireAuth, requireAuth,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { rows } = await query( const { rows } = await query(
+6
View File
@@ -1,5 +1,6 @@
const path = require('path'); const path = require('path');
const express = require('express'); const express = require('express');
const cookieParser = require('cookie-parser');
const session = require('express-session'); const session = require('express-session');
const pgSession = require('connect-pg-simple')(session); const pgSession = require('connect-pg-simple')(session);
@@ -7,11 +8,13 @@ const { pool, initSchema, checkConnection } = require('./db');
const { runSeed } = require('./seed'); const { runSeed } = require('./seed');
const { seedAdmin } = require('./seed-admin'); const { seedAdmin } = require('./seed-admin');
const { loadUser } = require('./middleware/auth'); const { loadUser } = require('./middleware/auth');
const { loadCookieConsent } = require('./middleware/cookieConsent');
const healthRoutes = require('./routes/health'); const healthRoutes = require('./routes/health');
const shopRoutes = require('./routes/shop'); const shopRoutes = require('./routes/shop');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const accountRoutes = require('./routes/account'); const accountRoutes = require('./routes/account');
const adminRoutes = require('./routes/admin'); const adminRoutes = require('./routes/admin');
const cookiesRoutes = require('./routes/cookies');
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0'; const HOST = process.env.HOST || '0.0.0.0';
@@ -35,6 +38,7 @@ async function start() {
app.use(healthRoutes); app.use(healthRoutes);
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use( app.use(
session({ session({
@@ -55,7 +59,9 @@ async function start() {
}) })
); );
app.use(loadCookieConsent);
app.use(loadUser); app.use(loadUser);
app.use('/cookies', cookiesRoutes);
app.use('/', shopRoutes); app.use('/', shopRoutes);
app.use('/', authRoutes); app.use('/', authRoutes);
app.use('/account', accountRoutes); app.use('/account', accountRoutes);
+24
View File
@@ -0,0 +1,24 @@
<%- include('partials/layout-start') %>
<article class="legal-page card">
<h1>Политика использования cookies</h1>
<h2>Что такое cookies</h2>
<p>Cookies — небольшие файлы, которые сайт сохраняет в браузере для корректной работы сервиса.</p>
<h2>Какие cookies мы используем</h2>
<ul>
<li><strong>cookie_consent</strong> — запоминает ваш выбор (принятие политики), срок до 1 года.</li>
<li><strong>connect.sid</strong> — сессия: корзина, вход в аккаунт, безопасность. Удаляется после выхода или по истечении срока.</li>
</ul>
<h2>Без согласия</h2>
<p>Вы можете просматривать каталог. Вход, регистрация и личный кабинет недоступны до нажатия «Принимаю».</p>
<h2>Управление</h2>
<p>Вы можете удалить cookies в настройках браузера. После этого потребуется снова принять политику и войти в аккаунт.</p>
<p><a href="/">← На главную</a></p>
</article>
<%- include('partials/layout-end') %>
+15
View File
@@ -0,0 +1,15 @@
<%- include('partials/layout-start', { returnTo: returnTo }) %>
<div class="cookies-required">
<h1>Согласие на cookies</h1>
<p>Для этой страницы (вход, регистрация, личный кабинет) необходимо принять использование cookies.</p>
<p class="muted">Мы сохраняем только технические cookies для сессии и безопасности.</p>
<form action="/cookies/accept" method="post" class="cookie-banner__actions" style="margin-top:1rem">
<input type="hidden" name="return_to" value="<%= returnTo %>">
<button type="submit" class="btn btn--primary btn--lg">Принимаю cookies</button>
<a href="/" class="btn btn--ghost">На главную</a>
</form>
<p style="margin-top:1rem"><a href="/cookies/policy">Политика cookies</a></p>
</div>
<%- include('partials/layout-end') %>
+18
View File
@@ -0,0 +1,18 @@
<% if (!cookieConsent) { %>
<div class="cookie-banner" role="dialog" aria-labelledby="cookie-banner-title" aria-live="polite">
<div class="cookie-banner__inner container">
<div class="cookie-banner__text">
<p id="cookie-banner-title"><strong>Мы используем cookies</strong></p>
<p class="muted">
Для входа, регистрации и личного кабинета нужны технические cookies (сессия).
Продолжая без согласия, каталог доступен; авторизация и регистрация — нет.
<a href="/cookies/policy">Подробнее</a>
</p>
</div>
<form action="/cookies/accept" method="post" class="cookie-banner__actions">
<input type="hidden" name="return_to" value="<%= typeof returnTo !== 'undefined' ? returnTo : '/' %>">
<button type="submit" class="btn btn--primary">Принимаю</button>
</form>
</div>
</div>
<% } %>
+2 -1
View File
@@ -1,8 +1,9 @@
</main> </main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<p>&copy; <%= new Date().getFullYear() %> Shop — локальный интернет-магазин на Node.js + SQLite</p> <p>&copy; <%= new Date().getFullYear() %> Shop · <a href="/cookies/policy">Cookies</a></p>
</div> </div>
</footer> </footer>
<%- include('cookie-banner') %>
</body> </body>
</html> </html>
+4 -1
View File
@@ -30,9 +30,12 @@
<form action="/logout" method="post" class="inline-form"> <form action="/logout" method="post" class="inline-form">
<button type="submit" class="btn btn--ghost btn--sm">Выйти</button> <button type="submit" class="btn btn--ghost btn--sm">Выйти</button>
</form> </form>
<% } else { %> <% } else if (cookieConsent) { %>
<a href="/login" class="nav__link">Вход</a> <a href="/login" class="nav__link">Вход</a>
<a href="/register" class="btn btn--primary btn--sm">Регистрация</a> <a href="/register" class="btn btn--primary btn--sm">Регистрация</a>
<% } else { %>
<span class="nav__link nav__link--disabled" title="Примите cookies">Вход</span>
<span class="nav__link nav__link--disabled" title="Примите cookies">Регистрация</span>
<% } %> <% } %>
</nav> </nav>
</div> </div>