-
Notifications
You must be signed in to change notification settings - Fork 84
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(api): add user upload endpoint (#47)
* feat: add utils and update env * feat(api): add upload api * chore: add script to run some check * chore: add deps * fea(api): add ApiError * fix: prevent race condition use the current value in the database to add to file.size * feat: add support for edge * fix: path store only path not full url * fix: make r2 optional
- Loading branch information
Showing
11 changed files
with
3,701 additions
and
1,816 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
import { hash } from "@/lib/hash-edge"; | ||
import { concat } from "@/lib/utils"; | ||
import withUser from "@/lib/with-user"; | ||
import { NextResponse } from "next/server"; | ||
import { ok, err } from "@justmiracle/result"; | ||
import { R2 } from "@/lib/r2"; | ||
import { db } from "@/db"; | ||
import { user as userTable, user_file } from "@/db/schema"; | ||
import { eq, sql } from "drizzle-orm"; | ||
import { generateId } from "lucia"; | ||
import { ApiError } from "@/lib/api-error"; | ||
import { HttpStatus } from "@/constants/http-status"; | ||
import { env } from "@/env"; | ||
|
||
export const runtime = "edge"; | ||
|
||
// 500MB | ||
const STORAGE_LIMIT = 500 * 1000 * 1000; | ||
|
||
export const POST = withUser(async ({ req, user }) => { | ||
const envs = [ | ||
env.R2_URL, | ||
env.R2_PUBLIC_URL, | ||
env.R2_BUCKET, | ||
env.R2_ACCESS_KEY, | ||
env.R2_SECRET_ACCESS_KEY, | ||
]; | ||
|
||
if (envs.some((env) => !env)) { | ||
throw new ApiError({ | ||
status: HttpStatus.INTERNAL_SERVER_ERROR, | ||
message: "R2 is not configured!", | ||
}); | ||
} | ||
|
||
const formData = await req.formData().then(ok).catch(err); | ||
|
||
if (formData.error) { | ||
throw new ApiError({ | ||
status: HttpStatus.BAD_REQUEST, | ||
message: "Something went wrong, please try again later", | ||
detailedMessage: formData.error.message, | ||
}); | ||
} | ||
|
||
const file = formData.value.get("file"); | ||
|
||
if (!file || !(file instanceof Blob)) { | ||
throw new ApiError({ | ||
status: HttpStatus.BAD_REQUEST, | ||
message: "Invalid file", | ||
}); | ||
} | ||
|
||
const newStorageUsage = user.storageUsage + file.size; | ||
|
||
if (newStorageUsage > STORAGE_LIMIT) { | ||
throw new ApiError({ | ||
status: HttpStatus.INSUFFICIENT_STORAGE, | ||
message: "Storage limit exceeded", | ||
}); | ||
} | ||
|
||
// e.g. image/png -> png | ||
const fileExtenstion = file.type.split("/")[1] || ""; | ||
|
||
// we can do compression or resizing here and return a new buffer | ||
const buffer = Buffer.from(await file.arrayBuffer()); | ||
|
||
// hash the content and append the file extension | ||
const hashedFilename = concat(await hash(buffer), ".", fileExtenstion); | ||
|
||
const uploadToR2 = await R2.upload({ | ||
buffer, | ||
userId: user.id, | ||
filename: hashedFilename, | ||
contentType: file.type, | ||
}) | ||
.then(ok) | ||
.catch(err); | ||
|
||
if (uploadToR2.error) | ||
throw new ApiError({ | ||
status: HttpStatus.INTERNAL_SERVER_ERROR, | ||
message: "Failed to upload your image", | ||
detailedMessage: uploadToR2.error.message, | ||
}); | ||
|
||
const updateUserStorageUsageQuery = db | ||
.update(userTable) | ||
.set({ storageUsage: sql`${userTable.storageUsage} + ${file.size}` }) | ||
.where(eq(userTable.id, user.id)); | ||
|
||
const insertFileQuery = db.insert(user_file).values({ | ||
id: generateId(15), | ||
user_id: user.id, | ||
filename: file.name, | ||
hashed: hashedFilename, | ||
path: uploadToR2.value.path, | ||
size_in_byte: file.size, | ||
created_at: Date.now(), | ||
}); | ||
|
||
const batch = await db | ||
.batch([updateUserStorageUsageQuery, insertFileQuery]) | ||
.then(ok) | ||
.catch(err); | ||
|
||
if (batch.error) { | ||
throw new ApiError({ | ||
status: HttpStatus.INTERNAL_SERVER_ERROR, | ||
message: "Failed to upload your image", | ||
detailedMessage: batch.error.message, | ||
}); | ||
} | ||
|
||
return NextResponse.json({ url: uploadToR2.value.url }); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
export enum HttpStatus { | ||
// 200 | ||
OK = 200, | ||
CREATED = 201, | ||
ACCEPTED = 202, | ||
NO_CONTENT = 204, | ||
|
||
// 300 | ||
MOVED_PERMANENTLY = 301, | ||
FOUND = 302, | ||
TEMPORARY_REDIRECT = 307, | ||
PERMANENT_REDIRECT = 308, | ||
|
||
// 400 | ||
BAD_REQUEST = 400, | ||
UNAUTHORIZED = 401, | ||
PAYMENT_REQUIRED = 402, | ||
FORBIDDEN = 403, | ||
NOT_FOUND = 404, | ||
METHOD_NOT_ALLOWED = 405, | ||
CONTENT_TOO_LARGE = 413, | ||
URI_TOO_LONG = 414, | ||
IM_A_TEAPOT = 418, | ||
TOO_MANY_REQUESTS = 429, | ||
|
||
// 500 | ||
INTERNAL_SERVER_ERROR = 500, | ||
NOT_IMPLEMENTED = 501, | ||
BAD_GATEWAY = 502, | ||
SERVICE_UNAVAILABLE = 503, | ||
INSUFFICIENT_STORAGE = 507, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
interface ApiErrorConstructor { | ||
message: string; | ||
status: number; | ||
detailedMessage?: string; | ||
} | ||
|
||
export class ApiError extends Error { | ||
detailedMessage?: string; | ||
status: number; | ||
|
||
constructor({ message, status, detailedMessage }: ApiErrorConstructor) { | ||
super(message); | ||
this.name = "ApiError"; | ||
this.status = status; | ||
this.detailedMessage = detailedMessage; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { scoped } from "./utils"; | ||
|
||
/** | ||
* Generate a SHA-1 hash of a string or BufferSource using the Web Crypto API | ||
* @param input - string or BufferSource to hash | ||
* @returns Promise that resolves to the hashed string | ||
*/ | ||
export const hash = async (input: string | BufferSource) => { | ||
// Convert the input string to a Uint8Array | ||
|
||
const data = scoped(() => { | ||
if (typeof input === "string") { | ||
return new TextEncoder().encode(input); | ||
} | ||
return input; | ||
}); | ||
|
||
const subtle = crypto.subtle; | ||
|
||
const hashBuffer = await subtle.digest("SHA-1", data); | ||
|
||
// Convert the hashBuffer to a hexadecimal string | ||
const hashArray = Array.from(new Uint8Array(hashBuffer)); | ||
const hashHex = hashArray | ||
.map((b) => b.toString(16).padStart(2, "0")) | ||
.join(""); | ||
|
||
return hashHex; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
import { env } from "@/env"; | ||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; | ||
|
||
export const R2Client = new S3Client({ | ||
region: "auto", | ||
endpoint: env.R2_URL ?? "", | ||
credentials: { | ||
accessKeyId: env.R2_ACCESS_KEY ?? "", | ||
secretAccessKey: env.R2_SECRET_ACCESS_KEY ?? "", | ||
}, | ||
}); | ||
|
||
async function upload({ | ||
buffer, | ||
userId, | ||
filename, | ||
contentType, | ||
}: { | ||
buffer: Buffer; | ||
userId: string; | ||
filename: string; | ||
contentType: string; | ||
}) { | ||
const key = `u-${userId}/${filename}`; | ||
|
||
await R2Client.send( | ||
new PutObjectCommand({ | ||
Key: key, | ||
ContentType: contentType, | ||
Body: buffer, | ||
Bucket: env.R2_BUCKET, | ||
}), | ||
); | ||
|
||
const url = new URL(key, env.R2_PUBLIC_URL); | ||
|
||
return { | ||
path: key, | ||
url: url.toString(), | ||
}; | ||
} | ||
|
||
export const R2 = { | ||
...R2Client, | ||
upload, | ||
}; |
Oops, something went wrong.