воркшоп · Node.js

Веб-магазин на Node.js + Express

Собираем работающий интернет-магазин: каталог, карточка товара, корзина, вход по паролю и защищённая админка, куда пускают только по роли. Не «hello world», а настоящее веб-приложение с HTTP, MVC, JWT-аутентификацией и middleware.

Как устроен воркшоп

Это воркшоп, а не лекция. Сначала — теория, которая нужна, чтобы понимать, что мы пишем, потом — код руками. Каждый кусок ты вбиваешь в свой файл и сразу проверяешь в браузере.

Что делаемЧто НЕ делаем
Пишем свой сервер с нуля, файл за файломНе клонируем готовый репозиторий и не «вайбкодим» весь проект одним промптом
Копируем маленькие куски (5–20 строк) и разбираем каждую строкуНе вставляем 300 строк, которые «просто работают»
После каждого шага открываем http://localhost:3000 и проверяемНе ждём конца урока, чтобы запустить
Ломаем нарочно (меняем роль, портим токен) и смотрим, что будетНе молчим, если 401/403/500 непонятны

Вот что получится в итоге:

Каталог товаров — главная страница магазина

Админ-панель: список товаров и форма добавления

Блок 0.1: Что вообще такое «сайт»

Прежде чем писать сервер, надо понять, что происходит, когда ты набираешь адрес и жмёшь Enter.

Немного истории

В 1989 году Тим Бернерс-Ли в лаборатории CERN предложил систему связанных документов — World Wide Web. В 1991 появился первый в мире сайт — просто текст со ссылками. Три изобретения, которые он сделал, живут до сих пор:

  • HTML — язык разметки документа;
  • HTTP — протокол, по которому документ передаётся;
  • URL — адрес документа.

Что происходит, когда ты открываешь адрес

Ты в браузере — это клиент. Где-то стоит сервер — компьютер, который вечно ждёт запросы и отдаёт ответы. Между вами — интернет. Вот полный путь одного открытия страницы:

Что происходит при открытии shop.kz

0 / 7
1URL
2DNS
3TCP + TLS
4HTTP-запрос
5Сервер думает
6HTTP-ответ
7Рендер

Нажми Start и пройди путь запроса по шагам.

Зачем нужен DNS?

Блок 0.2: HTML, CSS, JS — три разных материала

Браузер получает три вида файлов, и относится к ним по-разному. Это важно понять, чтобы позже осознанно решать, что рендерить на сервере, а что в браузере.

МатериалЧто этоКак браузер это грузит
HTMLСкелет страницы (структура, текст)Потоково (streaming): браузер начинает рисовать, не дожидаясь конца файла. Первые теги видны сразу.
CSSВнешний вид (цвета, отступы, шрифты)Блокирует рендер: пока CSS не загружен целиком, браузер не покажет страницу — чтобы не мигать нестилизованным текстом.
JSПоведение (клики, запросы, логика)Блокирует парсинг (если не async/defer): встретив <script>, браузер останавливает разбор HTML и выполняет скрипт.

Сайт против веб-приложения

Сайт (website)Веб-приложение (web app)
Показывает информацию (блог, новости, визитка)Пользователь что-то делает: логинится, кладёт в корзину, платит
Каждая страница — отдельный HTML с сервераМного логики, состояние (кто вошёл, что в корзине)
Читаешь и уходишьВзаимодействуешь: формы, кнопки, обновления без перезагрузки

Блок 0.3: HTTP и HTTPS — язык клиента и сервера

HTTP (HyperText Transfer Protocol) — это правила, по которым клиент и сервер разговаривают. Он до смешного простой: это просто текст. Запрос и ответ — обычные строки.

Запрос всегда: [метод] [путь] + заголовки + (иногда) тело. Ответ всегда: [статус-код] + заголовки + (иногда) тело.

🔍 Анатомия HTTP-запроса

Наведи на выделенный элемент — увидишь пояснение

← Запрос (Request)

GEThttp://localhost:3000/

Headers

Host: localhost:3000
Accept: text/html
Cookie: token=eyJhbGciOi...
Нет тела — GET не отправляет данные

← Ответ (Response)

200 OK

Headers

Content-Type: text/html; charset=utf-8

Body (JSON)

<!doctype html><html>...каталог...</html>

Методы: 4 глагола

Статус-коды: ответ одним числом

КодЗначитКогда увидишь
2xx (200, 201, 204)Успех200 OK — страница отдана; 201 Created — создано; 204 — успех без тела
3xx (301, 302)РедиректПосле логина сервер отвечает 302 → «иди на /»
4xx (400, 401, 403, 404)Ошибка клиента401 — ты не залогинен; 403 — залогинен, но нет прав; 404 — нет такой страницы
5xx (500)Ошибка сервераТвой код упал с исключением

Попробуй HTTP руками

Ниже — настоящий HTTP-клиент прямо в уроке. Нажми «Отправить запрос»: браузер сделает реальный запрос к публичному API и покажет живой ответ — статус, заголовки, тело.

HTTP вживую — потыкай методы

https://jsonplaceholder.typicode.com

Запрос

GEThttps://jsonplaceholder.typicode.com/posts/1

Нажми «Отправить запрос» — браузер сделает настоящий HTTP-запрос к серверу и вернёт реальный ответ.

А что добавляет «S» в HTTPS?

Пользователь залогинен как обычный user и открывает /admin. Какой статус-код логичнее вернуть?

Блок 0.4: Что такое Node.js и зачем он

JavaScript родился в браузере (1995, Брендан Айк, за 10 дней). Долго он только там и жил. В 2009 Райан Даль взял движок V8 из Google Chrome (он превращает JS в машинный код) и приделал к нему доступ к файлам и сети. Так родился Node.js — JavaScript, который работает вне браузера, на сервере. 6

JS в браузереJS в Node.js
Есть window, document, DOMНет DOM — зато есть файлы, сеть, процессы (fs, http, process)
Не может читать твой диск (безопасность)Может: читает файлы, слушает порты, ходит в БД
Задача — рисовать страницуЗадача — быть сервером: принимать запросы, отдавать ответы

Почему Node так устроен: одна нить + событийный цикл

Что Node.js взял из браузера Chrome?

Блок 0.5: MVC — как не утонуть в коде

Когда приложение растёт, весь код в одном файле превращается в кашу. MVC (Model–View–Controller) — способ разложить его по трём полкам. Мы построим магазин именно так.

СлойОтвечает заУ нас это файлы
Model (модель)Данные: где хранятся, как читать/писатьmodels/products.js, models/users.js, models/db.js
View (представление)Как данные выглядят — HTMLviews/pages.js, views/layout.js
Controller (контроллер)Склейка: взять из модели → отдать во viewcontrollers/shopController.js и др.

Пройди этот путь по шагам — увидишь, как запрос «прошивает» слои и возвращается ответом:

Запрос GET /product/2 сквозь MVC

0 / 6
1Браузер
2Router
3Controller
4Model
5View
6Ответ

Нажми Start — проследи, как запрос идёт по слоям.

MVC — не единственная архитектура

MVC удобен для сервера, который сам отдаёт HTML (как у нас). Но паттернов много — все решают одну задачу: разделить ответственность, чтобы код не превратился в один гигантский файл. Коротко, что ещё встретишь:

АрхитектураИдея в одной фразеГде встретишь
MVCModel–View–Controller — три слоя, контроллер связываетExpress, Rails, Laravel, Django (наш случай)
Layered / N-tierСлои: presentation → business logic → data. MVC — частный случайПочти любой enterprise-бэкенд
MVVMView связан с данными через биндинг, «ViewModel» синхронизируетVue, Angular (фронтенд, этот сайт)
Component-based (SPA)UI из переиспользуемых компонентов, состояние в браузереReact, Vue, Svelte
Client–Server / REST APIБэкенд отдаёт JSON, фронтенд рисует. Слои разнесены по сетиМобилки + API, SPA + backend
MicroservicesНе один сервер, а много мелких сервисов по своей задачеКрупные системы (Netflix, банки)
Hexagonal / CleanБизнес-логика в центре, БД и UI — сменные «плагины» по краямБольшие долгоживущие проекты
Event-drivenЧасти общаются через события/очереди, а не прямыми вызовамиОбработка платежей, уведомления, стримы

Блок 1: Первый сервер на Express

Хватит теории. Создаём проект.

1.1. Папка и зависимости

bash
mkdir aql-shop
cd aql-shop
npm init -y
npm install express jsonwebtoken bcryptjs cookie-parser dotenv

Открой package.json и добавь "type": "module" — чтобы писать современный JS с import:

json
{
  "name": "aql-shop",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js"
  }
}

1.2. Самый маленький сервер

Создай src/server.js:

src/server.js
1 import express2 express()3 app.get4 app.listen
...

Запусти:

bash
npm start

Открой http://localhost:3000 — увидишь заголовок. Ты только что написал веб-сервер. Когда браузер запросил /, сработала функция и вернула HTML.

Блок 2.1: Модель — наши данные

Сначала данные, потом всё остальное. Создай data/db.json:

json
{
  "products": [
    { "id": 1, "title": "Ноутбук Aqbook 14", "price": 450000, "emoji": "💻" },
    { "id": 2, "title": "Наушники SilentQ", "price": 39000, "emoji": "🎧" },
    { "id": 3, "title": "Смартфон Barys X", "price": 210000, "emoji": "📱" }
  ],
  "users": []
}

Вот как выглядят наши данные и их связи:

Данные магазина
🗄 users
🔑id int
·name text
·email text
·passwordHash text
·role text
1Nкорзина
🗄 carts
🔗user_id int
🔗product_id int
🔗 user_id → users.id 🔗 product_id → products.id
1Nтовар
🗄 products
🔑id int
·title text
·price int
·emoji text
🔑 Primary Key🔗 Foreign Key UNIQUE

2.1. Слой доступа к файлу

Создай src/models/db.js — он умеет только читать и писать весь файл:

javascript
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = join(__dirname, "..", "..", "data", "db.json");

export function readDb() {
return JSON.parse(readFileSync(DB_PATH, "utf8"));
}

export function writeDb(data) {
writeFileSync(DB_PATH, JSON.stringify(data, null, 2), "utf8");
}

2.2. Модель товаров

src/models/products.js — понятные функции поверх файла. Контроллеры зовут getAllProducts(), не зная, что внутри JSON:

javascript
import { readDb } from "./db.js";

export function getAllProducts() {
return readDb().products;
}

export function getProductById(id) {
return readDb().products.find((p) => p.id === Number(id)) ?? null;
}

Блок 2.2: View — рисуем HTML

Никакого шаблонизатора — чистый JS. Функция принимает данные и возвращает строку HTML.

2.3. Каркас страницы

src/views/layout.js:

src/views/layout.js
1 esc()2 nav(user)3 header + main
...

2.4. Страницы

src/views/pages.js:

javascript
import { layout, esc } from "./layout.js";

const tenge = (n) => `${Number(n).toLocaleString("ru-RU")} ₸`;

// user прокидываем во view, чтобы шапка (nav) знала, кто вошёл.
export function homePage(products, user) {
const cards = products
  .map(
    (p) => `<a class="card" href="/product/${p.id}">
      <div class="card-emoji">${esc(p.emoji)}</div>
      <div class="card-title">${esc(p.title)}</div>
      <div class="card-price">${tenge(p.price)}</div>
    </a>`,
  )
  .join("");
return layout({ title: "Каталог", user, body: `<h1>Каталог</h1>
  <div class="grid">${cards}</div>` });
}

export function productPage(product, user) {
return layout({ title: product.title, user, body: `
  <a href="/">← назад</a>
  <h1>${esc(product.emoji)} ${esc(product.title)}</h1>
  <p>${tenge(product.price)}</p>
  <form action="/cart/add" method="post">
    <input type="hidden" name="productId" value="${product.id}" />
    <button>🛒 В корзину</button>
  </form>` });
}

Функция productPage вернёт вот такую страницу товара с кнопкой «В корзину» (после того как добавишь стили):

Страница одного товара с кнопкой «В корзину»

Блок 2.3: Controller и роуты — оживляем каталог

Контроллер берёт данные из модели и отдаёт во view. src/controllers/shopController.js:

javascript
import { getAllProducts, getProductById } from "../models/products.js";
import { homePage, productPage } from "../views/pages.js";

export function home(req, res) {
  res.send(homePage(getAllProducts(), req.user));
}

export function productDetail(req, res) {
  const product = getProductById(req.params.id);
  if (!product) return res.status(404).send("<h1>404</h1>");
  res.send(productPage(product, req.user));
}

Теперь соберём приложение. src/server.js:

src/server.js
1 express.static2 роуты3 слушаем
...

Запусти и открой каталог — карточки кликаются, товары открываются. Работает голый HTML без стилей. Логика MVC готова.

🎨 Твоя задача: оживи внешний вид

CSS я не даю — это твой кусок работы. У тебя уже есть готовые классы в HTML (.card, .grid, .btn, .detail, nav). Создай public/style.css и сделай так, чтобы каталог выглядел как настоящий магазин.

Цель — примерно так:

Каталог со стилями — карточки в сетке

Пиши сам. Застрял — открывай подсказки по одной, они ведут от идеи к коду. Полный «продовый» style.css (с dark-темой, шрифтами, :hover-анимациями) лежит в референс-проекте — загляни туда только когда сделаешь свой вариант.

Подсказки0 / 4
Попробуй решить самостоятельно, прежде чем смотреть подсказки.

Куда Express положит "5" при запросе GET /product/5, если роут описан как "/product/:id"?

Блок 3.1: Пароли — никогда не храни в открытую

Пора впускать пользователей. Первое правило: пароль в базе хранить нельзя. Если базу украдут — утекут все пароли (а люди используют один пароль везде).

src/models/users.js:

javascript
import bcrypt from "bcryptjs";
import { readDb, writeDb } from "./db.js";

export function findUserByEmail(email) {
return readDb().users.find(
  (u) => u.email.toLowerCase() === String(email).toLowerCase(),
) ?? null;
}

export async function createUser({ name, email, password }) {
const db = readDb();
const nextId = db.users.reduce((m, u) => Math.max(m, u.id), 0) + 1;
const passwordHash = await bcrypt.hash(password, 10); // 10 = «стоимость»
const user = { id: nextId, name, email: email.toLowerCase(),
               passwordHash, role: "user", cart: [] };
db.users.push(user);
writeDb(db);
return user;
}

export async function verifyPassword(user, password) {
return bcrypt.compare(password, user.passwordHash);
}

Блок 3.2: JWT — паспорт пользователя

Пользователь ввёл пароль — сервер проверил. Но HTTP не помнит предыдущие запросы (он stateless). Как на следующем запросе понять, что это тот же вошедший человек? Ответ: выдать ему токен — подписанный «пропуск», который браузер будет присылать с каждым запросом.

Мы используем JWT (JSON Web Token) — стандарт RFC 7519. 12 Это строка из трёх частей через точку: header.payload.signature. Разбери настоящий токен:

🔓 Разбираем JWT по косточкам

Нажми на цветную часть — увидишь, что внутри

Токен = header . payload . signature

..

PAYLOAD → это просто base64, любой может прочитать!

{
  "sub": 2,
  "name": "Aruzhan",
  "role": "admin",
  "iat": 1719920000,
  "exp": 1719923600
}

⏰ exp = 1719923600 → 2024-07-02 12:33:20 UTC (когда токен протухнет)

Здесь лежат claims — факты о юзере: sub (id), role, exp. Никогда не клади сюда пароль или секрет — payload не зашифрован, а лишь закодирован.

💡 Главное: JWT не прячет данные (payload читается всеми), а защищает от подделки (подпись проверяется секретом). Секретность даёт HTTPS, целостность — подпись.

Способы аутентификации в API бывают разные — JWT в заголовке Authorization: Bearer один из самых частых:

🔑 Способы аутентификации в API

Нажми на карточку — узнай подробности

💡 В нашем боте мы используем API Key (Supabase anon key) и Bearer Token (OpenAI). Они часто идут вместе — Supabase требует оба в одном запросе.

3.2. Функции для токена

Сначала положим секрет в .env (его нельзя коммитить — добавь .env в .gitignore):

bash
JWT_SECRET=длинная-случайная-строка-поменяй-меня

src/auth.js:

javascript
import "dotenv/config"; // читает .env и кладёт всё в process.env
import jwt from "jsonwebtoken";

const JWT_SECRET = process.env.JWT_SECRET || "dev-secret-change-me";

export function signToken(user) {
// В payload — минимум: id, имя, роль. Пароль сюда НИКОГДА.
return jwt.sign(
  { sub: user.id, name: user.name, role: user.role },
  JWT_SECRET,
  { expiresIn: "2h" },
);
}

export function verifyToken(token) {
return jwt.verify(token, JWT_SECRET); // бросит ошибку, если подделан/протух
}

Блок 3.3: Middleware и роли — охрана на входе

Middleware — функция (req, res, next), которая стоит на пути запроса. Она либо пропускает дальше (next()), либо сама завершает ответ (редирект/ошибка). Из таких «прослоек» и строится вся защита.

Посмотри, как запрос от разных пользователей идёт по цепочке middleware на защищённый /admin. Отправь запрос гостем, юзером и админом — увидишь, где каждого развернут:

🛡 Кто дойдёт до /admin?

Отправь запрос от разного пользователя — смотри, где его развернут

1logger

app.use — пишет в консоль метод и путь. Всегда next().

2auth

Читает cookie с JWT. Нет токена → 401. Есть → кладёт req.user и next().

3requireRole('admin')

Смотрит req.user.role. Не admin → 403. Admin → next().

4handler

Контроллер /admin — отдаёт админ-панель.

Нажми на одного из трёх пользователей выше.

3.3. Пишем middleware

src/middleware/auth.js:

src/middleware/auth.js
1 attachUser2 requireAuth3 requireRole
...

3.4. View формы входа

Сначала — страница с формой. Добавь в src/views/pages.js:

javascript
// Форма входа. isAdmin=true — оформляем как вход в админку.
export function loginPage({ next = "/", error = "", isAdmin = false } = {}, user) {
return layout({
  title: isAdmin ? "Вход в админку" : "Вход",
  user,
  body: `<div class="auth">
    <h1>${isAdmin ? "⚙️ Вход в админку" : "Вход"}</h1>
    ${error ? `<p class="error">${esc(error)}</p>` : ""}
    <form action="/login" method="post">
      <input type="hidden" name="next" value="${esc(next)}" />
      <label>Email <input type="email" name="email" required /></label>
      <label>Пароль <input type="password" name="password" required /></label>
      <button class="btn">Войти</button>
    </form>
  </div>`,
});
}

3.5. Контроллер входа

src/controllers/authController.js:

javascript
import { findUserByEmail, verifyPassword } from "../models/users.js";
import { signToken } from "../auth.js";
import { loginPage } from "../views/pages.js";

const COOKIE_OPTS = { httpOnly: true, sameSite: "lax", maxAge: 2 * 60 * 60 * 1000 };

// GET /login — показать форму. next запоминаем, чтобы вернуть куда шли.
export function showLogin(req, res) {
const next = typeof req.query.next === "string" ? req.query.next : "/";
res.send(loginPage({ next }, req.user));
}

// GET /admin/login — та же форма, оформленная как вход в админку.
export function showAdminLogin(req, res) {
res.send(loginPage({ next: "/admin", isAdmin: true }, req.user));
}

// POST /login — проверить пароль и выдать JWT в cookie.
export async function login(req, res) {
const { email, password } = req.body;
const next = req.body.next || "/";
const user = findUserByEmail(email);
// Одинаковая ошибка на «нет юзера» и «неверный пароль» — не подсказываем врагу.
if (!user || !(await verifyPassword(user, password))) {
  return res.status(401).send(loginPage({ next, error: "Неверный email или пароль" }, null));
}
res.cookie("token", signToken(user), COOKIE_OPTS);
res.redirect(next);
}

export function logout(req, res) {
res.clearCookie("token");
res.redirect("/");
}

Форму рисует loginPage из views/pages.js (полный код — в референс-проекте; он просто возвращает <form method="post" action="/login"> с полями email и пароль).

Форма входа (/login) выглядит так — обычный HTML-<form method="post">, который шлёт email и password:

Страница входа с полями email и пароль

3.6. Админка: модель, view и контроллер

Сначала допишем модель — в src/models/products.js добавь две функции:

javascript
export function addProduct({ title, price, emoji }) {
const db = readDb();
const nextId = db.products.reduce((max, p) => Math.max(max, p.id), 0) + 1;
const product = { id: nextId, title: String(title).trim(),
                  price: Number(price), emoji: emoji || "📦" };
db.products.push(product);
writeDb(db);
return product;
}

export function deleteProduct(id) {
const db = readDb();
db.products = db.products.filter((p) => p.id !== Number(id));
writeDb(db);
}

Теперь view админки — в src/views/pages.js:

javascript
export function adminPage(products, user) {
const rows = products
  .map(
    (p) => `<tr>
      <td>${p.id}</td>
      <td>${esc(p.emoji)} ${esc(p.title)}</td>
      <td>${tenge(p.price)}</td>
      <td><form action="/admin/products/${p.id}/delete" method="post" class="inline">
        <button class="link danger">удалить</button></form></td>
    </tr>`,
  )
  .join("");
return layout({
  title: "Админка",
  user,
  body: `<h1>⚙️ Админка</h1>
    <table class="admin">
      <thead><tr><th>ID</th><th>Товар</th><th>Цена</th><th></th></tr></thead>
      <tbody>${rows}</tbody>
    </table>
    <h2>Добавить товар</h2>
    <form action="/admin/products" method="post" class="add">
      <input name="emoji" placeholder="📦" maxlength="4" />
      <input name="title" placeholder="Название" required />
      <input name="price" type="number" placeholder="Цена" required min="0" />
      <button class="btn">Добавить</button>
    </form>`,
});
}

// Пригодится для 404-ответа в server.js:
export function notFoundPage(user) {
return layout({ title: "404", user,
  body: `<h1>404 — не найдено</h1><p><a href="/">← на главную</a></p>` });
}

И, наконец, контроллер src/controllers/adminController.js:

javascript
import { getAllProducts, addProduct, deleteProduct } from "../models/products.js";
import { adminPage } from "../views/pages.js";

export function adminDashboard(req, res) {
res.send(adminPage(getAllProducts(), req.user));
}

export function createProduct(req, res) {
const { title, price, emoji } = req.body;
if (title && price) addProduct({ title, price, emoji });
res.redirect("/admin");
}

export function removeProduct(req, res) {
deleteProduct(req.params.id);
res.redirect("/admin");
}

3.7. Собираем всё вместе

Теперь всё соединяем в src/server.js. Это полный файл — с импортами. Порядок middleware важен, они выполняются сверху вниз:

src/server.js
1 импорты2 порядок!3 защита роутов
...

Блок 3.4: Корзина — где пригодился requireAuth

Корзину мы уже защитили requireAuth в роутах. Осталась модель и контроллер. Корзина живёт внутри пользователя: user.cart = [productId, ...] — так она сохраняется в db.json между запусками.

src/models/carts.js:

javascript
import { readDb, writeDb } from "./db.js";
import { getProductById } from "./products.js";

export function addToCart(userId, productId) {
  const db = readDb();
  const user = db.users.find((u) => u.id === Number(userId));
  if (!user) return;
  user.cart.push(Number(productId));
  writeDb(db);
}

export function removeFromCart(userId, productId) {
  const db = readDb();
  const user = db.users.find((u) => u.id === Number(userId));
  if (!user) return;
  const idx = user.cart.indexOf(Number(productId)); // убираем одну штуку
  if (idx !== -1) user.cart.splice(idx, 1);
  writeDb(db);
}

export function getCart(userId) {
  const db = readDb();
  const user = db.users.find((u) => u.id === Number(userId));
  if (!user) return { items: [], total: 0 };
  const items = user.cart.map((id) => getProductById(id)).filter(Boolean);
  const total = items.reduce((sum, p) => sum + p.price, 0);
  return { items, total };
}

View корзины — добавь cartPage в src/views/pages.js:

javascript
const tenge = (n) => `${Number(n).toLocaleString("ru-RU")} ₸`; // если ещё не объявлен выше

export function cartPage({ items, total }, user) {
  const rows = items
    .map(
      (p) => `<li>${esc(p.emoji)} ${esc(p.title)} — ${tenge(p.price)}
        <form action="/cart/remove" method="post" class="inline">
          <input type="hidden" name="productId" value="${p.id}" />
          <button class="link danger">✕</button>
        </form></li>`,
    )
    .join("");
  return layout({
    title: "Корзина",
    user,
    body: `<h1>Корзина</h1>
      ${
        items.length
          ? `<ul class="cart">${rows}</ul>
             <p class="total">Итого: <strong>${tenge(total)}</strong></p>`
          : `<p>Корзина пуста. <a href="/">В каталог</a></p>`
      }`,
  });
}

Контроллер в shopController.js. Обрати внимание на req.user — его положил attachUser, а requireAuth гарантировал, что он не null:

javascript
import { addToCart, removeFromCart, getCart } from "../models/carts.js";
import { cartPage } from "../views/pages.js";

export function cartView(req, res) {
  res.send(cartPage(getCart(req.user.id), req.user));
}

export function cartAdd(req, res) {
  addToCart(req.user.id, req.body.productId);
  res.redirect("/cart"); // PRG: после POST — редирект, чтобы F5 не дублировал
}

export function cartRemove(req, res) {
  removeFromCart(req.user.id, req.body.productId);
  res.redirect("/cart");
}

Эти три функции добавь в тот же shopController.js, где уже лежат home и productDetail.

Корзина: список выбранных товаров и итоговая сумма

Блок 3.5: Ломаем и проверяем

Запусти сервер и проверь все три сценария из интерактива выше — теперь по-настоящему:

ДействиеОжидаемый результат
Гость открывает /cartРедирект на /login (нет токена)
Гость открывает /adminРедирект на /login
Вошёл как aru@shop.kz, открыл /admin403 — залогинен, но роль user
Вошёл как admin@shop.kz, открыл /admin200 — админка открылась
Открыл DevTools → Application → CookiesВидишь token, помечен HttpOnly

Вот как выглядит 403, когда юзер с ролью user ломится в /admin — сервер узнал его, но не пустил:

Страница 403 Forbidden для обычного пользователя

Почему attachUser должен стоять ПОСЛЕ cookieParser в списке app.use?

Готово. Что дальше

За один день ты собрал настоящее веб-приложение: HTTP-сервер, MVC-архитектура, каталог, корзина, регистрация, вход по паролю с bcrypt, JWT в httpOnly-cookie, middleware и роли. Это база, на которой стоят почти все веб-сервисы.

Источники

  1. MDN — Critical rendering path. https://developer.mozilla.org/en-US/docs/Web/Performance/Critical_rendering_path
  2. J. J. Garrett, «Ajax: A New Approach to Web Applications», 2005.
  3. MDN — HTTP request methods. https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
  4. MDN — HTTP response status codes. https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
  5. MDN — What is HTTPS / TLS. https://developer.mozilla.org/en-US/docs/Glossary/HTTPS
  6. Node.js — About / History. https://nodejs.org/en/about
  7. Node.js — The event loop. https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
  8. npm Docs. https://docs.npmjs.com/
  9. Express — Routing & Middleware. https://expressjs.com/en/guide/routing.html
  10. OWASP — Cross Site Scripting (XSS). https://owasp.org/www-community/attacks/xss/
  11. OWASP — Password Storage Cheat Sheet (bcrypt). https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
  12. RFC 7519 — JSON Web Token (JWT). https://datatracker.ietf.org/doc/html/rfc7519
  13. OWASP — Session Management / Cookie flags. https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
  14. M. Fowler, «Patterns of Enterprise Application Architecture» (MVC, Layered, Service Layer). https://martinfowler.com/eaaCatalog/
© 2026 aqlacademy.kz