feat: add file upload utility (#33)

* chore: add dependencies

* feat: add env

* feat: add minio docker

* feat: add id utility

* feat: add file upload action

* feat: add get signed url

* feat: add provider env

* fix: command docs
This commit is contained in:
Nafees Nazik
2024-01-16 20:10:40 -06:00
committed by GitHub
parent 2f08917d29
commit 645bc0e38e
8 changed files with 1740 additions and 6 deletions
+11
View File
@@ -28,3 +28,14 @@ EMAIL_SERVER_PASSWORD="password"
EMAIL_SERVER_SECURE=0
# EMAIL_SERVER=smtp://opencap:[email protected]:2500
EMAIL_FROM=[email protected]
# Uploads
UPLOAD_PROVIDER="s3"
UPLOAD_ENDPOINT="http://127.0.0.1:9002"
# value should be 'auto' while using r2
UPLOAD_REGION="us-east-1"
UPLOAD_BUCKET="opencap"
UPLOAD_ACCESS_KEY_ID="opencap"
UPLOAD_SECRET_ACCESS_KEY="password"
+17 -1
View File
@@ -16,4 +16,20 @@ services:
ports:
- 9000:9000
- 2500:2500
- 1100:1100
- 1100:1100
minio:
image: minio/minio
container_name: opencap-minio
ports:
- 9002:9002
- 9001:9001
volumes:
- minio:/data
environment:
MINIO_ROOT_USER: opencap
MINIO_ROOT_PASSWORD: password
command: server --console-address ":9001" --address ":9002" /data
volumes:
minio:
+1548 -5
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -18,6 +18,8 @@
"email:preview": "email preview ./src/emails"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.490.0",
"@aws-sdk/s3-request-presigner": "^3.490.0",
"@headlessui/react": "^1.7.18",
"@hookform/resolvers": "^3.3.4",
"@next-auth/prisma-adapter": "^1.0.7",
@@ -28,6 +30,7 @@
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5",
"@remixicon/react": "^4.0.1",
"@sindresorhus/slugify": "^2.2.1",
"@t3-oss/env-nextjs": "^0.7.1",
"@tanstack/react-query": "^4.36.1",
"@trpc/client": "^10.43.6",
@@ -39,6 +42,7 @@
"clsx": "^2.1.0",
"jsx-email": "^1.7.2",
"lucide-react": "^0.307.0",
"nanoid": "^5.0.4",
"next": "^14.0.4",
"next-auth": "^4.24.5",
"nodemailer": "^6.9.8",
+6
View File
@@ -0,0 +1,6 @@
import { customAlphabet } from "nanoid";
export const customId = customAlphabet(
"0123456789abcdefghijklmnopqrstuvwxyz",
16,
);
+49
View File
@@ -0,0 +1,49 @@
import {
getPresignedPutUrl,
type getPresignedUrlOptions,
} from "@/server/file-uploads";
/**
* usage
* ```js
* import { uploadFile } from '@/common/uploads'
*
* const handleUpload = async (file: File) => {
* const { uploadKey } = await uploadFile(file);
*
* // save to the database
* saveDB({ uploadKey });
* };
* ```
*/
export const uploadFile = async (
file: File,
options?: Pick<getPresignedUrlOptions, "expiresIn" | "keyPrefix">,
) => {
const { url, key } = await getPresignedPutUrl({
contentType: file.type,
fileName: file.name,
...(options && { ...options }),
});
const body = await file.arrayBuffer();
const res = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/octet-stream",
},
body,
});
if (!res.ok) {
throw new Error(
`Failed to upload file "${file.name}", failed with status code ${res.status}`,
);
}
return {
uploadKey: key,
};
};
+16
View File
@@ -51,6 +51,15 @@ export const env = createEnv({
EMAIL_SERVER_SECURE: COERCED_BOOLEAN,
EMAIL_SERVER_USERNAME: z.string().optional(),
EMAIL_SERVER_PASSWORD: z.string().optional(),
// upload
UPLOAD_ENDPOINT: z.string(),
UPLOAD_REGION: z.string(),
UPLOAD_BUCKET: z.string(),
UPLOAD_ACCESS_KEY_ID: z.string().optional(),
UPLOAD_SECRET_ACCESS_KEY: z.string().optional(),
UPLOAD_PROVIDER: z.enum(["s3", "r2"]),
},
/**
@@ -79,6 +88,13 @@ export const env = createEnv({
EMAIL_SERVER_SECURE: process.env.EMAIL_SERVER_SECURE,
EMAIL_SERVER_USERNAME: process.env.EMAIL_SERVER_USERNAME,
EMAIL_SERVER_PASSWORD: process.env.EMAIL_SERVER_PASSWORD,
UPLOAD_ENDPOINT: process.env.UPLOAD_ENDPOINT,
UPLOAD_REGION: process.env.UPLOAD_REGION,
UPLOAD_BUCKET: process.env.UPLOAD_BUCKET,
UPLOAD_ACCESS_KEY_ID: process.env.UPLOAD_ACCESS_KEY_ID,
UPLOAD_SECRET_ACCESS_KEY: process.env.UPLOAD_SECRET_ACCESS_KEY,
UPLOAD_PROVIDER: process.env.UPLOAD_PROVIDER,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
+89
View File
@@ -0,0 +1,89 @@
"use server";
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { customId } from "@/common/id";
import path from "node:path";
import slugify from "@sindresorhus/slugify";
import { env } from "@/env";
const region = env.UPLOAD_REGION;
const endpoint = env.UPLOAD_ENDPOINT;
const accessKeyId = env.UPLOAD_ACCESS_KEY_ID;
const secretAccessKey = env.UPLOAD_SECRET_ACCESS_KEY;
const hasCredentials = accessKeyId && secretAccessKey;
const Bucket = env.UPLOAD_BUCKET;
const S3 = new S3Client({
region,
endpoint,
credentials: hasCredentials
? {
secretAccessKey,
accessKeyId,
}
: undefined,
});
export interface getPresignedUrlOptions {
contentType: string;
expiresIn?: number;
fileName: string;
keyPrefix?: string;
}
const TEN_MINUTES_IN_SECONDS = 10 * 60;
export const getPresignedPutUrl = async ({
contentType,
expiresIn,
fileName,
keyPrefix,
}: getPresignedUrlOptions) => {
const { name, ext } = path.parse(fileName);
const Key = `${keyPrefix ? keyPrefix + "/" : ""}${customId(12)}/${slugify(
name,
)}${ext}`;
const putObjectCommand = new PutObjectCommand({
Bucket,
Key,
ContentType: contentType,
});
const url: string = await getSignedUrl(S3, putObjectCommand, {
expiresIn: expiresIn ?? TEN_MINUTES_IN_SECONDS,
});
return { url, key: Key };
};
export const getPresignedGetUrl = async (key: string) => {
const getObjectCommand = new GetObjectCommand({
Bucket,
Key: key,
});
const url = await getSignedUrl(S3, getObjectCommand, {
expiresIn: TEN_MINUTES_IN_SECONDS,
});
return { key, url };
};
export const deleteBucketFile = (key: string) => {
return S3.send(
new DeleteObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key,
}),
);
};