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') %> + + + +<%- 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) { %> + +<% } %> 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') %> diff --git a/src/views/partials/layout-start.ejs b/src/views/partials/layout-start.ejs index 52a6880..89900df 100644 --- a/src/views/partials/layout-start.ejs +++ b/src/views/partials/layout-start.ejs @@ -30,9 +30,12 @@
- <% } else { %> + <% } else if (cookieConsent) { %> Вход Регистрация + <% } else { %> + Вход + Регистрация <% } %>