├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── compose ├── .dockerignore ├── db-push-and-start.sh ├── docker-compose.yml └── web.Dockerfile ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── prettier.config.js ├── prisma └── schema.prisma ├── public └── favicon.ico ├── src ├── components │ ├── FileItem.tsx │ ├── FilesContainer.tsx │ ├── LoadSpinner.tsx │ └── UploadFilesForm │ │ ├── UploadFilesFormUI.tsx │ │ ├── UploadFilesRoute.tsx │ │ └── UploadFilesS3PresignedUrl.tsx ├── env.js ├── pages │ ├── _app.tsx │ ├── api │ │ └── files │ │ │ ├── delete │ │ │ └── [id].ts │ │ │ ├── download │ │ │ ├── presignedUrl │ │ │ │ └── [id].ts │ │ │ └── smallFiles │ │ │ │ └── [id].ts │ │ │ ├── index.ts │ │ │ └── upload │ │ │ ├── presignedUrl.ts │ │ │ ├── saveFileInfo.ts │ │ │ └── smallFiles.ts │ └── index.tsx ├── server │ └── db.ts ├── styles │ └── globals.css └── utils │ ├── fileUploadHelpers.ts │ ├── s3-file-management.ts │ └── types.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Since the ".env" file is gitignored, you can use the ".env.example" file to 2 | # build a new ".env" file when you clone the repo. Keep this file up-to-date 3 | # when you add new variables to `.env`. 4 | 5 | # This file will be committed to version control, so make sure not to have any 6 | # secrets in it. If you are cloning this repo, create a copy of this file named 7 | # ".env" and populate it with your secrets. 8 | 9 | # When adding additional environment variables, the schema in "/src/env.js" 10 | # should be updated accordingly. 11 | 12 | # Prisma 13 | # https://www.prisma.io/docs/reference/database-reference/connection-urls#env 14 | DATABASE_URL="postgresql://postgres:xxxxxxxx@xxxxxxxxx:5432/postgres?schema=public" 15 | 16 | 17 | S3_ENDPOINT="s3.amazonaws.com" 18 | S3_PORT="9000" 19 | S3_ACCESS_KEY="" 20 | S3_SECRET_KEY="" 21 | S3_BUCKET_NAME="" -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | project: true, 6 | }, 7 | plugins: ["@typescript-eslint"], 8 | extends: [ 9 | "next/core-web-vitals", 10 | "plugin:@typescript-eslint/recommended-type-checked", 11 | "plugin:@typescript-eslint/stylistic-type-checked", 12 | ], 13 | rules: { 14 | // These opinionated rules are enabled in stylistic-type-checked above. 15 | // Feel free to reconfigure them to your own preference. 16 | "@typescript-eslint/array-type": "off", 17 | "@typescript-eslint/consistent-type-definitions": "off", 18 | 19 | "@typescript-eslint/consistent-type-imports": [ 20 | "warn", 21 | { 22 | prefer: "type-imports", 23 | fixStyle: "inline-type-imports", 24 | }, 25 | ], 26 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 27 | "@typescript-eslint/require-await": "off", 28 | "@typescript-eslint/no-misused-promises": [ 29 | "error", 30 | { 31 | checksVoidReturn: { attributes: false }, 32 | }, 33 | ], 34 | }, 35 | }; 36 | 37 | module.exports = config; 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 35 | .env 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Example of a File Storage With Next.js, PostgreSQL, and Minio S3 2 | 3 | This is the code for the articles: 4 | 5 | - [Building a Local Development Environment: Running a Next.js Full-Stack App with PostgreSQL and Minio S3 Using Docker](https://blog.alexefimenko.com/posts/nextjs-postgres-s3-locally) 6 | 7 | - [Building a file storage with Next.js, PostgreSQL, and Minio S3](https://blog.alexefimenko.com/posts/file-storage-nextjs-postgres-s3) 8 | 9 | To run the application, use the following command: 10 | 11 | ```bash copy 12 | docker-compose -f compose/docker-compose.yml --env-file .env up 13 | ``` 14 | 15 | After you run the application, you can access it at `http://localhost:3000`. 16 | The Minio S3 will be available at `http://localhost:9000`. You can use the access key `minio` and the secret key `miniosecret` to log in. 17 | 18 | You will see something like this: 19 | 20 | ![File storage app](https://blog.alexefimenko.com/blog-assets/file-storage-nextjs-postgres-s3/app-screenshot.png) 21 | 22 | The application is built with Next.js, PostgreSQL, and Minio S3. It allows you to upload, download, and delete files. The application is built with Docker, so you can run it locally without installing any dependencies. 23 | -------------------------------------------------------------------------------- /compose/.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | Dockerfile 3 | .dockerignore 4 | .next 5 | .git 6 | .gitignore 7 | node_modules 8 | npm-debug.log 9 | README.md -------------------------------------------------------------------------------- /compose/db-push-and-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npx prisma generate 3 | npx prisma db push 4 | npm run dev -------------------------------------------------------------------------------- /compose/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | name: nextjs-postgres-s3minio 3 | services: 4 | web: 5 | container_name: nextjs 6 | build: 7 | context: ../ 8 | dockerfile: compose/web.Dockerfile 9 | args: 10 | NEXT_PUBLIC_CLIENTVAR: "clientvar" 11 | ports: 12 | - 3000:3000 13 | volumes: 14 | - ../:/app 15 | environment: 16 | - DATABASE_URL=postgresql://postgres:postgres@db:5432/myapp-db?schema=public 17 | # Be sure kubernetes.docker.internal is added to your hosts file 18 | # S3 endpoint might be "minio" but in this case in order to use presigned URLs 19 | # you should add the following to your hosts file: 20 | # 127.0.0.1 minio 21 | # Or use the IP address of your physical machine, localhost will not work for presigned URLs 22 | # https://stackoverflow.com/questions/24319662/from-inside-of-a-docker-container-how-do-i-connect-to-the-localhost-of-the-mach 23 | - S3_ENDPOINT=kubernetes.docker.internal 24 | - S3_PORT=9000 25 | - S3_ACCESS_KEY=minio 26 | - S3_SECRET_KEY=miniosecret 27 | - S3_BUCKET_NAME=s3bucket 28 | depends_on: 29 | - db 30 | - minio 31 | # Optional, if you want to apply db schema from prisma to postgres 32 | command: sh ./compose/db-push-and-start.sh 33 | db: 34 | image: postgres:15.3 35 | container_name: postgres 36 | ports: 37 | - 5432:5432 38 | environment: 39 | POSTGRES_USER: postgres 40 | POSTGRES_PASSWORD: postgres 41 | POSTGRES_DB: myapp-db 42 | volumes: 43 | - postgres-data:/var/lib/postgresql/data 44 | restart: unless-stopped 45 | minio: 46 | container_name: s3minio 47 | image: bitnami/minio:latest 48 | ports: 49 | - "9000:9000" 50 | - "9001:9001" 51 | volumes: 52 | - minio_storage:/data 53 | volumes: 54 | postgres-data: 55 | minio_storage: 56 | -------------------------------------------------------------------------------- /compose/web.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | RUN mkdir app 4 | COPY ../prisma ./app 5 | COPY ../package.json ../package-lock.json ./app/ 6 | WORKDIR /app 7 | 8 | RUN npm ci 9 | 10 | # Start app 11 | CMD ["npm", "run", "dev"] 12 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful 3 | * for Docker builds. 4 | */ 5 | await import("./src/env.js"); 6 | 7 | /** @type {import("next").NextConfig} */ 8 | const config = { 9 | reactStrictMode: true, 10 | output: "standalone", 11 | 12 | /** 13 | * If you are using `appDir` then you must comment the below `i18n` config out. 14 | * 15 | * @see https://github.com/vercel/next.js/issues/41980 16 | */ 17 | i18n: { 18 | locales: ["en"], 19 | defaultLocale: "en", 20 | }, 21 | }; 22 | 23 | export default config; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "local-nextjs-postgres-s3", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "next build", 8 | "db:push": "prisma db push", 9 | "db:studio": "prisma studio", 10 | "dev": "next dev", 11 | "postinstall": "prisma generate", 12 | "lint": "next lint", 13 | "start": "next start" 14 | }, 15 | "dependencies": { 16 | "@prisma/client": "^5.6.0", 17 | "@t3-oss/env-nextjs": "^0.7.1", 18 | "formidable": "^3.5.1", 19 | "minio": "^7.1.3", 20 | "nanoid": "^5.0.4", 21 | "next": "^14.0.4", 22 | "next-connect": "^1.0.0-next.4", 23 | "react": "18.2.0", 24 | "react-dom": "18.2.0", 25 | "zod": "^3.22.4" 26 | }, 27 | "devDependencies": { 28 | "@types/eslint": "^8.44.7", 29 | "@types/formidable": "^3.4.5", 30 | "@types/node": "^18.19.6", 31 | "@types/react": "^18.2.37", 32 | "@types/react-dom": "^18.2.15", 33 | "@typescript-eslint/eslint-plugin": "^6.11.0", 34 | "@typescript-eslint/parser": "^6.11.0", 35 | "autoprefixer": "^10.4.14", 36 | "eslint": "^8.54.0", 37 | "eslint-config-next": "^14.0.4", 38 | "postcss": "^8.4.31", 39 | "prettier": "^3.1.0", 40 | "prettier-plugin-tailwindcss": "^0.5.7", 41 | "prisma": "^5.6.0", 42 | "tailwindcss": "^3.3.5", 43 | "typescript": "^5.1.6" 44 | }, 45 | "ct3aMetadata": { 46 | "initVersion": "7.25.1" 47 | }, 48 | "packageManager": "npm@10.2.1" 49 | } 50 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */ 2 | const config = { 3 | plugins: ["prettier-plugin-tailwindcss"], 4 | }; 5 | 6 | export default config; 7 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x", "linux-musl-openssl-3.0.x", "rhel-openssl-1.0.x"] 7 | } 8 | 9 | datasource db { 10 | provider = "postgresql" 11 | url = env("DATABASE_URL") 12 | } 13 | 14 | model File { 15 | id String @id @default(uuid()) 16 | bucket String 17 | fileName String @unique 18 | originalName String 19 | createdAt DateTime @default(now()) 20 | size Int 21 | } 22 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aleksandr-efimenko/local-nextjs-postgres-s3/8d237ace71dd881b0c1f1232922f24a754cbbaee/public/favicon.ico -------------------------------------------------------------------------------- /src/components/FileItem.tsx: -------------------------------------------------------------------------------- 1 | import { type FileProps } from "~/utils/types"; 2 | import { LoadSpinner } from "./LoadSpinner"; 3 | import { formatBytes } from "~/utils/fileUploadHelpers"; 4 | 5 | type FileItemProps = { 6 | file: FileProps; 7 | fetchFiles: () => Promise; 8 | setFiles: ( 9 | files: FileProps[] | ((files: FileProps[]) => FileProps[]), 10 | ) => void; 11 | downloadUsingPresignedUrl: boolean; 12 | }; 13 | 14 | async function getPresignedUrl(file: FileProps) { 15 | const response = await fetch(`/api/files/download/presignedUrl/${file.id}`); 16 | return (await response.json()) as string; 17 | } 18 | 19 | export function FileItem({ 20 | file, 21 | fetchFiles, 22 | setFiles, 23 | downloadUsingPresignedUrl, 24 | }: FileItemProps) { 25 | async function deleteFile(id: string) { 26 | setFiles((files: FileProps[]) => 27 | files.map((file: FileProps) => 28 | file.id === id ? { ...file, isDeleting: true } : file, 29 | ), 30 | ); 31 | try { 32 | // delete file request to the server 33 | await fetch(`/api/files/delete/${id}`, { 34 | method: "DELETE", 35 | }); 36 | // fetch files after deleting 37 | await fetchFiles(); 38 | } catch (error) { 39 | console.error(error); 40 | alert("Failed to delete file"); 41 | } finally { 42 | setFiles((files: FileProps[]) => 43 | files.map((file: FileProps) => 44 | file.id === id ? { ...file, isDeleting: false } : file, 45 | ), 46 | ); 47 | } 48 | } 49 | 50 | // Depending on the upload mode, we either download the file using the presigned url from S3 or the Nextjs API endpoint. 51 | const downloadFile = async (file: FileProps) => { 52 | if (downloadUsingPresignedUrl) { 53 | const presignedUrl = await getPresignedUrl(file); 54 | window.open(presignedUrl, "_blank"); 55 | } else { 56 | window.open(`/api/files/download/smallFiles/${file.id}`, "_blank"); 57 | } 58 | }; 59 | 60 | return ( 61 |
  • 62 | 68 | 69 |
    70 | {formatBytes(file.fileSize)} 71 | 72 | 81 |
    82 | 83 | {file.isDeleting && ( 84 |
    85 | 86 |
    87 | )} 88 |
  • 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /src/components/FilesContainer.tsx: -------------------------------------------------------------------------------- 1 | import { type FilesListProps } from "~/utils/types"; 2 | import { FileItem } from "./FileItem"; 3 | 4 | export function FilesContainer({ 5 | files, 6 | fetchFiles, 7 | setFiles, 8 | downloadUsingPresignedUrl, 9 | }: FilesListProps) { 10 | if (files.length === 0) { 11 | return ( 12 |
    13 |

    No files uploaded yet

    14 |
    15 | ); 16 | } 17 | 18 | return ( 19 |
    20 |

    21 | Last {files.length} uploaded file{files.length > 1 ? "s" : ""} 22 |

    23 | 34 |
    35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/LoadSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { type LoadSpinnerProps } from "~/utils/types"; 2 | 3 | export function LoadSpinner({ size = "medium" }: LoadSpinnerProps) { 4 | const sizeClasses = { 5 | small: "h-6 w-6", 6 | medium: "h-16 w-16", 7 | large: "h-24 w-24", 8 | }; 9 | return ( 10 |
    11 |
    14 |
    15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/UploadFilesForm/UploadFilesFormUI.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { LoadSpinner } from "../LoadSpinner"; 3 | import { type UploadFilesFormUIProps } from "~/utils/types"; 4 | 5 | const GIT_HUB_REPO_LINK = 6 | "https://github.com/aleksandr-efimenko/local-nextjs-postgres-s3"; 7 | 8 | export function UploadFilesFormUI({ 9 | isLoading, 10 | fileInputRef, 11 | uploadToServer, 12 | maxFileSize, 13 | }: UploadFilesFormUIProps) { 14 | return ( 15 |
    19 |

    20 | File upload example using Next.js, MinIO S3, Prisma and PostgreSQL 21 |

    22 |

    {`Total file(s) size should not exceed ${maxFileSize} MB`}

    23 | 27 | GitHub repo 28 | 29 | {isLoading ? ( 30 | 31 | ) : ( 32 |
    33 | 41 | 48 |
    49 | )} 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/UploadFilesForm/UploadFilesRoute.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from "react"; 2 | import { validateFiles, createFormData } from "~/utils/fileUploadHelpers"; 3 | import { MAX_FILE_SIZE_NEXTJS_ROUTE } from "~/utils/fileUploadHelpers"; 4 | import { UploadFilesFormUI } from "./UploadFilesFormUI"; 5 | 6 | type UploadFilesFormProps = { 7 | onUploadSuccess: () => void; 8 | }; 9 | 10 | export function UploadFilesRoute({ onUploadSuccess }: UploadFilesFormProps) { 11 | const fileInputRef = useRef(null); 12 | const [isLoading, setIsLoading] = useState(false); 13 | 14 | const uploadToServer = async (event: React.FormEvent) => { 15 | event.preventDefault(); 16 | if (!fileInputRef.current?.files?.length) { 17 | alert("Please, select file you want to upload"); 18 | return; 19 | } 20 | const files = Object.values(fileInputRef.current?.files); 21 | const filesInfo = files.map((file) => ({ 22 | originalFileName: file.name, 23 | fileSize: file.size, 24 | })); 25 | 26 | const filesValidationResult = validateFiles( 27 | filesInfo, 28 | MAX_FILE_SIZE_NEXTJS_ROUTE, 29 | ); 30 | if (filesValidationResult) { 31 | alert(filesValidationResult); 32 | return; 33 | } 34 | 35 | setIsLoading(true); 36 | 37 | const formData = createFormData(files); 38 | const response = await fetch("/api/files/upload/smallFiles", { 39 | method: "POST", 40 | body: formData, 41 | }); 42 | const body = (await response.json()) as { 43 | status: "ok" | "fail"; 44 | message: string; 45 | }; 46 | if (body.status === "ok") { 47 | onUploadSuccess(); 48 | } else { 49 | alert(body.message); 50 | } 51 | setIsLoading(false); 52 | }; 53 | 54 | return ( 55 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/components/UploadFilesForm/UploadFilesS3PresignedUrl.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from "react"; 2 | import { 3 | validateFiles, 4 | MAX_FILE_SIZE_S3_ENDPOINT, 5 | handleUpload, 6 | getPresignedUrls, 7 | } from "~/utils/fileUploadHelpers"; 8 | import { UploadFilesFormUI } from "./UploadFilesFormUI"; 9 | import { type ShortFileProp } from "~/utils/types"; 10 | 11 | type UploadFilesFormProps = { 12 | onUploadSuccess: () => void; 13 | }; 14 | 15 | export function UploadFilesS3PresignedUrl({ 16 | onUploadSuccess, 17 | }: UploadFilesFormProps) { 18 | const fileInputRef = useRef(null); 19 | const [isLoading, setIsLoading] = useState(false); 20 | 21 | const uploadToServer = async (event: React.FormEvent) => { 22 | event.preventDefault(); 23 | // check if files are selected 24 | if (!fileInputRef.current?.files?.length) { 25 | alert("Please, select file you want to upload"); 26 | return; 27 | } 28 | // get File[] from FileList 29 | const files = Object.values(fileInputRef.current.files); 30 | // validate files 31 | const filesInfo: ShortFileProp[] = files.map((file) => ({ 32 | originalFileName: file.name, 33 | fileSize: file.size, 34 | })); 35 | 36 | const filesValidationResult = validateFiles( 37 | filesInfo, 38 | MAX_FILE_SIZE_S3_ENDPOINT, 39 | ); 40 | if (filesValidationResult) { 41 | alert(filesValidationResult); 42 | return; 43 | } 44 | setIsLoading(true); 45 | 46 | const presignedUrls = await getPresignedUrls(filesInfo); 47 | if (!presignedUrls?.length) { 48 | alert("Something went wrong, please try again later"); 49 | return; 50 | } 51 | 52 | // upload files to s3 endpoint directly and save file info to db 53 | await handleUpload(files, presignedUrls, onUploadSuccess); 54 | 55 | setIsLoading(false); 56 | }; 57 | 58 | return ( 59 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/env.js: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import { z } from "zod"; 3 | 4 | export const env = createEnv({ 5 | /** 6 | * Specify your server-side environment variables schema here. This way you can ensure the app 7 | * isn't built with invalid env vars. 8 | */ 9 | server: { 10 | DATABASE_URL: z 11 | .string() 12 | .url() 13 | .refine( 14 | (str) => !str.includes("YOUR_MYSQL_URL_HERE"), 15 | "You forgot to change the default URL", 16 | ), 17 | NODE_ENV: z 18 | .enum(["development", "test", "production"]) 19 | .default("development"), 20 | S3_ENDPOINT: z.string(), 21 | S3_PORT: z.string().optional(), 22 | S3_ACCESS_KEY: z.string(), 23 | S3_SECRET_KEY: z.string(), 24 | S3_BUCKET_NAME: z.string(), 25 | S3_USE_SSL: z.string().optional(), 26 | }, 27 | 28 | /** 29 | * Specify your client-side environment variables schema here. This way you can ensure the app 30 | * isn't built with invalid env vars. To expose them to the client, prefix them with 31 | * `NEXT_PUBLIC_`. 32 | */ 33 | client: { 34 | // NEXT_PUBLIC_CLIENTVAR: z.string(), 35 | }, 36 | 37 | /** 38 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. 39 | * middlewares) or client-side so we need to destruct manually. 40 | */ 41 | runtimeEnv: { 42 | DATABASE_URL: process.env.DATABASE_URL, 43 | NODE_ENV: process.env.NODE_ENV, 44 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, 45 | S3_ENDPOINT: process.env.S3_ENDPOINT, 46 | S3_PORT: process.env.S3_PORT, 47 | S3_ACCESS_KEY: process.env.S3_ACCESS_KEY, 48 | S3_SECRET_KEY: process.env.S3_SECRET_KEY, 49 | S3_BUCKET_NAME: process.env.S3_BUCKET_NAME, 50 | S3_USE_SSL: process.env.S3_USE_SSL, 51 | }, 52 | /** 53 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially 54 | * useful for Docker builds. 55 | */ 56 | skipValidation: !!process.env.SKIP_ENV_VALIDATION, 57 | /** 58 | * Makes it so that empty strings are treated as undefined. 59 | * `SOME_VAR: z.string()` and `SOME_VAR=''` will throw an error. 60 | */ 61 | emptyStringAsUndefined: true, 62 | }); 63 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { type AppType } from "next/dist/shared/lib/utils"; 2 | 3 | import "~/styles/globals.css"; 4 | 5 | const MyApp: AppType = ({ Component, pageProps }) => { 6 | return ; 7 | }; 8 | 9 | export default MyApp; 10 | -------------------------------------------------------------------------------- /src/pages/api/files/delete/[id].ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { deleteFileFromBucket } from "~/utils/s3-file-management"; 3 | import { db } from "~/server/db"; 4 | import { env } from "~/env"; 5 | 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== "DELETE") { 11 | res.status(405).json({ message: "Only DELETE requests are allowed" }); 12 | } 13 | const { id } = req.query; 14 | 15 | if (!id || typeof id !== "string") { 16 | return res.status(400).json({ message: "Missing or invalid id" }); 17 | } 18 | 19 | // Get the file name in bucket from the database 20 | const fileObject = await db.file.findUnique({ 21 | where: { 22 | id, 23 | }, 24 | select: { 25 | fileName: true, 26 | }, 27 | }); 28 | 29 | if (!fileObject) { 30 | return res.status(404).json({ message: "Item not found" }); 31 | } 32 | // Delete the file from the bucket 33 | await deleteFileFromBucket({ 34 | bucketName: env.S3_BUCKET_NAME, 35 | fileName: fileObject?.fileName, 36 | }); 37 | // Delete the file from the database 38 | const deletedItem = await db.file.delete({ 39 | where: { 40 | id, 41 | }, 42 | }); 43 | 44 | if (deletedItem) { 45 | res.status(200).json({ message: "Item deleted successfully" }); 46 | } else { 47 | res.status(404).json({ message: "Item not found" }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/pages/api/files/download/presignedUrl/[id].ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { createPresignedUrlToDownload } from "~/utils/s3-file-management"; 3 | import { db } from "~/server/db"; 4 | import { env } from "~/env"; 5 | 6 | /** 7 | * This route is used to get presigned url for downloading file from S3 8 | */ 9 | export default async function handler( 10 | req: NextApiRequest, 11 | res: NextApiResponse, 12 | ) { 13 | if (req.method !== "GET") { 14 | res.status(405).json({ message: "Only GET requests are allowed" }); 15 | } 16 | 17 | const { id } = req.query; 18 | 19 | if (!id || typeof id !== "string") { 20 | return res.status(400).json({ message: "Missing or invalid id" }); 21 | } 22 | 23 | // Get the file name in bucket from the database 24 | const fileObject = await db.file.findUnique({ 25 | where: { 26 | id, 27 | }, 28 | select: { 29 | fileName: true, 30 | }, 31 | }); 32 | 33 | if (!fileObject) { 34 | return res.status(404).json({ message: "Item not found" }); 35 | } 36 | 37 | // Get presigned url from s3 storage 38 | const presignedUrl = await createPresignedUrlToDownload({ 39 | bucketName: env.S3_BUCKET_NAME, 40 | fileName: fileObject?.fileName, 41 | }); 42 | 43 | res.status(200).json(presignedUrl); 44 | } 45 | -------------------------------------------------------------------------------- /src/pages/api/files/download/smallFiles/[id].ts: -------------------------------------------------------------------------------- 1 | import { type NextApiRequest, type NextApiResponse } from "next"; 2 | import { getFileFromBucket } from "~/utils/s3-file-management"; 3 | import { env } from "~/env"; 4 | import { db } from "~/server/db"; 5 | 6 | async function handler(req: NextApiRequest, res: NextApiResponse) { 7 | const { id } = req.query; 8 | if (typeof id !== "string") 9 | return res.status(400).json({ message: "Invalid request" }); 10 | 11 | // get the file name and original name from the database 12 | const fileObject = await db.file.findUnique({ 13 | where: { 14 | id, 15 | }, 16 | select: { 17 | fileName: true, 18 | originalName: true, 19 | }, 20 | }); 21 | if (!fileObject) { 22 | return res.status(404).json({ message: "Item not found" }); 23 | } 24 | // get the file from the bucket and pipe it to the response object 25 | const data = await getFileFromBucket({ 26 | bucketName: env.S3_BUCKET_NAME, 27 | fileName: fileObject?.fileName, 28 | }); 29 | 30 | if (!data) { 31 | return res.status(404).json({ message: "Item not found" }); 32 | } 33 | // set header for download file 34 | res.setHeader( 35 | "content-disposition", 36 | `attachment; filename="${fileObject?.originalName}"`, 37 | ); 38 | 39 | // pipe the data to the res object 40 | data.pipe(res); 41 | } 42 | 43 | export default handler; 44 | -------------------------------------------------------------------------------- /src/pages/api/files/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import type { FileProps } from "~/utils/types"; 3 | import { db } from "~/server/db"; 4 | 5 | const LIMIT_FILES = 10; 6 | 7 | const handler = async (req: NextApiRequest, res: NextApiResponse) => { 8 | // Get 10 latest files from the database 9 | // For simplicity, we are not using pagination 10 | // If you want to implement pagination, you can use skip and take 11 | // https://www.prisma.io/docs/concepts/components/prisma-client/pagination#skip-and-take 12 | 13 | const files = await db.file.findMany({ 14 | take: LIMIT_FILES, 15 | orderBy: { 16 | createdAt: "desc", 17 | }, 18 | select: { 19 | id: true, 20 | originalName: true, 21 | size: true, 22 | }, 23 | }); 24 | // The database type is a bit different from the frontend type 25 | // Make the array of files compatible with the frontend type FileProps 26 | const filesWithProps: FileProps[] = files.map((file) => ({ 27 | id: file.id, 28 | originalFileName: file.originalName, 29 | fileSize: file.size, 30 | })); 31 | 32 | return res.status(200).json(filesWithProps); 33 | }; 34 | 35 | export default handler; 36 | -------------------------------------------------------------------------------- /src/pages/api/files/upload/presignedUrl.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import type { ShortFileProp, PresignedUrlProp } from "~/utils/types"; 3 | import { createPresignedUrlToUpload } from "~/utils/s3-file-management"; 4 | import { env } from "~/env"; 5 | import { nanoid } from "nanoid"; 6 | 7 | const bucketName = env.S3_BUCKET_NAME; 8 | const expiry = 60 * 60; // 24 hours 9 | 10 | export default async function handler( 11 | req: NextApiRequest, 12 | res: NextApiResponse, 13 | ) { 14 | if (req.method !== "POST") { 15 | res.status(405).json({ message: "Only POST requests are allowed" }); 16 | return; 17 | } 18 | // get the files from the request body 19 | const files = req.body as ShortFileProp[]; 20 | 21 | if (!files?.length) { 22 | res.status(400).json({ message: "No files to upload" }); 23 | return; 24 | } 25 | 26 | const presignedUrls = [] as PresignedUrlProp[]; 27 | 28 | if (files?.length) { 29 | // use Promise.all to get all the presigned urls in parallel 30 | await Promise.all( 31 | // loop through the files 32 | files.map(async (file) => { 33 | const fileName = `${nanoid(5)}-${file?.originalFileName}`; 34 | 35 | // get presigned url using s3 sdk 36 | const url = await createPresignedUrlToUpload({ 37 | bucketName, 38 | fileName, 39 | expiry, 40 | }); 41 | // add presigned url to the list 42 | presignedUrls.push({ 43 | fileNameInBucket: fileName, 44 | originalFileName: file.originalFileName, 45 | fileSize: file.fileSize, 46 | url, 47 | }); 48 | }), 49 | ); 50 | } 51 | 52 | res.status(200).json(presignedUrls); 53 | } 54 | -------------------------------------------------------------------------------- /src/pages/api/files/upload/saveFileInfo.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { env } from "~/env"; 3 | import { db } from "~/server/db"; 4 | import type { PresignedUrlProp, FileInDBProp } from "~/utils/types"; 5 | 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | if (req.method !== "POST") { 11 | res.status(405).json({ message: "Only POST requests are allowed" }); 12 | return; 13 | } 14 | 15 | const presignedUrls = req.body as PresignedUrlProp[]; 16 | 17 | // Get the file name in bucket from the database 18 | const saveFilesInfo = await db.file.createMany({ 19 | data: presignedUrls.map((file: FileInDBProp) => ({ 20 | bucket: env.S3_BUCKET_NAME, 21 | fileName: file.fileNameInBucket, 22 | originalName: file.originalFileName, 23 | size: file.fileSize, 24 | })), 25 | }); 26 | 27 | if (saveFilesInfo) { 28 | res.status(200).json({ message: "Files saved successfully" }); 29 | } else { 30 | res.status(404).json({ message: "Files not found" }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/pages/api/files/upload/smallFiles.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import fs from "fs"; 3 | import { IncomingForm, type File } from "formidable"; 4 | import { env } from "~/env"; 5 | import { saveFileInBucket } from "~/utils/s3-file-management"; 6 | import { nanoid } from "nanoid"; 7 | import { db } from "~/server/db"; 8 | 9 | const bucketName = env.S3_BUCKET_NAME; 10 | 11 | type ProcessedFiles = Array<[string, File]>; 12 | 13 | const handler = async (req: NextApiRequest, res: NextApiResponse) => { 14 | let status = 200, 15 | resultBody = { status: "ok", message: "Files were uploaded successfully" }; 16 | 17 | // Get files from request using formidable 18 | const files = await new Promise( 19 | (resolve, reject) => { 20 | const form = new IncomingForm(); 21 | const files: ProcessedFiles = []; 22 | form.on("file", function (field, file) { 23 | files.push([field, file]); 24 | }); 25 | form.on("end", () => resolve(files)); 26 | form.on("error", (err) => reject(err)); 27 | form.parse(req, () => { 28 | // 29 | }); 30 | }, 31 | ).catch(() => { 32 | ({ status, resultBody } = setErrorStatus(status, resultBody)); 33 | return undefined; 34 | }); 35 | 36 | if (files?.length) { 37 | // Upload files to S3 bucket 38 | try { 39 | await Promise.all( 40 | files.map(async ([_, fileObject]) => { 41 | const file = fs.createReadStream(fileObject?.filepath); 42 | // generate unique file name 43 | const fileName = `${nanoid(5)}-${fileObject?.originalFilename}`; 44 | // Save file to S3 bucket and save file info to database concurrently 45 | await saveFileInBucket({ 46 | bucketName, 47 | fileName, 48 | file, 49 | }); 50 | // save file info to database 51 | await db.file.create({ 52 | data: { 53 | bucket: bucketName, 54 | fileName, 55 | originalName: fileObject?.originalFilename ?? fileName, 56 | size: fileObject?.size ?? 0, 57 | }, 58 | }); 59 | }), 60 | ); 61 | } catch (e) { 62 | console.error(e); 63 | ({ status, resultBody } = setErrorStatus(status, resultBody)); 64 | } 65 | } 66 | 67 | res.status(status).json(resultBody); 68 | }; 69 | // Set error status and result body if error occurs 70 | export function setErrorStatus( 71 | status: number, 72 | resultBody: { status: string; message: string }, 73 | ) { 74 | status = 500; 75 | resultBody = { 76 | status: "fail", 77 | message: "Upload error", 78 | }; 79 | return { status, resultBody }; 80 | } 81 | 82 | // Disable body parser built-in to Next.js to allow formidable to work 83 | export const config = { 84 | api: { 85 | bodyParser: false, 86 | }, 87 | }; 88 | 89 | export default handler; 90 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import { UploadFilesS3PresignedUrl } from "~/components/UploadFilesForm/UploadFilesS3PresignedUrl"; 3 | import { FilesContainer } from "~/components/FilesContainer"; 4 | import { useState, useEffect } from "react"; 5 | import { type FileProps } from "~/utils/types"; 6 | import { UploadFilesRoute } from "~/components/UploadFilesForm/UploadFilesRoute"; 7 | 8 | export type fileUploadMode = "s3PresignedUrl" | "NextjsAPIEndpoint"; 9 | 10 | export default function Home() { 11 | const [files, setFiles] = useState([]); 12 | const [uploadMode, setUploadMode] = 13 | useState("s3PresignedUrl"); 14 | 15 | const fetchFiles = async () => { 16 | const response = await fetch("/api/files"); 17 | const body = (await response.json()) as FileProps[]; 18 | // set isDeleting to false for all files after fetching 19 | setFiles(body.map((file) => ({ ...file, isDeleting: false }))); 20 | }; 21 | 22 | // fetch files on the first render 23 | useEffect(() => { 24 | fetchFiles().catch(console.error); 25 | }, []); 26 | 27 | // determine if we should download using presigned url or Nextjs API endpoint 28 | const downloadUsingPresignedUrl = uploadMode === "s3PresignedUrl"; 29 | // handle mode change between s3PresignedUrl and NextjsAPIEndpoint 30 | const handleModeChange = (event: React.ChangeEvent) => { 31 | setUploadMode(event.target.value as fileUploadMode); 32 | }; 33 | 34 | return ( 35 | <> 36 | 37 | File Uploads with Next.js, Prisma, and PostgreSQL 38 | 42 | 43 | 44 |
    45 |
    46 | 50 | {uploadMode === "s3PresignedUrl" ? ( 51 | 52 | ) : ( 53 | 54 | )} 55 | 61 |
    62 |
    63 | 64 | ); 65 | } 66 | 67 | export type ModeSwitchMenuProps = { 68 | uploadMode: fileUploadMode; 69 | handleModeChange: (event: React.ChangeEvent) => void; 70 | }; 71 | function ModeSwitchMenu({ uploadMode, handleModeChange }: ModeSwitchMenuProps) { 72 | return ( 73 |
      74 |
    • 75 | 76 |
    • 77 |
    • 78 | 87 |
    • 88 |
    89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /src/server/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | import { env } from "~/env"; 4 | 5 | const globalForPrisma = globalThis as unknown as { 6 | prisma: PrismaClient | undefined; 7 | }; 8 | 9 | export const db = 10 | globalForPrisma.prisma ?? 11 | new PrismaClient({ 12 | log: 13 | env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], 14 | }); 15 | 16 | if (env.NODE_ENV !== "production") globalForPrisma.prisma = db; 17 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/utils/fileUploadHelpers.ts: -------------------------------------------------------------------------------- 1 | import type { ShortFileProp, PresignedUrlProp } from "./types"; 2 | 3 | export const MAX_FILE_SIZE_NEXTJS_ROUTE = 4; 4 | export const MAX_FILE_SIZE_S3_ENDPOINT = 100; 5 | export const FILE_NUMBER_LIMIT = 10; 6 | 7 | /** 8 | * 9 | * @param files array of files 10 | * @returns true if all files are valid 11 | */ 12 | export function validateFiles( 13 | files: ShortFileProp[], 14 | maxSizeMB: number, 15 | ): string | undefined { 16 | // check if all files in total are less than 100 MB 17 | const totalFileSize = files.reduce((acc, file) => acc + file.fileSize, 0); 18 | const isFileSizeValid = totalFileSize < maxSizeMB * 1024 * 1024; 19 | if (!isFileSizeValid) { 20 | return `Total file size should be less than ${maxSizeMB} MB`; 21 | } 22 | if (files.length > FILE_NUMBER_LIMIT) { 23 | return `You can upload maximum ${FILE_NUMBER_LIMIT} files at a time`; 24 | } 25 | return; 26 | } 27 | 28 | /** 29 | * Gets presigned urls for uploading files to S3 30 | * @param formData form data with files to upload 31 | * @returns 32 | */ 33 | export const getPresignedUrls = async (files: ShortFileProp[]) => { 34 | const response = await fetch("/api/files/upload/presignedUrl", { 35 | method: "POST", 36 | headers: { 37 | "Content-Type": "application/json", 38 | }, 39 | body: JSON.stringify(files), 40 | }); 41 | return (await response.json()) as PresignedUrlProp[]; 42 | }; 43 | 44 | /** 45 | * Uploads file to S3 directly using presigned url 46 | * @param presignedUrl presigned url for uploading 47 | * @param file file to upload 48 | * @returns response from S3 49 | */ 50 | export const uploadToS3 = async ( 51 | presignedUrl: PresignedUrlProp, 52 | file: File, 53 | ) => { 54 | const response = await fetch(presignedUrl.url, { 55 | method: "PUT", 56 | body: file, 57 | headers: { 58 | "Content-Type": file.type, 59 | "Access-Control-Allow-Origin": "*", 60 | }, 61 | }); 62 | return response; 63 | }; 64 | 65 | /** 66 | * Saves file info in DB 67 | * @param presignedUrls presigned urls for uploading 68 | * @returns 69 | */ 70 | export const saveFileInfoInDB = async (presignedUrls: PresignedUrlProp[]) => { 71 | return await fetch("/api/files/upload/saveFileInfo", { 72 | method: "POST", 73 | headers: { 74 | "Content-Type": "application/json", 75 | }, 76 | body: JSON.stringify(presignedUrls), 77 | }); 78 | }; 79 | 80 | /** 81 | * Uploads files to S3 and saves file info in DB 82 | * @param files files to upload 83 | * @param presignedUrls presigned urls for uploading 84 | * @param onUploadSuccess callback to execute after successful upload 85 | * @returns 86 | */ 87 | export const handleUpload = async ( 88 | files: File[], 89 | presignedUrls: PresignedUrlProp[], 90 | onUploadSuccess: () => void, 91 | ) => { 92 | const uploadToS3Response = await Promise.all( 93 | presignedUrls.map((presignedUrl) => { 94 | const file = files.find( 95 | (file) => 96 | file.name === presignedUrl.originalFileName && 97 | file.size === presignedUrl.fileSize, 98 | ); 99 | if (!file) { 100 | throw new Error("File not found"); 101 | } 102 | return uploadToS3(presignedUrl, file); 103 | }), 104 | ); 105 | 106 | if (uploadToS3Response.some((res) => res.status !== 200)) { 107 | alert("Upload failed"); 108 | return; 109 | } 110 | 111 | await saveFileInfoInDB(presignedUrls); 112 | onUploadSuccess(); 113 | }; 114 | 115 | /** 116 | * 117 | * @param bytes size of file 118 | * @param decimals number of decimals to show 119 | * @returns formatted string 120 | */ 121 | export function formatBytes(bytes: number, decimals = 2) { 122 | if (bytes === 0) return "0 Bytes"; 123 | 124 | const k = 1024; 125 | const dm = decimals < 0 ? 0 : decimals; 126 | const sizes = ["Bytes", "KB", "MB"]; 127 | 128 | // get index of size to use from sizes array 129 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 130 | 131 | // return formatted string 132 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; 133 | } 134 | 135 | /** 136 | * 137 | * @param files array of files 138 | * @returns FormData object 139 | */ 140 | export function createFormData(files: File[]): FormData { 141 | const formData = new FormData(); 142 | files.forEach((file) => { 143 | formData.append("file", file); 144 | }); 145 | return formData; 146 | } 147 | -------------------------------------------------------------------------------- /src/utils/s3-file-management.ts: -------------------------------------------------------------------------------- 1 | import * as Minio from "minio"; 2 | import type internal from "stream"; 3 | import { env } from "~/env.js"; 4 | 5 | export const s3Client = new Minio.Client({ 6 | endPoint: env.S3_ENDPOINT, 7 | port: env.S3_PORT ? Number(env.S3_PORT) : undefined, 8 | accessKey: env.S3_ACCESS_KEY, 9 | secretKey: env.S3_SECRET_KEY, 10 | useSSL: env.S3_USE_SSL === "true", 11 | }); 12 | 13 | export async function createBucketIfNotExists(bucketName: string) { 14 | const bucketExists = await s3Client.bucketExists(bucketName); 15 | if (!bucketExists) { 16 | await s3Client.makeBucket(bucketName); 17 | } 18 | } 19 | 20 | export async function saveFileInBucket({ 21 | bucketName, 22 | fileName, 23 | file, 24 | }: { 25 | bucketName: string; 26 | fileName: string; 27 | file: Buffer | internal.Readable; 28 | }) { 29 | // Create bucket if it doesn't exist 30 | await createBucketIfNotExists(bucketName); 31 | 32 | // check if file exists 33 | const fileExists = await checkFileExistsInBucket({ 34 | bucketName, 35 | fileName, 36 | }); 37 | 38 | if (fileExists) { 39 | throw new Error("File already exists"); 40 | } 41 | 42 | // Upload image to S3 bucket 43 | await s3Client.putObject(bucketName, fileName, file); 44 | } 45 | 46 | export async function checkFileExistsInBucket({ 47 | bucketName, 48 | fileName, 49 | }: { 50 | bucketName: string; 51 | fileName: string; 52 | }) { 53 | try { 54 | await s3Client.statObject(bucketName, fileName); 55 | } catch (error) { 56 | return false; 57 | } 58 | return true; 59 | } 60 | 61 | export async function getFileFromBucket({ 62 | bucketName, 63 | fileName, 64 | }: { 65 | bucketName: string; 66 | fileName: string; 67 | }) { 68 | try { 69 | await s3Client.statObject(bucketName, fileName); 70 | } catch (error) { 71 | console.error(error); 72 | return null; 73 | } 74 | return await s3Client.getObject(bucketName, fileName); 75 | } 76 | 77 | export async function deleteFileFromBucket({ 78 | bucketName, 79 | fileName, 80 | }: { 81 | bucketName: string; 82 | fileName: string; 83 | }) { 84 | try { 85 | await s3Client.removeObject(bucketName, fileName); 86 | } catch (error) { 87 | console.error(error); 88 | return false; 89 | } 90 | return true; 91 | } 92 | 93 | export async function createPresignedUrlToUpload({ 94 | bucketName, 95 | fileName, 96 | expiry = 60 * 60, // 1 hour 97 | }: { 98 | bucketName: string; 99 | fileName: string; 100 | expiry?: number; 101 | }) { 102 | // Create bucket if it doesn't exist 103 | await createBucketIfNotExists(bucketName); 104 | 105 | return await s3Client.presignedPutObject(bucketName, fileName, expiry); 106 | } 107 | 108 | export async function createPresignedUrlToDownload({ 109 | bucketName, 110 | fileName, 111 | expiry = 60 * 60, // 1 hour 112 | }: { 113 | bucketName: string; 114 | fileName: string; 115 | expiry?: number; 116 | }) { 117 | return await s3Client.presignedGetObject(bucketName, fileName, expiry); 118 | } 119 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type ShortFileProp = { 2 | originalFileName: string; 3 | fileSize: number; 4 | }; 5 | 6 | export type PresignedUrlProp = ShortFileProp & { 7 | url: string; 8 | fileNameInBucket: string; 9 | }; 10 | 11 | export type FileProps = ShortFileProp & { 12 | id: string; 13 | isDeleting?: boolean; 14 | }; 15 | 16 | export type FilesListProps = { 17 | files: FileProps[]; 18 | fetchFiles: () => Promise; 19 | setFiles: ( 20 | files: FileProps[] | ((files: FileProps[]) => FileProps[]), 21 | ) => void; 22 | downloadUsingPresignedUrl: boolean; 23 | }; 24 | 25 | export type LoadSpinnerProps = { 26 | size?: "small" | "medium" | "large"; 27 | }; 28 | 29 | export type UploadFilesFormUIProps = { 30 | isLoading: boolean; 31 | fileInputRef: React.RefObject; 32 | uploadToServer: (event: React.FormEvent) => void; 33 | maxFileSize: number; 34 | }; 35 | 36 | export type FileInDBProp = { 37 | fileNameInBucket: string; 38 | originalFileName: string; 39 | fileSize: number; 40 | }; 41 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { type Config } from "tailwindcss"; 2 | import { fontFamily } from "tailwindcss/defaultTheme"; 3 | 4 | export default { 5 | content: ["./src/**/*.tsx"], 6 | theme: { 7 | extend: { 8 | fontFamily: { 9 | sans: ["var(--font-sans)", ...fontFamily.sans], 10 | }, 11 | }, 12 | }, 13 | plugins: [], 14 | } satisfies Config; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | "allowJs": true, 8 | "resolveJsonModule": true, 9 | "moduleDetection": "force", 10 | "isolatedModules": true, 11 | 12 | /* Strictness */ 13 | "strict": true, 14 | "noUncheckedIndexedAccess": true, 15 | "checkJs": true, 16 | 17 | /* Bundled projects */ 18 | "lib": ["dom", "dom.iterable", "ES2022"], 19 | "noEmit": true, 20 | "module": "ESNext", 21 | "moduleResolution": "Bundler", 22 | "jsx": "preserve", 23 | "plugins": [{ "name": "next" }], 24 | "incremental": true, 25 | 26 | /* Path Aliases */ 27 | "baseUrl": ".", 28 | "paths": { 29 | "~/*": ["./src/*"] 30 | } 31 | }, 32 | "include": [ 33 | ".eslintrc.cjs", 34 | "next-env.d.ts", 35 | "**/*.ts", 36 | "**/*.tsx", 37 | "**/*.cjs", 38 | "**/*.js", 39 | ".next/types/**/*.ts" 40 | ], 41 | "exclude": ["node_modules"] 42 | } 43 | --------------------------------------------------------------------------------