feat: согласие на cookies — блокировка входа и регистрации
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
.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,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
@@ -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 || '/';
|
||||||
|
|||||||
@@ -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 { 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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
</main>
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container">
|
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
<%- include('cookie-banner') %>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user