DevGang
Авторизоваться

Создание файлового хранилища с помощью Next.js, PostgreSQL и Minio S3

Это вторая часть серии статей о создании файлового хранилища с помощью Next.js, PostgreSQL и Minio S3. В первой части мы настроили среду разработки с помощью Docker Compose. В этой части мы создадим полнофункциональное приложение с использованием Next.js, PostgreSQL и Minio S3.

На заре веб-разработки файлы, такие как изображения и документы, хранились на веб-сервере вместе с кодом приложения. Однако с увеличением пользовательского трафика и необходимостью хранить большие файлы облачные сервисы хранения, такие как Amazon S3, стали предпочтительным способом хранения файлов.

Отделение хранения файлов от веб-сервера дает ряд преимуществ, в том числе:

  • Масштабируемость и производительность
  • Поддержка больших файлов (до 5 ТБ)
  • Эффективность затрат
  • Разделение интересов

В этой статье мы создадим пример приложения для хранения файлов с использованием Next.js, PostgreSQL и Minio S3. Существует два основных способа загрузки файлов на S3 из Next.js:

  • Использование заранее заданных URL-адресов для получения временного доступа для загрузки файлов, а затем загрузки файлов непосредственно из внешнего интерфейса в S3. Этот подход немного сложнее, но он не использует ресурсы сервера Next.js для загрузки файлов.

Исходный код

Полный исходный код этого руководства можно найти на GitHub.

Общий код для обоих подходов

Некоторый код будет использоваться совместно этими двумя подходами, например, компоненты пользовательского интерфейса, модели баз данных, служебные функции и типы.

Схема базы данных

Для сохранения информации о загруженных файлах создадим в базе данных Модель файла. В файле Schema.prisma добавьте следующую модель:

model File {
    id           String   @id @default(uuid())
    bucket       String
    fileName     String   @unique
    originalName String
    createdAt    DateTime @default(now())
    size         Int
}
  • id — уникальный идентификатор файла в базе данных, сгенерированный функцией uuid().
  • Bucket — имя бакета в S3, где хранится файл, в нашем случае оно будет одинаковым для всех файлов.
  • fileName — имя файла в S3, оно будет уникальным для каждого файла. Если пользователи загружают файлы с одинаковым именем, новый файл заменит старый.
  • originalName — исходное имя файла, который загрузил пользователь. Мы будем использовать его для отображения имени файла пользователю при загрузке файла.
  • CreateAt — дата и время загрузки файла.
  • Size - размер файла в байтах.

После создания модели нам необходимо применить изменения к базе данных. Мы можем сделать это с помощью команды db push или dbmigration. Разница между этими двумя командами заключается в том, что db push удалит базу данных и создаст ее заново, тогда как dbmigration применит только изменения к базе данных. Дополнительную информацию о командах можно найти в документации Prisma. В нашем случае не имеет значения, какую команду мы используем, поэтому мы будем использовать команду db push.

Переменные среды

Если мы используем Docker Compose для запуска приложения для тестирования и разработки, мы можем хранить переменные среды в файле Compose, поскольку нет необходимости хранить их в секрете. Однако в рабочей среде нам следует хранить переменные среды в файле .env. Вот пример файла .env для AWS S3 и PostgreSQL. Замените значения на свои.

DATABASE_URL="postgresql://postgres:postgres@REMOTESERVERHOST:5432/myapp-db?schema=public"

S3_ENDPOINT="s3.amazonaws.com"
S3_ACCESS_KEY="AKIAIOSFODNN7EXAMPLE"
S3_SECRET_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
S3_BUCKET_NAME="my-bucket"

При использовании Google Cloud Storage файл .env будет выглядеть следующим образом:

S3_ENDPOINT="storage.googleapis.com"
...

Служебные функции и типы

Поскольку в качестве службы хранения мы используем Minio S3, нам необходимо установить библиотеку Minio для взаимодействия с S3. Эта библиотека совместима с любой службой хранения, совместимой с S3, включая Amazon S3, Google Cloud Storage и другие.

Установите библиотеку Minio, используя следующую команду:

npm install minio

Затем давайте создадим служебные функции для загрузки файлов в Minio S3. Лично я предпочитаю создавать отдельный файл для служебных функций, поэтому в папке utils создам файл s3-file-management.ts. Здесь мы используем включенную в стек T3 библиотеку env-nextjs для проверки переменных среды.

import * as Minio from 'minio'
import type internal from 'stream'
import { env } from '~/env.js'
 
// Create a new Minio client with the S3 endpoint, access key, and secret key
export const s3Client = new Minio.Client({
  endPoint: env.S3_ENDPOINT,
  port: env.S3_PORT ? Number(env.S3_PORT) : undefined,
  accessKey: env.S3_ACCESS_KEY,
  secretKey: env.S3_SECRET_KEY,
  useSSL: env.S3_USE_SSL === 'true',
})
 
export async function createBucketIfNotExists(bucketName: string) {
  const bucketExists = await s3Client.bucketExists(bucketName)
  if (!bucketExists) {
    await s3Client.makeBucket(bucketName)
  }
}

Главная страница со списком файлов

На главной странице будет форма загрузки и список загруженных файлов.

Чтобы получить список файлов из базы данных, мы создадим функцию fetchFiles, которая отправляет запрос GET на маршрут API для получения списка файлов из базы данных.

А вот полный код главной страницы:

import Head from 'next/head'
import { UploadFilesS3PresignedUrl } from '~/components/UploadFilesForm/UploadFilesS3PresignedUrl'
import { FilesContainer } from '~/components/FilesContainer'
import { useState, useEffect } from 'react'
import { type FileProps } from '~/utils/types'
import { UploadFilesRoute } from '~/components/UploadFilesForm/UploadFilesRoute'
 
export type fileUploadMode = 's3PresignedUrl' | 'NextjsAPIEndpoint'
 
export default function Home() {
  const [files, setFiles] = useState<FileProps[]>([])
  const [uploadMode, setUploadMode] = useState<fileUploadMode>('s3PresignedUrl')
 
  // Fetch files from the database
  const fetchFiles = async () => {
    const response = await fetch('/api/files')
    const body = (await response.json()) as FileProps[]
    // set isDeleting to false for all files after fetching
    setFiles(body.map((file) => ({ ...file, isDeleting: false })))
  }
 
  // fetch files on the first render
  useEffect(() => {
    fetchFiles().catch(console.error)
  }, [])
 
  // determine if we should download using presigned url or Nextjs API endpoint
  const downloadUsingPresignedUrl = uploadMode === 's3PresignedUrl'
  // handle mode change between s3PresignedUrl and NextjsAPIEndpoint
  const handleModeChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
    setUploadMode(event.target.value as fileUploadMode)
  }
 
  return (
    <>
      <Head>
        <title>File Uploads with Next.js, Prisma, and PostgreSQL</title>
        <meta name='description' content='File Uploads with Next.js, Prisma, and PostgreSQL ' />
        <link rel='icon' href='/favicon.ico' />
      </Head>
      <main className='flex min-h-screen items-center justify-center gap-5 font-mono'>
        <div className='container flex flex-col gap-5 px-3'>
          <ModeSwitchMenu uploadMode={uploadMode} handleModeChange={handleModeChange} />
          {uploadMode === 's3PresignedUrl' ? (
            <UploadFilesS3PresignedUrl onUploadSuccess={fetchFiles} />
          ) : (
            <UploadFilesRoute onUploadSuccess={fetchFiles} />
          )}
          <FilesContainer
            files={files}
            fetchFiles={fetchFiles}
            setFiles={setFiles}
            downloadUsingPresignedUrl={downloadUsingPresignedUrl}
          />
        </div>
      </main>
    </>
  )
}

Маршрут API для получения списка файлов из базы данных

Чтобы сделать запрос к базе данных, нам нужно создать маршрут API. Создайте файл index.ts в папке pages/api/files. Этот файл вернет список файлов из базы данных. Для простоты мы не будем использовать пагинацию, мы просто возьмем 10 последних файлов из базы данных. Вы можете реализовать это, используя Skip и Take. Более подробную информацию о нумерации страниц можно найти в документации Prisma.

import type { NextApiRequest, NextApiResponse } from 'next'
import type { FileProps } from '~/utils/types'
import { db } from '~/server/db'
 
const LIMIT_FILES = 10
 
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  // Get the 10 latest files from the database
  const files = await db.file.findMany({
    take: LIMIT_FILES,
    orderBy: {
      createdAt: 'desc',
    },
    select: {
      id: true,
      originalName: true,
      size: true,
    },
  })
  // The database type is a bit different from the frontend type
  // Make the array of files compatible with the frontend type FileProps
  const filesWithProps: FileProps[] = files.map((file) => ({
    id: file.id,
    originalFileName: file.originalName,
    fileSize: file.size,
  }))
 
  return res.status(200).json(filesWithProps)
}
 
export default handler

Меню переключения режимов

Меню переключения режимов позволит пользователю переключаться между двумя подходами загрузки файлов.

export type ModeSwitchMenuProps = {
  uploadMode: fileUploadMode
  handleModeChange: (event: React.ChangeEvent<HTMLSelectElement>) => void
}
function ModeSwitchMenu({ uploadMode, handleModeChange }: ModeSwitchMenuProps) {
  return (
    <ul className='flex items-center justify-center gap-2'>
      <li>
        <label htmlFor='uploadMode'>Upload Mode:</label>
      </li>
      <li>
        <select
          className='rounded-md border-2 border-gray-300'
          id='uploadMode'
          value={uploadMode}
          onChange={handleModeChange}
        >
          <option value='s3PresignedUrl'>S3 Presigned Url</option>
          <option value='NextjsAPIEndpoint'>Next.js API Endpoint</option>
        </select>
      </li>
    </ul>
  )
}

Пользовательский интерфейс элемента файла

Для отображения файлов мы создадим компонент FileItem.tsx, который будет отображать имя файла, его размер и кнопку удаления. Вот упрощенная версия файла без функций загрузки и удаления файлов. Эти функции будут добавлены позже.

import { type FileProps } from '~/utils/types'
import { LoadSpinner } from './LoadSpinner'
import { formatBytes } from '~/utils/fileUploadHelpers'
 
type FileItemProps = {
  file: FileProps
  fetchFiles: () => Promise<void>
  setFiles: (files: FileProps[] | ((files: FileProps[]) => FileProps[])) => void
  downloadUsingPresignedUrl: boolean
}
 
export function FileItem({ file, fetchFiles, setFiles, downloadUsingPresignedUrl }: FileItemProps) {
  return (
    <li className='relative flex items-center justify-between gap-2 border-b py-2 text-sm'>
      <button
        className='truncate text-blue-500 hover:text-blue-600 hover:underline  '
        onClick={() => downloadFile(file)}
      >
        {file.originalFileName}
      </button>
 
      <div className=' flex items-center gap-2'>
        <span className='w-32 '>{formatBytes(file.fileSize)}</span>
 
        <button
          className='flex w-full flex-1 cursor-pointer items-center justify-center
           rounded-md bg-red-500 px-4 py-2 text-white hover:bg-red-600
           disabled:cursor-not-allowed disabled:opacity-50'
          onClick={() => deleteFile(file.id)}
          disabled={file.isDeleting}
        >
          Delete
        </button>
      </div>
 
      {file.isDeleting && (
        <div className='absolute inset-0 flex items-center justify-center rounded-md bg-gray-900 bg-opacity-20'>
          <LoadSpinner size='small' />
        </div>
      )}
    </li>
  )
}

Пользовательский интерфейс файлового контейнера

Для отображения файлов мы создадим компонент FileContainer.tsx, который будет отображать список файлов с помощью компонента FileItem.

import { type FilesListProps } from '~/utils/types'
import { FileItem } from './FileItem'
 
export function FilesContainer({ files, fetchFiles, setFiles, downloadUsingPresignedUrl }: FilesListProps) {
  if (files.length === 0) {
    return (
      <div className='flex h-96 flex-col items-center justify-center '>
        <p className='text-xl'>No files uploaded yet</p>
      </div>
    )
  }
 
  return (
    <div className='h-96'>
      <h1 className='text-xl '>
        Last {files.length} uploaded file{files.length > 1 ? 's' : ''}
      </h1>
      <ul className='h-80 overflow-auto'>
        {files.map((file) => (
          <FileItem
            key={file.id}
            file={file}
            fetchFiles={fetchFiles}
            setFiles={setFiles}
            downloadUsingPresignedUrl={downloadUsingPresignedUrl}
          />
        ))}
      </ul>
    </div>
  )
}

Загрузить интерфейс формы

Для загрузки файлов создадим форму с полем ввода файла. UploadFilesFormUI.tsx будет содержать пользовательский интерфейс для формы загрузки, которая будет использоваться в обоих подходах. Вот упрощенная версия файла:

import Link from 'next/link'
import { LoadSpinner } from '../LoadSpinner'
import { type UploadFilesFormUIProps } from '~/utils/types'
 
export function UploadFilesFormUI({ isLoading, fileInputRef, uploadToServer, maxFileSize }: UploadFilesFormUIProps) {
  return (
    <form className='flex flex-col items-center justify-center gap-3' onSubmit={uploadToServer}>
      <h1 className='text-2xl'>File upload example using Next.js, MinIO S3, Prisma and PostgreSQL</h1>
      {isLoading ? (
        <LoadSpinner />
      ) : (
        <div className='flex h-16 gap-5'>
          <input
            id='file'
            type='file'
            multiple
            className='rounded-md border bg-gray-100 p-2 py-5'
            required
            ref={fileInputRef}
          />
          <button
            disabled={isLoading}
            className='m-2 rounded-md bg-blue-500 px-5 py-2 text-white
                hover:bg-blue-600  disabled:cursor-not-allowed disabled:bg-gray-400'
          >
            Upload
          </button>
        </div>
      )}
    </form>
  )
}

1.1 Загрузка файлов с использованием маршрутов API Next.js (ограничение 4 МБ)

На диаграмме выше показаны шаги, необходимые для загрузки и скачивания файлов с использованием маршрутов API Next.js.

Чтобы загрузить файлы:

  • Пользователь отправляет запрос POST на маршрут API с файлом для загрузки.
  • Маршрут API загружает файл на S3 и возвращает имя файла.
  • Имя файла сохраняется в базе данных.

Frontend — логика формы загрузки для маршрутов API

Сначала мы создадим файл UploadFilesRoute.tsx с логикой формы загрузки.

Алгоритм загрузки файлов на сервер следующий:

  • Пользователь выбирает файлы для загрузки, и fileInputRef обновляется выбранными файлами.
  • Данные формы создаются из выбранных файлов с помощью функции createFormData и API FormData.
  • Данные формы отправляются на сервер с помощью POST-запроса по маршруту /api/files/upload/smallFiles.
  • Сервер загружает файлы на S3 и возвращает статус и сообщение в ответе.

Обычно рекомендуется извлечь логику компонента пользовательского интерфейса в отдельный файл. Один из способов — создать хуки для логики и использовать хуки в UI-компоненте, однако для простоты мы создадим для логики отдельный файл «fileUploadHelpers.ts» и будем использовать его в компоненте «UploadFilesRoute».

/**
 * Create form data from files
 * @param files files to upload
 * @returns form data
 */
export function createFormData(files: File[]): FormData {
  const formData = new FormData()
  files.forEach((file) => {
    formData.append('file', file)
  })
  return formData
}

Вот упрощенная версия без проверки, состояния загрузки и обработки ошибок:

import { useState, useRef } from 'react'
import { validateFiles, createFormData } from '~/utils/fileUploadHelpers'
import { MAX_FILE_SIZE_NEXTJS_ROUTE } from '~/utils/fileUploadHelpers'
import { UploadFilesFormUI } from './UploadFilesFormUI'
 
type UploadFilesFormProps = {
  onUploadSuccess: () => void
}
 
export function UploadFilesRoute({ onUploadSuccess }: UploadFilesFormProps) {
  const fileInputRef = useRef<HTMLInputElement | null>(null)
 
  const uploadToServer = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
 
    const files = Object.values(fileInputRef.current?.files)
 
    const formData = createFormData(files)
    const response = await fetch('/api/files/upload/smallFiles', {
      method: 'POST',
      body: formData,
    })
    const body = (await response.json()) as {
      status: 'ok' | 'fail'
      message: string
    }
  }
 
  return (
    <UploadFilesFormUI
      isLoading={isLoading}
      fileInputRef={fileInputRef}
      uploadToServer={uploadToServer}
      maxFileSize={MAX_FILE_SIZE_NEXTJS_ROUTE}
    />
  )
}

Полный код проверьте в репозитории GitHub.

Серверная часть — загрузка файлов с использованием маршрутов API Next.js.

1. Создайте служебные функции для загрузки файлов с помощью Minio S3.

Чтобы загрузить файлы в S3, мы создадим служебную функцию saveFileInBucket, которая использует метод putObject клиента Minio для загрузки файла в корзину S3. Функция createBucketIfNotExists создает корзину, если она не существует.

/**
 * Save file in S3 bucket
 * @param bucketName name of the bucket
 * @param fileName name of the file
 * @param file file to save
 */
export async function saveFileInBucket({
  bucketName,
  fileName,
  file,
}: {
  bucketName: string
  fileName: string
  file: Buffer | internal.Readable
}) {
  // Create bucket if it doesn't exist
  await createBucketIfNotExists(bucketName)
 
  // check if file exists - optional.
  // Without this check, the file will be overwritten if it exists
  const fileExists = await checkFileExistsInBucket({
    bucketName,
    fileName,
  })
 
  if (fileExists) {
    throw new Error('File already exists')
  }
 
  // Upload image to S3 bucket
  await s3Client.putObject(bucketName, fileName, file)
}
 
/**
 * Check if file exists in bucket
 * @param bucketName name of the bucket
 * @param fileName name of the file
 * @returns true if file exists, false if not
 */
export async function checkFileExistsInBucket({ bucketName, fileName }: { bucketName: string; fileName: string }) {
  try {
    await s3Client.statObject(bucketName, fileName)
  } catch (error) {
    return false
  }
  return true
}

2. Создайте маршрут API для загрузки файлов.

Далее мы создадим маршрут API для обработки загрузки файлов. Создайте файл smallFiles.ts в папкеpages/api/files/upload. Этот файл выполнит как загрузку файла, так и сохранение имени файла в базе данных.

Для парсинга входящего запроса мы воспользуемся грозной библиотекой. Formidable — это модуль Node.js для анализа данных форм, особенно загрузок файлов.

Алгоритм загрузки файлов на сервер:

  • Получите файлы по запросу с помощью formidable.

Затем для каждого файла:

  • Прочтите файл по пути к файлу с помощью fs.createReadStream.
  • Создайте уникальное имя файла, используя библиотеку nanoid.
  • Сохраните файл в S3, используя функцию saveFileInBucket, которая вызывает метод putObject клиента Minio.
  • Сохраните информацию о файле в базе данных, используя метод Prisma file.create.

Вернуть статус и сообщение в ответ клиенту.

Загрузка файла и сохранение информации о файле в базе данных будут выполняться одновременно с использованием Promise.all. Также рассмотрите возможность использования Promise.allSettled для обработки ошибок при загрузке файла и сохранения информации о файле в базе данных.

Если во время загрузки файла или сохранения информации о файле в базе данных возникает ошибка, мы устанавливаем статус 500 и возвращаем сообщение об ошибке.

import type { NextApiRequest, NextApiResponse } from 'next'
import fs from 'fs'
import { IncomingForm, type File } from 'formidable'
import { env } from '~/env'
import { saveFileInBucket } from '~/utils/s3-file-management'
import { nanoid } from 'nanoid'
import { db } from '~/server/db'
 
const bucketName = env.S3_BUCKET_NAME
 
type ProcessedFiles = Array<[string, File]>
 
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  let status = 200,
    resultBody = { status: 'ok', message: 'Files were uploaded successfully' }
 
  // Get files from request using formidable
  const files = await new Promise<ProcessedFiles | undefined>((resolve, reject) => {
    const form = new IncomingForm()
    const files: ProcessedFiles = []
    form.on('file', function (field, file) {
      files.push([field, file])
    })
    form.on('end', () => resolve(files))
    form.on('error', (err) => reject(err))
    form.parse(req, () => {
      //
    })
  }).catch(() => {
    ;({ status, resultBody } = setErrorStatus(status, resultBody))
    return undefined
  })
 
  if (files?.length) {
    // Upload files to S3 bucket
    try {
      await Promise.all(
        files.map(async ([_, fileObject]) => {
          const file = fs.createReadStream(fileObject?.filepath)
          // generate unique file name
          const fileName = `${nanoid(5)}-${fileObject?.originalFilename}`
          // Save file to S3 bucket and save file info to database concurrently
          await saveFileInBucket({
            bucketName,
            fileName,
            file,
          })
          // save file info to database
          await db.file.create({
            data: {
              bucket: bucketName,
              fileName,
              originalName: fileObject?.originalFilename ?? fileName,
              size: fileObject?.size ?? 0,
            },
          })
        })
      )
    } catch (e) {
      console.error(e)
      ;({ status, resultBody } = setErrorStatus(status, resultBody))
    }
  }
 
  res.status(status).json(resultBody)
}
 
// Set error status and result body if error occurs
export function setErrorStatus(status: number, resultBody: { status: string; message: string }) {
  status = 500
  resultBody = {
    status: 'fail',
    message: 'Upload error',
  }
  return { status, resultBody }
}
 
// Disable body parser built-in to Next.js to allow formidable to work
export const config = {
  api: {
    bodyParser: false,
  },
}
 
export default handler

Не забудьте включить конфигурацию экспорта const, это не позволяет встроенному парсеру тела Next.js анализировать тело запроса, что позволяет работать formidable.

1.2 Загрузка файлов с использованием маршрутов API Next.js (ограничение 4 МБ)

Чтобы скачать файлы:

  • Пользователь отправляет запрос GET на маршрут API с идентификатором файла для загрузки.
  • Маршрут API запрашивает имя файла из базы данных.
  • Маршрут API загружает файл с S3.
  • Файл передается объекту ответа и возвращается клиенту.

Frontend — загрузка файлов с использованием маршрутов API Next.js.

Для загрузки файлов мы создадим функцию downloadFile внутри компонента FileItem. Функция отправляет запрос GET на маршрут API для загрузки файла с S3. Файл возвращается пользователю по маршруту API.

const downloadFile = async (file: FileProps) => {
  window.open(`/api/files/download/smallFiles/${file.id}`, '_blank')
}

Backend — загрузка файлов с использованием маршрутов API Next.js.

1. Создайте служебную функцию для загрузки файлов с S3.

Чтобы загрузить файлы из S3, мы создадим служебную функцию getFileFromBucket, которая использует метод getObject клиента Minio для загрузки файла из корзины S3.

/**
 * Get file from S3 bucket
 * @param bucketName name of the bucket
 * @param fileName name of the file
 * @returns file from S3
 */
export async function getFileFromBucket({ bucketName, fileName }: { bucketName: string; fileName: string }) {
  try {
    await s3Client.statObject(bucketName, fileName)
  } catch (error) {
    console.error(error)
    return null
  }
  return await s3Client.getObject(bucketName, fileName)
}

2. Создайте маршрут API для загрузки файлов.

Для загрузки файлов мы создадим маршрут API для обработки загрузки файлов. Создайте файл [id].ts в папкеpages/api/files/download/. Этот файл загрузит файл с S3 и вернет его пользователю.

Здесь мы используем динамический маршрут Next.js с [id], чтобы получить идентификатор файла из запроса запроса. Дополнительную информацию о динамических маршрутах можно найти в документации Next.js.

Алгоритм скачивания файлов с сервера следующий:

  • Получите имя файла и исходное имя из базы данных, используя метод Prisma file.findUnique.
  • Получите файл из корзины S3 с помощью функции getFileFromBucket.
  • Установите заголовок для скачивания файла.
  • Передайте файл объекту ответа.
import { type NextApiRequest, type NextApiResponse } from 'next'
import { getFileFromBucket } from '~/utils/s3-file-management'
import { env } from '~/env'
import { db } from '~/server/db'
 
async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { id } = req.query
  if (typeof id !== 'string') return res.status(400).json({ message: 'Invalid request' })
 
  // get the file name and original name from the database
  const fileObject = await db.file.findUnique({
    where: {
      id,
    },
    select: {
      fileName: true,
      originalName: true,
    },
  })
  if (!fileObject) {
    return res.status(404).json({ message: 'Item not found' })
  }
  // get the file from the bucket and pipe it to the response object
  const data = await getFileFromBucket({
    bucketName: env.S3_BUCKET_NAME,
    fileName: fileObject?.fileName,
  })
 
  if (!data) {
    return res.status(404).json({ message: 'Item not found' })
  }
  // set header for download file
  res.setHeader('content-disposition', `attachment; filename="${fileObject?.originalName}"`)
 
  // pipe the data to the res object
  data.pipe(res)
}
 
export default handler

2.1 Загрузка файлов с использованием заранее заданных URL-адресов

На диаграмме выше мы можем увидеть шаги, необходимые для загрузки и скачивания файлов с использованием заранее заданных URL-адресов. Это более сложный подход, но он не использует ресурсы сервера Next.js для загрузки файлов. Заранее заданный URL-адрес генерируется на сервере и отправляется клиенту. Клиент использует заранее назначенный URL-адрес для загрузки файла непосредственно на S3.

Чтобы загрузить файлы:

  • Пользователь отправляет запрос POST на маршрут API с информацией о файле для загрузки.
  • Маршрут API отправляет запросы к S3 для создания заранее заданных URL-адресов для каждого файла.
  • S3 возвращает заранее назначенные URL-адреса на маршрут API.
  • Маршрут API отправляет заранее назначенные URL-адреса клиенту.
  • Клиент загружает файлы непосредственно на S3, используя заранее назначенные URL-адреса и запросы PUT.
  • Клиент отправляет информацию о файле по маршруту API, чтобы сохранить информацию о файле.
  • Маршрут API сохраняет информацию о файле в базе данных.

Frontend – логика формы загрузки для заранее заданных URL-адресов.

1. Создайте функцию для отправки запроса на маршрут API Next.js для получения заранее заданных URL-адресов.

/**
 * Gets presigned urls for uploading files to S3
 * @param formData form data with files to upload
 * @returns
 */
export const getPresignedUrls = async (files: ShortFileProp[]) => {
  const response = await fetch('/api/files/upload/presignedUrl', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(files),
  })
  return (await response.json()) as PresignedUrlProp[]
}

2. Создайте функцию для загрузки файлов с помощью запроса PUT на S3 с заранее заданным URL-адресом.

Функция uploadToS3 отправляет запрос PUT на заранее заданный URL-адрес для загрузки файла на S3.

/**
 * Uploads file to S3 directly using presigned url
 * @param presignedUrl presigned url for uploading
 * @param file  file to upload
 * @returns  response from S3
 */
export const uploadToS3 = async (presignedUrl: PresignedUrlProp, file: File) => {
  const response = await fetch(presignedUrl.url, {
    method: 'PUT',
    body: file,
    headers: {
      'Content-Type': file.type,
      'Access-Control-Allow-Origin': '*',
    },
  })
  return response
}

3. Создайте функцию для сохранения информации о файле в базе данных.

Функция saveFileInfoInDB отправляет запрос POST на маршрут API для сохранения информации о файле в базе данных.

/**
 * Saves file info in DB
 * @param presignedUrls presigned urls for uploading
 * @returns
 */
export const saveFileInfoInDB = async (presignedUrls: PresignedUrlProp[]) => {
  return await fetch('/api/files/upload/saveFileInfo', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(presignedUrls),
  })
}

4. Создайте форму для загрузки файлов с использованием заранее заданных URL-адресов.

/**
 * Uploads files to S3 and saves file info in DB
 * @param files files to upload
 * @param presignedUrls  presigned urls for uploading
 * @param onUploadSuccess callback to execute after successful upload
 * @returns
 */
export const handleUpload = async (files: File[], presignedUrls: PresignedUrlProp[], onUploadSuccess: () => void) => {
  const uploadToS3Response = await Promise.all(
    presignedUrls.map((presignedUrl) => {
      const file = files.find(
        (file) => file.name === presignedUrl.originalFileName && file.size === presignedUrl.fileSize
      )
      if (!file) {
        throw new Error('File not found')
      }
      return uploadToS3(presignedUrl, file)
    })
  )
 
  if (uploadToS3Response.some((res) => res.status !== 200)) {
    alert('Upload failed')
    return
  }
 
  await saveFileInfoInDB(presignedUrls)
  onUploadSuccess()
}

5. Создайте форму для загрузки файлов, используя заранее назначенные URL-адреса, используя функции из предыдущих шагов.

Здесь я показываю упрощенную версию файла без проверки, состояния загрузки и обработки ошибок.

import { useState, useRef } from 'react'
import { validateFiles, MAX_FILE_SIZE_S3_ENDPOINT, handleUpload, getPresignedUrls } from '~/utils/fileUploadHelpers'
import { UploadFilesFormUI } from './UploadFilesFormUI'
import { type ShortFileProp } from '~/utils/types'
 
type UploadFilesFormProps = {
  onUploadSuccess: () => void
}
 
export function UploadFilesS3PresignedUrl({ onUploadSuccess }: UploadFilesFormProps) {
  const fileInputRef = useRef<HTMLInputElement | null>(null)
  const [isLoading, setIsLoading] = useState(false)
 
  const uploadToServer = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    // get File[] from FileList
    const files = Object.values(fileInputRef.current.files)
    // validate files
    const filesInfo: ShortFileProp[] = files.map((file) => ({
      originalFileName: file.name,
      fileSize: file.size,
    }))
 
    const presignedUrls = await getPresignedUrls(filesInfo)
 
    // upload files to s3 endpoint directly and save file info to db
    await handleUpload(files, presignedUrls, onUploadSuccess)
 
    setIsLoading(false)
  }
 
  return (
    <UploadFilesFormUI
      isLoading={isLoading}
      fileInputRef={fileInputRef}
      uploadToServer={uploadToServer}
      maxFileSize={MAX_FILE_SIZE_S3_ENDPOINT}
    />
  )
}

BackEnd: загрузка файлов с использованием заранее заданных URL-адресов.

1. Создайте вспомогательную функцию для создания заранее назначенных URL-адресов для загрузки файлов на S3.

/**
 * Generate presigned urls for uploading files to S3
 * @param files files to upload
 * @returns promise with array of presigned urls
 */
export async function createPresignedUrlToUpload({
  bucketName,
  fileName,
  expiry = 60 * 60, // 1 hour
}: {
  bucketName: string
  fileName: string
  expiry?: number
}) {
  // Create bucket if it doesn't exist
  await createBucketIfNotExists(bucketName)
 
  return await s3Client.presignedPutObject(bucketName, fileName, expiry)
}

2. Создайте маршрут API для отправки запросов в S3 для создания заранее заданных URL-адресов для каждого файла.

В этом подходе мы не используем formidable для анализа входящего запроса, поэтому нам не нужно отключать встроенный парсер тела Next.js. Мы можем использовать парсер тела по умолчанию.

Алгоритм генерации заранее заданных URL-адресов для загрузки файлов на S3:

  • Получите информацию о файлах из тела запроса.
  • Проверьте, есть ли файлы для загрузки.
  • Создайте пустой массив для хранения заданных URL-адресов.

Для каждого файла:

  • Создайте уникальное имя файла, используя библиотеку nanoid.
  • Получите заданный URL-адрес с помощью функции createPresignedUrlToUpload.
  • Добавьте заданный URL-адрес в массив.

Затем верните массив заранее заданных URL-адресов в ответ клиенту.

import type { NextApiRequest, NextApiResponse } from 'next'
import type { ShortFileProp, PresignedUrlProp } from '~/utils/types'
import { createPresignedUrlToUpload } from '~/utils/s3-file-management'
import { env } from '~/env'
import { nanoid } from 'nanoid'
 
const bucketName = env.S3_BUCKET_NAME
const expiry = 60 * 60 // 24 hours
 
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    res.status(405).json({ message: 'Only POST requests are allowed' })
    return
  }
  // get the files from the request body
  const files = req.body as ShortFileProp[]
 
  if (!files?.length) {
    res.status(400).json({ message: 'No files to upload' })
    return
  }
 
  const presignedUrls = [] as PresignedUrlProp[]
 
  if (files?.length) {
    // use Promise.all to get all the presigned urls in parallel
    await Promise.all(
      // loop through the files
      files.map(async (file) => {
        const fileName = `${nanoid(5)}-${file?.originalFileName}`
 
        // get presigned url using s3 sdk
        const url = await createPresignedUrlToUpload({
          bucketName,
          fileName,
          expiry,
        })
        // add presigned url to the list
        presignedUrls.push({
          fileNameInBucket: fileName,
          originalFileName: file.originalFileName,
          fileSize: file.fileSize,
          url,
        })
      })
    )
  }
 
  res.status(200).json(presignedUrls)
}

3. Создайте маршрут API для сохранения информации о файле в базе данных с помощью Prisma.

Алгоритм сохранения информации о файле в базе данных:

  • Получите информацию о файле из тела запроса.
  • Сохраните информацию о файле в базе данных, используя метод Prisma file.create.
  • Вернуть статус и сообщение в ответ клиенту.
import type { NextApiRequest, NextApiResponse } from 'next'
import { env } from '~/env'
import { db } from '~/server/db'
import type { PresignedUrlProp, FileInDBProp } from '~/utils/types'
 
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    res.status(405).json({ message: 'Only POST requests are allowed' })
    return
  }
 
  const presignedUrls = req.body as PresignedUrlProp[]
 
  // Get the file name in bucket from the database
  const saveFilesInfo = await db.file.createMany({
    data: presignedUrls.map((file: FileInDBProp) => ({
      bucket: env.S3_BUCKET_NAME,
      fileName: file.fileNameInBucket,
      originalName: file.originalFileName,
      size: file.fileSize,
    })),
  })
 
  if (saveFilesInfo) {
    res.status(200).json({ message: 'Files saved successfully' })
  } else {
    res.status(404).json({ message: 'Files not found' })
  }
}

2.2 Загрузка файлов по заранее заданным URL-адресам

Чтобы скачать файлы:

  • Пользователь отправляет запрос GET с идентификатором файла на маршрут API для получения файла.
  • Маршрут API отправляет запрос в базу данных для получения имени файла и получает имя файла.
  • Маршрут API отправляет запрос S3 на создание заранее назначенного URL-адреса для файла и получает предварительно назначенный URL-адрес.
  • Маршрут API отправляет заранее заданный URL-адрес клиенту.
  • Клиент загружает файл непосредственно с S3, используя заранее заданный URL-адрес.

Frontend: загрузка файлов по заранее заданным URL-адресам.

Для загрузки файлов мы создадим функцию downloadFile внутри компонента FileItem. Функция отправляет запрос GET на маршрут API, чтобы получить заранее назначенный URL-адрес файла от S3. Файл возвращается пользователю по маршруту API.

async function getPresignedUrl(file: FileProps) {
  const response = await fetch(`/api/files/download/presignedUrl/${file.id}`)
  return (await response.json()) as string
}
 
const downloadFile = async (file: FileProps) => {
  const presignedUrl = await getPresignedUrl(file)
  window.open(presignedUrl, '_blank')
}

BackEnd — загрузка файлов с использованием заранее заданных URL-адресов.

1. Создайте вспомогательную функцию для создания заранее назначенных URL-адресов для загрузки файлов с S3.

Чтобы загрузить файлы с S3, мы создадим служебную функцию createPresignedUrlToDownload, которая использует метод presignedGetObject клиента Minio для генерации заранее заданного URL-адреса для файла.

export async function createPresignedUrlToDownload({
  bucketName,
  fileName,
  expiry = 60 * 60, // 1 hour
}: {
  bucketName: string
  fileName: string
  expiry?: number
}) {
  return await s3Client.presignedGetObject(bucketName, fileName, expiry)
}

2. Создайте маршрут API для отправки запросов в S3 для создания заранее заданных URL-адресов для каждого файла.

Алгоритм генерации заранее прописанных URL для скачивания файлов с S3:

  • Получите имя файла из базы данных, используя идентификатор файла.
  • Получите заданный URL-адрес с помощью функции createPresignedUrlToDownload.
  • Верните заранее заданный URL-адрес в ответ клиенту.
import type { NextApiRequest, NextApiResponse } from 'next'
import { createPresignedUrlToDownload } from '~/utils/s3-file-management'
import { db } from '~/server/db'
import { env } from '~/env'
 
/**
 * This route is used to get presigned url for downloading file from S3
 */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'GET') {
    res.status(405).json({ message: 'Only GET requests are allowed' })
  }
 
  const { id } = req.query
 
  if (!id || typeof id !== 'string') {
    return res.status(400).json({ message: 'Missing or invalid id' })
  }
 
  // Get the file name in bucket from the database
  const fileObject = await db.file.findUnique({
    where: {
      id,
    },
    select: {
      fileName: true,
    },
  })
 
  if (!fileObject) {
    return res.status(404).json({ message: 'Item not found' })
  }
 
  // Get presigned url from s3 storage
  const presignedUrl = await createPresignedUrlToDownload({
    bucketName: env.S3_BUCKET_NAME,
    fileName: fileObject?.fileName,
  })
 
  res.status(200).json(presignedUrl)
}

3. Удалить файлы из S3

Frontend — удаление файлов из S3

Удаление файла можно выполнить с помощью запроса DELETE к маршруту API. Я создал функцию удаления в компоненте FileItem, которая отправляет запрос DELETE на маршрут API для удаления файла из корзины S3 и базы данных.

Алгоритм удаления файлов с S3:

  • Немедленно удалите файл из списка файлов на клиенте.
  • Отправьте запрос DELETE на маршрут API, чтобы удалить файл из корзины S3 и базы данных.
  • Получите файлы после удаления.

Вот пример функции удаления в компоненте FileItem:

async function deleteFile(id: string) {
  // remove file from the list of files on the client
  setFiles((files: FileProps[]) =>
    files.map((file: FileProps) => (file.id === id ? { ...file, isDeleting: true } : file))
  )
  try {
    // delete file request to the server
    await fetch(`/api/files/delete/${id}`, {
      method: 'DELETE',
    })
    // fetch files after deleting
    await fetchFiles()
  } catch (error) {
    console.error(error)
    alert('Failed to delete file')
  } finally {
    // remove isDeleting flag from the file
    setFiles((files: FileProps[]) =>
      files.map((file: FileProps) => (file.id === id ? { ...file, isDeleting: false } : file))
    )
  }
}

BackEnd – удаление файлов из S3

1. Создайте служебную функцию для удаления файлов из S3.

Чтобы удалить файлы из S3, мы создадим служебную функцию deleteFileFromBucket, которая использует метод removeObject клиента Minio для удаления файла из корзины S3.

/**
 * Delete file from S3 bucket
 * @param bucketName name of the bucket
 * @param fileName name of the file
 * @returns true if file was deleted, false if not
 */
export async function deleteFileFromBucket({ bucketName, fileName }: { bucketName: string; fileName: string }) {
  try {
    await s3Client.removeObject(bucketName, fileName)
  } catch (error) {
    console.error(error)
    return false
  }
  return true
}

2. Создайте маршрут API для удаления файлов из S3.

Вот пример маршрута API для удаления файлов из S3. Создайте файл delete/[id].ts в папке pages/api/files/delete. Этот файл удалит файл из корзины S3 и базы данных.

Алгоритм удаления файлов с S3:

  • Получите имя файла в корзине из базы данных, используя идентификатор файла.
  • Проверьте, существует ли файл в базе данных.
  • Удалите файл из корзины S3 с помощью функции deleteFileFromBucket.
  • Удалите файл из базы данных с помощью метода Prisma file.delete.
  • Вернуть статус и сообщение в ответ клиенту.
import type { NextApiRequest, NextApiResponse } from 'next'
import { deleteFileFromBucket } from '~/utils/s3-file-management'
import { db } from '~/server/db'
import { env } from '~/env'
 
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'DELETE') {
    res.status(405).json({ message: 'Only DELETE requests are allowed' })
  }
  const { id } = req.query
 
  if (!id || typeof id !== 'string') {
    return res.status(400).json({ message: 'Missing or invalid id' })
  }
 
  // Get the file name in bucket from the database
  const fileObject = await db.file.findUnique({
    where: {
      id,
    },
    select: {
      fileName: true,
    },
  })
 
  if (!fileObject) {
    return res.status(404).json({ message: 'Item not found' })
  }
  // Delete the file from the bucket
  await deleteFileFromBucket({
    bucketName: env.S3_BUCKET_NAME,
    fileName: fileObject?.fileName,
  })
  // Delete the file from the database
  const deletedItem = await db.file.delete({
    where: {
      id,
    },
  })
 
  if (deletedItem) {
    res.status(200).json({ message: 'Item deleted successfully' })
  } else {
    res.status(404).json({ message: 'Item not found' })
  }
}

Развертывание локально с помощью Docker Compose.

Чтобы развернуть приложение локально, мы будем использовать Docker Compose для запуска приложения Next.js, PostgreSQL и MinIO S3. Я объяснил, как настроить файл Docker Compose, в предыдущей статье.

Здесь я хочу упомянуть некоторые изменения в файле Docker Compose, которые нам необходимо внести, поскольку нам нужно использовать заранее заданные URL-адреса.

Ключевой момент: когда внутри Docker-контейнера создается заранее заданный URL-адрес, он будет иметь тот же хост, который мы установили в файле компоновки Docker. Например, если у нас есть среда: - S3_ENDPOINT=minio в файле компоновки Docker, заранее назначенный URL-адрес будет иметь хост minio. Все заранее назначенные URL-адреса будут иметь вид http://minio:9000/bucket-name/file-name. Эти URL-адреса не будут работать на стороне клиента (если мы не добавим minio в файл хостов). Здесь мы также не можем использовать localhost, потому что localhost будет хостом контейнера, а не хостом клиента.

Решение — использовать kubernetes.docker.internal в качестве конечной точки Minio S3. Это специальное DNS-имя, которое преобразуется в хост-компьютер изнутри контейнера Docker. Он доступен в Docker для Mac и Docker для Windows.

Также убедитесь, что kubernetes.docker.internal находится в файле хостов (он должен быть там по умолчанию). Тогда заранее назначенные URL-адреса будут иметь вид http://kubernetes.docker.internal:9000/bucket-name/file-name и будут работать на стороне клиента.

Вот полный файл Docker Compose:

version: '3.9'
name: nextjs-postgres-s3minio
services:
  web:
    container_name: nextjs
    build:
      context: ../
      dockerfile: compose/web.Dockerfile
      args:
        NEXT_PUBLIC_CLIENTVAR: 'clientvar'
    ports:
      - 3000:3000
    volumes:
      - ../:/app
    environment:
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/myapp-db?schema=public
      - S3_ENDPOINT=kubernetes.docker.internal
      - S3_PORT=9000
      - S3_ACCESS_KEY=minio
      - S3_SECRET_KEY=miniosecret
      - S3_BUCKET_NAME=s3bucket
    depends_on:
      - db
      - minio
    # Optional, if you want to apply db schema from prisma to postgres
    command: sh ./compose/db-push-and-start.sh
  db:
    image: postgres:15.3
    container_name: postgres
    ports:
      - 5432:5432
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myapp-db
    volumes:
      - postgres-data:/var/lib/postgresql/data
    restart: unless-stopped
  minio:
    container_name: s3minio
    image: bitnami/minio:latest
    ports:
      - '9000:9000'
      - '9001:9001'
    volumes:
      - minio_storage:/data
volumes:
  postgres-data:
  minio_storage:

Чтобы запустить приложение, используйте следующую команду:

docker-compose -f compose/docker-compose.yml --env-file .env up

После запуска приложения вы сможете получить к нему доступ по адресу http://localhost:3000. Minio S3 будет доступен по адресу http://localhost:9000. Для входа в систему вы можете использовать ключ доступа minio и секретный ключ minisecret.

Вы увидите что-то вроде этого:

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

Вы можете проверить живую демо-версию приложения здесь. Он доступен всем, поэтому все файлы видны всем. Вы можете загружать, скачивать и удалять любые файлы. Пожалуйста, не загружайте конфиденциальную информацию.

Заключение

В этой статье мы узнали, как загружать и скачивать файлы с помощью Next.js, PostgreSQL и Minio S3. Мы также узнали, как использовать заранее назначенные URL-адреса для загрузки и скачивания файлов непосредственно из клиента в S3. Мы создали приложение для загрузки, скачивания и удаления файлов с S3 по заранее заданным URL-адресам. Мы также узнали, как развернуть приложение локально с помощью Docker Compose.

Надеюсь, вы нашли эту статью полезной. Если у вас есть какие-либо вопросы или предложения, не стесняйтесь оставлять комментарии.

Источник:

#PostgreSQL #NestJS
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу