Разработка клиент-серверного приложения с помощью Next.js и TypeScript. Часть 1. Настройка проекта и разработка сервера

byf9qu-qkjvc3cpega2osikmrcq.png


Привет, друзья!

В этой серии из 2 статей-туториалов мы с вами разработаем клиент-серверное (фуллстек — fullstack) приложение с помощью Next.js и TypeScript.


  1. Наше приложение будет представлять собой блог — относительно полноценную платформу для публикации, редактирования и удаления постов.
  2. Мы реализуем собственный сервис аутентификации на основе JSON Web Tokens и HTTP-куки.
  3. Данные пользователей и постов будут храниться в реляционной базе данных SQLite.

В первом туториале мы подготовим и настроим проект, а также реализуем серверную часть приложения с помощью интерфейса роутов (API Routes), во втором — разработаем клиента и проверим работоспособность приложения.

Обратите внимание: данный туториал рассчитан на разработчиков, которые имеют некоторый опыт работы с React и Node.js.

Для тех, кого интересует только код, вот соответствующий репозиторий.

Интересно? Тогда прошу под кат.


Подготовка и настройка проекта


Создание проекта и установка зависимостей

Для работы с зависимостями будет использоваться Yarn.

Создаем новый Next.js-проект с поддержкой TS с помощью Create Next App:

yarn create next-app fullstack-next-ts-app --ts


rl9oni2indqo888lavzejtxdc2a.png

Советую взглянуть на Tabby — продвинутый терминал с кучей интересных возможностей

Обратите внимание, что мы выбрали ESLint и директорию src для хранения файлов приложения, но отказались от экспериментальной директории app.

Устанавливаем минимальный набор npm-пакетов, необходимых для работы нашего приложения:

# производственные зависимости
yarn add @emotion/cache @emotion/react @emotion/server @emotion/styled @formkit/auto-animate @mui/icons-material @mui/joy @mui/material @prisma/client @welldone-software/why-did-you-render argon2 cookie jsonwebtoken multer next-connect react-error-boundary react-toastify swiper swr
# зависимости для разработки
yarn add -D @types/cookie @types/jsonwebtoken @types/multer babel-plugin-import prisma sass


  • @mui/... — компоненты и иконки Material UI;
  • @emotion/... — решение CSS-в-JS, которое используется для стилизации компонентов Material UI;
  • prisma — ORM для работы с реляционными БД PostgreSQL, MySQL, SQLite и SQL Server, а также с NoSQL-БД MongoDB и CockroachDB;
  • @prisma/client — клиент Prisma;
  • @welldone-software/why-did-you-render — полезная утилита для отладки React-приложений, позволяющая определить причину повторного рендеринга компонента;
  • argon2 — утилита для хэширования и проверки паролей;
  • cookie — утилита для работы с куки;
  • jsonwebtoken — утилита для работы с токенами;
  • multer — посредник (middleware) Node.js для обработки multipart/form-data (для работы с файлами, содержащимися в запросе);
  • next-connect — библиотека, позволяющая работать с интерфейсом роутов Next.js как с роутами Express;
  • react-error-boundary — компонент-предохранитель для React-приложений;
  • react-toastify — компонент и утилита для реализации уведомлений в React-приложениях;
  • swiper — продвинутый компонент слайдера;
  • swr — хуки React для запроса (получения — fetching) данных от сервера, позволяющие обойтись без инструмента для управления состоянием (state manager);
  • @types/... — недостающие типы TS;
  • babel-plugin-import — плагин Babel для эффективной «тряски дерева» (tree shaking) при импорте компонентов MUI по названию;
  • sass — препроцессор CSS.

Мы рассмотрим большую часть этих пакетов далее и в следующей части туториала.


Подготовка БД и настройка ORM

Для хранения данных пользователей и постов нам нужна БД. Для простоты будем использовать SQLite — в этой БД данные хранятся в виде файла на сервере. Для работы с SQLite будет использоваться Prisma.

Советую установить это расширение для VSCode для работы со схемой Prisma

Инициализируем Prisma, находясь в корневой директории проекта:

npx prisma init


x27mycjja10txzjvpn4c2cwaqty.png

Выполнение этой команды приводит к генерации директории prisma и файла .env. Редактируем файл schema.prisma в директории prisma, определяя провайдер для БД в блоке datasource и модели пользователя, поста и лайка:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  // !
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

// модель пользователя
model User {
  id        String  @id @default(uuid())
  username  String?
  avatarUrl String?
  email     String  @unique
  password  String
  posts     Post[]
  likes     Like[]
}
// модель поста
model Post {
  id        String   @id @default(uuid())
  title     String
  content   String
  author    User     @relation(fields: [authorId], references: [id], onUpdate: Cascade, onDelete: Cascade)
  authorId  String
  likes     Like[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
// модель лайка
model Like {
  id     String @id @default(uuid())
  user   User   @relation(fields: [userId], references: [id])
  userId String
  post   Post   @relation(fields: [postId], references: [id], onUpdate: Cascade, onDelete: Cascade)
  postId String
}

Редактируем файл .env, определяя в нем путь к файлу БД:

DATABASE_URL="file:./dev.db"

Создаем и применяем миграцию к БД:

npx prisma migrate dev --name init


unamm4qyvayu8ivnku-z-znm44i.png

Выполнение этой команды приводит к генерации директории migrations с миграцией на SQL.

Обратите внимание: при первом выполнении migrate dev автоматически устанавливается и генерируется клиент Prisma. В дальнейшем при любом изменении схемы Prisma необходимо вручную выполнять команду npx prisma generate для обновления клиента.

Также обратите внимание, что для быстрого восстановления исходного состояния БД с потерей всех данных можно удалить файл dev.db и выполнить команду npx prisma db push.

Осталось настроить клиента Prisma. Создаем файл src/utils/prisma.ts следующего содержания:

// https://github.com/prisma/prisma-examples/blob/latest/typescript/rest-nextjs-api-routes-auth/lib/prisma.ts
import { PrismaClient } from '@prisma/client'
declare let global: { prisma: PrismaClient }

let prisma: PrismaClient

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient()
} else {
  if (!global.prisma) {
    global.prisma = new PrismaClient()
  }
  prisma = global.prisma
}

export default prisma

Этот сниппет обеспечивает существование только одного экземпляра (синглтона — singleton) клиента Prisma при работе как в производственной среде, так и в среде для разработки. Дело в том, что в режиме разработки из-за HMR при перезагрузке модуля, импортирующего prisma, будет создаваться новый экземпляр клиента.


Подготовка и настройка статических данных для клиента

Наше приложение будет состоять из 3 страниц: главной, блога и контактов. На главной странице и странице контактов будут использоваться статические данные в формате JSON. При этом данные для главной страницы будут храниться локально, а данные для страницы контактов — в JSONBin. Для главной страницы реализуем статическую генерацию с данными с помощью функции getStaticProps, а для страницы контактов — статическую генерацию с данными с инкрементальной регенерацией с помощью функций getStaticProps и getStaticPaths. Мы еще поговорим об этом во второй части туториала.

Создаем файл public/data/home.json с данными для главной страницы:

{
  "blocks": [
    {
      "id": 1,
      "imgSrc": "/img/landscape.jpg",
      "imgAlt": "First landscape",
      "title": "First block",
      "description": "Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni amet illum recusandae numquam iste repudiandae inventore. Sit quis, impedit autem dolorum, perspiciatis tempora voluptas consectetur expedita aspernatur reiciendis labore recusandae voluptatibus, explicabo laboriosam ut temporibus doloremque! Voluptate recusandae commodi quis dolor adipisci fugiat earum? Ratione aliquam modi deserunt voluptatibus error."
    },
    {
      "id": 2,
      "imgSrc": "/img/landscape2.jpg",
      "imgAlt": "Second landscape",
      "title": "Second block",
      "description": "Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni amet illum recusandae numquam iste repudiandae inventore. Sit quis, impedit autem dolorum, perspiciatis tempora voluptas consectetur expedita aspernatur reiciendis labore recusandae voluptatibus, explicabo laboriosam ut temporibus doloremque! Voluptate recusandae commodi quis dolor adipisci fugiat earum? Ratione aliquam modi deserunt voluptatibus error."
    },
    {
      "id": 3,
      "imgSrc": "https://images.unsplash.com/photo-1506744038136-46273834b3fb?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
      "imgAlt": "Third landscape",
      "title": "Third block",
      "description": "Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni amet illum recusandae numquam iste repudiandae inventore. Sit quis, impedit autem dolorum, perspiciatis tempora voluptas consectetur expedita aspernatur reiciendis labore recusandae voluptatibus, explicabo laboriosam ut temporibus doloremque! Voluptate recusandae commodi quis dolor adipisci fugiat earum? Ratione aliquam modi deserunt voluptatibus error."
    },
    {
      "id": 4,
      "imgSrc": "https://images.unsplash.com/photo-1434725039720-aaad6dd32dfe?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1042&q=80",
      "imgAlt": "Forth landscape",
      "title": "Forth block",
      "description": "Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni amet illum recusandae numquam iste repudiandae inventore. Sit quis, impedit autem dolorum, perspiciatis tempora voluptas consectetur expedita aspernatur reiciendis labore recusandae voluptatibus, explicabo laboriosam ut temporibus doloremque! Voluptate recusandae commodi quis dolor adipisci fugiat earum? Ratione aliquam modi deserunt voluptatibus error."
    }
  ]
}

Советую установить это расширение для VSCode для визуализации данных в формате JSON

Обратите внимание на источники изображений (imgSrc). 2 изображения хранятся локально в директории public/img, а еще 2 запрашиваются с Unsplash. Для того, чтобы иметь возможность получать изображения из другого источника (origin) необходимо добавить в файл next.config.js такую настройку:

images: {
  domains: ['images.unsplash.com']
}

Авторизуемся в JSONBin, переходим в раздел «Bins», нажимаем «Create a Bin» и добавляем данные для страницы контактов (новости — файл public/data/news.json):

{
  "news": [
    {
      "id": 1,
      "imgSrc": "https://images.unsplash.com/photo-1506744038136-46273834b3fb?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
      "imgAlt": "First landscape",
      "author": "John",
      "datePublished": "2022/12/31",
      "title": "First news",
      "description": "Lorem, ipsum dolor sit amet consectetur adipisicing elit. Blanditiis vel, odio perspiciatis alias quos et labore sit ab laborum. Laboriosam hic autem earum tempore voluptas?",
      "text": "Lorem ipsum dolor sit amet consectetur adipisicing elit. Eos recusandae aspernatur, distinctio autem quia dolor sed libero dignissimos suscipit. Earum aliquam eius eaque corporis cupiditate velit, odit ullam officia nam quibusdam ex laborum possimus eveniet aliquid adipisci assumenda necessitatibus ducimus. Enim nesciunt fuga, aperiam deserunt quia, aut itaque omnis similique molestias veniam assumenda repellendus consequuntur error exercitationem ex debitis quod quidem magni. Cupiditate iure corporis veritatis tenetur rerum, animi quisquam praesentium accusantium est quas in! Eligendi vitae corrupti sunt distinctio nisi blanditiis atque reprehenderit incidunt obcaecati corporis laborum voluptate iusto nostrum dolorum temporibus facere inventore, quaerat optio unde consequuntur velit."
    },
    {
      "id": 2,
      "imgSrc": "https://images.unsplash.com/photo-1494500764479-0c8f2919a3d8?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
      "imgAlt": "Second landscape",
      "author": "Jane",
      "datePublished": "2022/12/31",
      "title": "Second news",
      "description": "Lorem, ipsum dolor sit amet consectetur adipisicing elit. Blanditiis vel, odio perspiciatis alias quos et labore sit ab laborum. Laboriosam hic autem earum tempore voluptas?",
      "text": "Lorem ipsum dolor sit amet consectetur adipisicing elit. Eos recusandae aspernatur, distinctio autem quia dolor sed libero dignissimos suscipit. Earum aliquam eius eaque corporis cupiditate velit, odit ullam officia nam quibusdam ex laborum possimus eveniet aliquid adipisci assumenda necessitatibus ducimus. Enim nesciunt fuga, aperiam deserunt quia, aut itaque omnis similique molestias veniam assumenda repellendus consequuntur error exercitationem ex debitis quod quidem magni. Cupiditate iure corporis veritatis tenetur rerum, animi quisquam praesentium accusantium est quas in! Eligendi vitae corrupti sunt distinctio nisi blanditiis atque reprehenderit incidunt obcaecati corporis laborum voluptate iusto nostrum dolorum temporibus facere inventore, quaerat optio unde consequuntur velit."
    },
    {
      "id": 3,
      "imgSrc": "https://images.unsplash.com/photo-1506744038136-46273834b3fb?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80",
      "imgAlt": "Third landscape",
      "author": "Bob",
      "datePublished": "2022/12/31",
      "title": "Third news",
      "description": "Lorem, ipsum dolor sit amet consectetur adipisicing elit. Blanditiis vel, odio perspiciatis alias quos et labore sit ab laborum. Laboriosam hic autem earum tempore voluptas?",
      "text": "Lorem ipsum dolor sit amet consectetur adipisicing elit. Eos recusandae aspernatur, distinctio autem quia dolor sed libero dignissimos suscipit. Earum aliquam eius eaque corporis cupiditate velit, odit ullam officia nam quibusdam ex laborum possimus eveniet aliquid adipisci assumenda necessitatibus ducimus. Enim nesciunt fuga, aperiam deserunt quia, aut itaque omnis similique molestias veniam assumenda repellendus consequuntur error exercitationem ex debitis quod quidem magni. Cupiditate iure corporis veritatis tenetur rerum, animi quisquam praesentium accusantium est quas in! Eligendi vitae corrupti sunt distinctio nisi blanditiis atque reprehenderit incidunt obcaecati corporis laborum voluptate iusto nostrum dolorum temporibus facere inventore, quaerat optio unde consequuntur velit."
    },
    {
      "id": 4,
      "imgSrc": "https://images.unsplash.com/photo-1434725039720-aaad6dd32dfe?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1042&q=80",
      "imgAlt": "Forth landscape",
      "author": "Alice",
      "datePublished": "2022/12/31",
      "title": "Forth news",
      "description": "Lorem, ipsum dolor sit amet consectetur adipisicing elit. Blanditiis vel, odio perspiciatis alias quos et labore sit ab laborum. Laboriosam hic autem earum tempore voluptas?",
      "text": "Lorem ipsum dolor sit amet consectetur adipisicing elit. Eos recusandae aspernatur, distinctio autem quia dolor sed libero dignissimos suscipit. Earum aliquam eius eaque corporis cupiditate velit, odit ullam officia nam quibusdam ex laborum possimus eveniet aliquid adipisci assumenda necessitatibus ducimus. Enim nesciunt fuga, aperiam deserunt quia, aut itaque omnis similique molestias veniam assumenda repellendus consequuntur error exercitationem ex debitis quod quidem magni. Cupiditate iure corporis veritatis tenetur rerum, animi quisquam praesentium accusantium est quas in! Eligendi vitae corrupti sunt distinctio nisi blanditiis atque reprehenderit incidunt obcaecati corporis laborum voluptate iusto nostrum dolorum temporibus facere inventore, quaerat optio unde consequuntur velit."
    },
    {
      "id": 5,
      "imgSrc": "https://images.unsplash.com/34/BA1yLjNnQCI1yisIZGEi_2013-07-16_1922_IMG_9873.jpg?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1171&q=80",
      "imgAlt": "Fifth landscape",
      "author": "Alice",
      "datePublished": "2022/12/31",
      "title": "Fifth news",
      "description": "Lorem, ipsum dolor sit amet consectetur adipisicing elit. Blanditiis vel, odio perspiciatis alias quos et labore sit ab laborum. Laboriosam hic autem earum tempore voluptas?",
      "text": "Lorem ipsum dolor sit amet consectetur adipisicing elit. Eos recusandae aspernatur, distinctio autem quia dolor sed libero dignissimos suscipit. Earum aliquam eius eaque corporis cupiditate velit, odit ullam officia nam quibusdam ex laborum possimus eveniet aliquid adipisci assumenda necessitatibus ducimus. Enim nesciunt fuga, aperiam deserunt quia, aut itaque omnis similique molestias veniam assumenda repellendus consequuntur error exercitationem ex debitis quod quidem magni. Cupiditate iure corporis veritatis tenetur rerum, animi quisquam praesentium accusantium est quas in! Eligendi vitae corrupti sunt distinctio nisi blanditiis atque reprehenderit incidunt obcaecati corporis laborum voluptate iusto nostrum dolorum temporibus facere inventore, quaerat optio unde consequuntur velit."
    },
    {
      "id": 6,
      "imgSrc": "https://images.unsplash.com/photo-1429704658776-3d38c9990511?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1079&q=80",
      "imgAlt": "Sixth landscape",
      "author": "Bob",
      "datePublished": "2022/12/31",
      "title": "Sixth news",
      "description": "Lorem, ipsum dolor sit amet consectetur adipisicing elit. Blanditiis vel, odio perspiciatis alias quos et labore sit ab laborum. Laboriosam hic autem earum tempore voluptas?",
      "text": "Lorem ipsum dolor sit amet consectetur adipisicing elit. Eos recusandae aspernatur, distinctio autem quia dolor sed libero dignissimos suscipit. Earum aliquam eius eaque corporis cupiditate velit, odit ullam officia nam quibusdam ex laborum possimus eveniet aliquid adipisci assumenda necessitatibus ducimus. Enim nesciunt fuga, aperiam deserunt quia, aut itaque omnis similique molestias veniam assumenda repellendus consequuntur error exercitationem ex debitis quod quidem magni. Cupiditate iure corporis veritatis tenetur rerum, animi quisquam praesentium accusantium est quas in! Eligendi vitae corrupti sunt distinctio nisi blanditiis atque reprehenderit incidunt obcaecati corporis laborum voluptate iusto nostrum dolorum temporibus facere inventore, quaerat optio unde consequuntur velit."
    }
  ]
}

Нажимаем на шестеренку и вводим news в качестве название бина, а также нажимаем на замочек для того, чтобы сделать бин доступным публично:


ka4rnmc4b0qpbn9couzhzrafswa.png

Нажимаем «Save Bin» и копируем BIN ID в переменную JSONBIN_BIN_ID в файле .env:

JSONBIN_BIN_ID=<ваш-bin-id>

Переходим в раздел «API KEYS», нажимаем «Create Access Key», вводим news в качестве названия ключа доступа и выбираем «Read» в разделе «Bins»:


0xuc9dvdhn6erolejz1dxgsaqgc.png

Нажимаем «Save Access Key» и копируем значения полей «X-MASTER-KEY» и «X-ACCESS_KEY» в соответствующие переменные:

JSONBIN_X_MASTER_KEY=
JSONBIN_X_ACCESS_KEY=

Создаем файл environment.d.ts в корне проекта и определяем в нем типы переменных среды окружения:

declare namespace NodeJS {
  interface ProcessEnv {
    JSONBIN_BIN_ID: string
    JSONBIN_X_MASTER_KEY: string
    JSONBIN_X_ACCESS_KEY: string
    // об этом чуть позже
    ID_TOKEN_SECRET: string
    ACCESS_TOKEN_PAYLOAD: string
    ACCESS_TOKEN_SECRET: string
    COOKIE_NAME: string
  }
}

Подключаем этот файл в tsconfig.json:

"include": [
  "next-env.d.ts",
  "environment.d.ts",
  "**/*.ts",
  "**/*.tsx",
],

Пожалуй, это все, что требуется для подготовки и настройки проекта на данном этапе.


Аутентификация и авторизация

Для аутентификации и авторизации пользователей нашего приложения мы воспользуемся современной и одной из наиболее безопасных схем — JSON Web Tokens + Cookie. На самом высоком уровне это означает следующее:


  • для хранения состояния аутентификации сервер генерирует токен идентификации (idToken) на основе данных пользователя (например, его ID) и записывает его в куки со специальными настройками;
  • на основе куки из запроса пользователя сервер определяет, зарегистрирован ли пользователь в приложении. Если пользователь зарегистрирован, сервер извлекает ID пользователя из токена идентификации, получает данные пользователя из БД и возвращает их клиенту;
  • для доступа к защищенным ресурсам сервер генерирует токен доступа (accessToken) и возвращает его авторизованному клиенту;
  • при доступе к защищенному ресурсу сервер проверяет наличие и валидность токена доступа из заголовка Authorization объекта запроса.


Посредники и утилиты авторизации

Реализуем 2 посредника и 1 утилиту авторизации:


  • cookie — посредник для работы с куки;
  • authGuard — посредник для предоставления доступа к защищенным ресурсам;
  • checkFields — утилита для проверки наличия обязательных полей в теле запроса.

Начнем с определения переменных для куки в файле .env:

ID_TOKEN_SECRET="id-token-secret"
ACCESS_TOKEN_PAYLOAD="access-token-payload"
ACCESS_TOKEN_SECRET="access-token-secret"
COOKIE_NAME="uid"

Обратите внимание: в реальном приложении секреты и полезная нагрузка должны быть длинными произвольно сгенерированными строками.

Определяем типы для посредника cookie в файле src/types.ts:

import { CookieSerializeOptions } from 'cookie'
import { NextApiRequest, NextApiResponse } from 'next'

// параметры, принимаемые функцией
export type CookieArgs = {
  name: string
  value: any
  options?: CookieSerializeOptions
}

// расширяем объект ответа
export type NextApiResponseWithCookie = NextApiResponse & {
  cookie: (args: CookieArgs) => void
}

// расширяем обработчик запросов
export type NextApiHandlerWithCookie = (
  req: NextApiRequest,
  res: NextApiResponseWithCookie
) => unknown | Promise

// определяем тип посредника
export type NextApiMiddleware = (
  handler: H
) => (req: NextApiRequest, res: R) => void

Определяем посредника для работы с куки в файле utils/cookie.ts:

import { serialize } from 'cookie'
import { NextApiResponse } from 'next'
import {
  CookieArgs,
  NextApiHandlerWithCookie,
  NextApiMiddleware,
  NextApiResponseWithCookie
} from '../types'

const cookieFn = (
  res: NextApiResponse,
  { name, value, options = {} }: CookieArgs
) => {
  const stringValue =
    typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value)

  if (typeof options.maxAge === 'number') {
    options.expires = new Date(Date.now() + options.maxAge)
    options.maxAge /= 1000
  }

  // устанавливаем заголовок `Set-Cookie`
  res.setHeader('Set-Cookie', serialize(name, String(stringValue), options))
}

const cookies: NextApiMiddleware<
  NextApiHandlerWithCookie,
  NextApiResponseWithCookie
> = (handler) => (req, res) => {
  // расширяем объект ответа
  res.cookie = (args: CookieArgs) => cookieFn(res, args)

  // передаем управление следующему обработчику
  return handler(req, res)
}

export default cookies

Этот посредник позволяет устанавливать куки с помощью res.cookie({ name, value, options }).

Для применения посредника достаточно обернуть в него обработчик запросов:

import { NextApiHandlerWithCookie } from '@/types'
import cookies from '@/utils/cookie'

const handler: NextApiHandlerWithCookie = async (req, res) => {
  console.log(res.cookie)
  // ...
}

export default cookies(handler)

Определяем посредника для предоставления доступа к защищенным ресурсам в файле utils/authGuard.ts:

import jwt from 'jsonwebtoken'
import { NextApiHandler, NextApiResponse } from 'next'
import { NextApiMiddleware } from '../types'

const authGuard: NextApiMiddleware =
  (handler) => async (req, res) => {
    // извлекаем токен доступа из заголовка `Authorization`
    // значением этого заголовка должна быть строка `Bearer [accessToken]`
    const accessToken = req.headers.authorization?.split(' ')[1]

    // если токен доступа отсутствует
    if (!accessToken) {
      return res.status(403).json({ message: 'Access token must be provided' })
    }

    // декодируем токен
    // сигнатура токена - `{ payload: string }`
    const decodedToken = (await jwt.verify(
      accessToken,
      process.env.ACCESS_TOKEN_SECRET
    )) as unknown as {
      payload: string
    }

    // если полезная нагрузка отсутствует или не совпадает с полезной нагрузкой на сервере
    if (
      !decodedToken ||
      decodedToken.payload !== process.env.ACCESS_TOKEN_PAYLOAD
    ) {
      return res.status(403).json({ message: 'Invalid token' })
    }

    // передаем управление следующему обработчику
    return handler(req, res)
  }

export default authGuard

Наконец, определяем утилиту для проверки наличия обязательных полей в теле запроса в файле utils/checkFields.ts:

export default function checkFields(obj: T, keys: Array) {
  for (const key of keys) {
    if (!obj[key]) {
      return false
    }
  }
  return true
}

Думаю, здесь все понятно.


Роуты аутентификации и авторизации

Интерфейсы роутов определяются в директории pages/api и доступны по адресу /api/*.

Создаем в ней директорию auth с файлами register.ts и login.ts.

Определяем роут для регистрации:

import { NextApiHandlerWithCookie } from '@/types'
import checkFields from '@/utils/checkFields'
import cookies from '@/utils/cookie'
import prisma from '@/utils/prisma'
import { User } from '@prisma/client'
import argon2 from 'argon2'
import jwt from 'jsonwebtoken'

const registerHandler: NextApiHandlerWithCookie = async (req, res) => {
  // извлекаем данные из тела запроса
  // одним из преимуществ использования Prisma является автоматическая генерация типов моделей
  const data: Pick = JSON.parse(
    req.body
  )

  // если отсутствует хотя бы одно обязательное поле
  if (!checkFields(data, ['email', 'password'])) {
    return res.status(400).json({ message: 'Some required fields are missing' })
  }

  try {
    // получаем данные пользователя
    const existingUser = await prisma.user.findUnique({
      where: { email: data.email }
    })

    // если данные имеются, значит, пользователь уже зарегистрирован
    if (existingUser) {
      return res.status(409).json({ message: 'Email already in use' })
    }

    // хэшируем пароль
    const passwordHash = await argon2.hash(data.password)
    // и заменяем им оригинальный
    data.password = passwordHash

    // создаем пользователя - записываем учетные данные пользователя в БД
    const newUser = await prisma.user.create({
      data,
      // важно!
      // не "выбираем" пароль
      select: {
        id: true,
        username: true,
        email: true
      }
    })

    // генерируем токен идентификации на основе ID пользователя
    const idToken = await jwt.sign(
      { userId: newUser.id },
      process.env.ID_TOKEN_SECRET,
      {
        // срок жизни токена, т.е. время, в течение которого токен будет считаться валидным составляет 7 дней
        expiresIn: '7d'
      }
    )

    // генерируем токен доступа на основе полезной нагрузки, известной только серверу
    const accessToken = await jwt.sign(
      { payload: process.env.ACCESS_TOKEN_PAYLOAD },
      process.env.ACCESS_TOKEN_SECRET,
      {
        // важно!
        // такой срок жизни токена доступа приемлем только при разработке приложения
        // см. ниже
        expiresIn: '1d'
      }
    )

    // записываем токен идентификации в куки
    res.cookie({
      name: process.env.COOKIE_NAME,
      value: idToken,
      // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes
      // важно!
      // настройки `httpOnly: true` и `secure: true` являются обязательными
      options: {
        httpOnly: true,
        // значение данной настройки должно совпадать со значением настройки `expiresIn` токена
        maxAge: 1000 * 60 * 60 * 24 * 7,
        // куки применяется для всего приложения
        path: '/',
        // клиент и сервер живут по одному адресу
        sameSite: true,
        secure: true
      }
    })

    // возвращаем данные пользователя и токен доступа
    res.status(200).json({
      user: newUser,
      accessToken
    })
  } catch (e) {
    console.log(e)
    res.status(500).json({ message: 'User register error' })
  }
}

export default cookies(registerHandler)

Мы генерируем токен доступа с очень длительным сроком жизни. Это избавляет нас от необходимости его продления (генерации нового токена) в посреднике authGuard, например. Но это небезопасно, поэтому в производственном приложении срок жизни токена доступа должен составлять примерно 1 час. Также в реальном приложении должен быть предусмотрен механизм автоматического продления токена идентификации: в нашем приложении пользователь должен будет выполнять вход в систему один раз в неделю.

Определяем роут для авторизации:

import { NextApiHandlerWithCookie } from '@/types'
import checkFields from '@/utils/checkFields'
import cookies from '@/utils/cookie'
import prisma from '@/utils/prisma'
import { User } from '@prisma/client'
import argon2 from 'argon2'
import jwt from 'jsonwebtoken'

const loginHandler: NextApiHandlerWithCookie = async (req, res) => {
  const data: Pick = JSON.parse(req.body)

  if (!checkFields(data, ['email', 'password'])) {
    return res.status(400).json({ message: 'Some required fields are missing' })
  }

  try {
    // получаем данные пользователя
    const user = await prisma.user.findUnique({
      where: {
        email: data.email
      },
      // важно!
      // здесь нам нужен пароль
      select: {
        id: true,
        email: true,
        password: true,
        username: true,
        avatarUrl: true
      }
    })

    // если данные отсутствуют
    if (!user) {
      return res.status(404).json({ message: 'User not found' })
    }

    // проверяем пароль
    const isPasswordCorrect = await argon2.verify(user.password, data.password)

    // если введен неправильный пароль
    if (!isPasswordCorrect) {
      return res.status(403).json({ message: 'Wrong password' })
    }

    // генерируем токен идентификации
    const idToken = await jwt.sign(
      { userId: user.id },
      process.env.ID_TOKEN_SECRET,
      {
        expiresIn: '7d'
      }
    )

    // генерируем токен доступа
    const accessToken = await jwt.sign(
      { payload: process.env.ACCESS_TOKEN_PAYLOAD },
      process.env.ACCESS_TOKEN_SECRET,
      {
        expiresIn: '1d'
      }
    )

    // записываем токен идентификации в куки
    res.cookie({
      name: process.env.COOKIE_NAME,
      value: idToken,
      options: {
        httpOnly: true,
        maxAge: 1000 * 60 * 60 * 24 * 7,
        path: '/',
        sameSite: true,
        secure: true
      }
    })

    // возвращаем данные пользователя (без пароля!)
    // и токен доступа
    res.status(200).json({
      user: {
        id: user.id,
        email: user.email,
        username: user.username,
        avatarUrl: user.avatarUrl
      },
      accessToken
    })
  } catch (e) {
    console.log(e)
    res.status(500).json({ message: 'User login error' })
  }
}

export default cookies(loginHandler)

Создаем файл auth/user.ts для роута определения состояния аутентификации и получения данных пользователя:

import prisma from '@/utils/prisma'
import jwt from 'jsonwebtoken'
import { NextApiHandler } from 'next'

const userHandler: NextApiHandler = async (req, res) => {
  // извлекаем токен идентификации из куки
  const idToken = req.cookies[process.env.COOKIE_NAME]

  // если токен отсутствует
  if (!idToken) {
    return res.status(401).json({ message: 'ID token must be provided' })
  }

  try {
    // декодируем токен
    const decodedToken = (await jwt.verify(
      idToken,
      process.env.ID_TOKEN_SECRET
    )) as unknown as { userId: string }

    // если полезная нагрузка отсутствует
    if (!decodedToken || !decodedToken.userId) {
      return res.status(403).json({ message: 'Invalid token' })
    }

    // получаем данные пользователя
    const user = await prisma.user.findUnique({
      where: { id: decodedToken.userId },
      // важно!
      // не получаем пароль
      select: {
        id: true,
        email: true,
        username: true,
        avatarUrl: true
      }
    })

    // если данные отсутствуют
    if (!user) {
      return res.status(404).json({ message: 'User not found' })
    }

    // генерируем токен доступа
    const accessToken = await jwt.sign(
      { payload: process.env.ACCESS_TOKEN_PAYLOAD },
      process.env.ACCESS_TOKEN_SECRET,
      {
        expiresIn: '1d'
      }
    )

    // возвращаем данные пользователя и токен доступа
    res.status(200).json({ user, accessToken })
  } catch (e) {
    console.log(e)
    res.status(500).json({ message: 'User get error' })
  }
}

export default userHandler

Наконец, определяем роут для выхода пользователя из системы в файле auth/logout.ts:

import { NextApiHandlerWithCookie } from '@/types'
import authGuard from '@/utils/authGuard'
import cookies from '@/utils/cookie'

const logoutHandler: NextApiHandlerWithCookie = async (req, res) => {
  // для реализации выхода пользователя из системы достаточно удалить куки
  res.cookie({
    name: process.env.COOKIE_NAME,
    value: '',
    options: {
      httpOnly: true,
      maxAge: 0,
      path: '/',
      sameSite: true,
      secure: true
    }
  })

  res.status(200).json({ message: 'Logout success' })
}

// обратите внимание, что этот роут является защищенным
export default authGuard(cookies(logoutHandler) as any)

Таким образом, мы реализовали 4 роута аутентификации и авторизации:


  • POST /api/register — для регистрации пользователя;
  • POST /api/login — для входа пользователя в систему;
  • GET /api/user — для получения данных зарегистрированного пользователя;
  • GET /api/logout — для выхода пользователя из системы.


Загрузка файлов

Пользователи нашего приложения будут иметь возможность загружать аватары. Следовательно, нам необходимо реализовать роут для сохранения файлов на сервере. Для работы с файлами из запроса традиционно используется Multer.

Обратите внимание: для реализации всех последующих роутов будет использоваться next-connect.

Создаем в директории api файл upload.ts следующего содержания:

import authGuard from '@/utils/authGuard'
import prisma from '@/utils/prisma'
import multer from 'multer'
import { NextApiRequest, NextApiResponse } from 'next'
import nextConnect from 'next-connect'

// создаем обработчик файлов
const upload = multer({
  storage: multer.diskStorage({
    // определяем директорию для хранения аваторов пользователей
    destination: './public/avatars',
    // важно!
    // названием файла является идентификатор пользователя + расширение исходного файла
    // это будет реализовано на клиенте
    filename: (req, file, cb) => cb(null, file.originalname)
  })
})

// создаем роут
const uploadHandler = nextConnect<
  NextApiRequest & { file?: Express.Multer.File },
  NextApiResponse
>()

// добавляем посредника
// важно!
// поле для загрузки файла на клиенте должно называться `avatar`
// 
uploadHandler.use(upload.single('avatar'))

// обрабатываем POST-запрос
uploadHandler.post(async (req, res) => {
  // multer сохраняет файл в директории `public/avatars`
  // и записывает данные файла в объект `req.file`
  if (!req.file) {
    return res.status(500).json({ message: 'File write error' })
  }

  // извлекаем ID пользователя из названия файла
  const userId = req.file.filename.split('.')[0]

  try {
    // обновляем данные пользователя
    const user = await prisma.user.update({
      where: { id: userId },
      data: {
        // удаляем `public`
        avatarUrl: req.file.path.replace('public', '')
      },
      // важно!
      // не получаем пароль
      select: {
        id: true,
        username: true,
        avatarUrl: true,
        email: true
      }
    })

    // возвращаем данные пользователя
    res.status(200).json(user)
  } catch (e) {
    console.error(e)
    res.status(500).json({ message: 'User update error' })
  }
})

// роут является защищенным
export default authGuard(uploadHandler)

// важно!
// отключаем преобразование тела запроса в JSON
export const config = {
  api: {
    bodyParser: false
  }
}

Этот роут доступен по адресу /api/upload с помощью метода POST.

Следует отметить, что в нашей реализации не хватает логики для удаления старых аватаров пользователей: название файла состоит из ID пользователя и расширения файла, т.е. один пользователь может иметь несколько файлов с разными расширениями. Это касается только файлов на сервере, поле avatarUrl всегда будет содержать ссылку на последний загруженный файл. Также в реальном приложении имеет смысл определить логику для уменьшения размера загружаемого файла, например, путем его сжатия.


CRUD-операции для постов и лайков

Серверная часть нашего приложения почти готова. Осталось реализовать роуты для добавления, редактирования и удаления постов, а также для добавления и удаления лайков.

Обратите внимание: все последующие роуты являются защищенными.

Также обратите внимание, что роуты для получения всех постов и одного поста по ID будут реализованы на клиенте (серверной логики на клиенте) с помощью функции getServerSideProps.

Создаем в директории api файл post.ts следующего содержания:

import authGuard from '@/utils/authGuard'
import checkFields from '@/utils/checkFields'
import prisma from '@/utils/prisma'
import { Post } from '@prisma/client'
import { NextApiRequest, NextApiResponse } from 'next'
import nextConnect from 'next-connect'

const postsHandler = nextConnect()

// обрабатываем POST-запрос
// создание поста
postsHandler.post(async (req, res) => {
  const data: Pick = JSON.parse(
    req.body
  )

  if (!checkFields(data, ['title', 'content', 'authorId'])) {
    res.status(400).json({ message: 'Some required fields are missing' })
  }

  try {
    const post = await prisma.post.create({
      data
    })
    res.status(200).json(post)
  } catch (e) {
    console.error(e)
    res.status(500).json({ message: 'Post create error' })
  }
})

// обработка PUT-запроса
// обновление поста
postsHandler.put(async (req, res) => {
  const data: Pick & {
    postId: string
  } = JSON.parse(req.body)

  if (!checkFields(data, ['title', 'content'])) {
    res.status(400).json({ message: 'Some required fields are missing' })
  }

  try {
    const post = await prisma.post.update({
      where: { id: data.postId },
      data: {
        title: data.title,
        content: data.content
      }
    })
    res.status(200).json(post)
  } catch (e) {
    console.error(e)
    res.status(500).json({ message: 'Update post error' })
  }
})

// обработка DELETE-запроса
// удаление поста
postsHandler.delete(async (req, res) => {
  const id = req.query.id as string

  if (!id) {
    return res.status(400).json({
      message: 'Post ID is missing'
    })
  }

  try {
    const post = await prisma.post.delete({
      where: {
        id
      }
    })
    res.status(200).json(post)
  } catch (e) {
    console.error(e)
    res.status(500).json({ message: 'Post remove error' })
  }
})

export default authGuard(postsHandler)

Во всех случаях в ответ на запрос возвращаются данные поста.

Таким образом, у нас имеется 3 роута для поста:


  • POST /api/post — для создания поста;
  • PUT /api/post — для обновления поста;
  • DELETE /api/post?postId= — для удаления поста.

Определяем роут для лайков в файле api/like.ts:

import authGuard from '@/utils/authGuard'
import checkFields from '@/utils/checkFields'
import prisma from '@/utils/prisma'
import { Like } from '@prisma/client'
import { NextApiRequest, NextApiResponse } from 'next'
import nextConnect from 'next-connect'

const likeHandler = nextConnect()

// обработка POST-запроса
// создание лайка
likeHandler.post(async (req, res) => {
  const data = JSON.parse(req.body) as Omit

  if (!checkFields(data, ['postId', 'userId'])) {
    return res.status(400).json({ message: 'Some required fields are missing' })
  }

  try {
    const like = await prisma.like.create({
      data
    })
    res.status(201).json(like)
  } catch (e) {
    console.log(e)
    res.status(500).json({ message: 'Like create error' })
  }
})

// обработка DELETE-запроса
// удаление поста
likeHandler.delete(async (req, res) => {
  const id = req.query.id as string

  if (!id) {
    return res.status(400).json({ message: 'Like ID is missing' })
  }

  try {
    const like = await prisma.like.delete({
      where: {
        id
      }
    })
    res.status(200).json(like)
  } catch (e) {
    console.log(e)
    res.status(500).json({ message: 'Like remove error' })
  }
})

export default authGuard(likeHandler)

Таким образом, у нас имеется 2 роута для лайка:


  • POST /api/like — для создания лайка;
  • DELETE /api/like?likeId= — для удаления лайка.

В качестве последнего штриха определяем некоторые заголовки HTTP, связанные с безопасностью, в next.config.js для всех роутов:

/** @type {import('next').NextConfig} */
const securityHeaders = [
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'X-XSS-Protection', value: '1; mode=block' },
  { key: 'Cross-Origin-Resource-Policy', value: 'same-site' },
  {
    key: 'Cross-Origin-Opener-Policy',
    value: 'same-origin-allow-popups'
  },
  { key: 'Cross-Origin-Embedder-Policy', value: 'require-corp' },
  { key: 'Referrer-Policy', value: 'no-referrer' },
  {
    key: 'Strict-Transport-Security',
    value: 'max-age=31536000; includeSubDomains'
  },
  { key: 'Expect-CT', value: 'enforce, max-age=86400' },
  {
    key: 'Content-Security-Policy',
    value: `object-src 'none'; frame-ancestors 'self'; block-all-mixed-content; upgrade-insecure-requests`
  },
  {
    key: 'Permissions-Policy',
    value: 'camera=(), microphone=(), geolocation=(), payment=()'
  }
]

const nextConfig = {
  reactStrictMode: true,
  images: {
    domains: ['images.unsplash.com']
  },
  async headers() {
    return [
      {
        source: '/:path*',
        headers: securityHeaders
      }
    ]
  }
}

module.exports = nextConfig

Мы закончили разработку серверной части нашего приложения. Следующая часть туториала будет посвящена разработке клиента и проверке работоспособности приложения. Буду рад любым замечаниям и предложениям.

Благодарю за внимание и happy coding!


p-u9l27ynelxi92bcmdxhu76ma8.png

© Habrahabr.ru