feat: согласие на cookies — блокировка входа и регистрации
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+5
-2
@@ -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 || '/';
|
||||
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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>
|
||||
<% } %>
|
||||
@@ -1,8 +1,9 @@
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p>© <%= new Date().getFullYear() %> Shop — локальный интернет-магазин на Node.js + SQLite</p>
|
||||
<p>© <%= new Date().getFullYear() %> Shop · <a href="/cookies/policy">Cookies</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
<%- include('cookie-banner') %>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -30,9 +30,12 @@
|
||||
<form action="/logout" method="post" class="inline-form">
|
||||
<button type="submit" class="btn btn--ghost btn--sm">Выйти</button>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<% } else if (cookieConsent) { %>
|
||||
<a href="/login" class="nav__link">Вход</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>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user