mirror of
https://github.com/hanzoai/captable.git
synced 2026-10-16 14:21:29 +00:00
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:
@@ -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"
|
||||
@@ -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:
|
||||
Generated
+1548
-5
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
export const customId = customAlphabet(
|
||||
"0123456789abcdefghijklmnopqrstuvwxyz",
|
||||
16,
|
||||
);
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user