Skip to content

Commit

Permalink
feat(api): add user upload endpoint (#47)
Browse files Browse the repository at this point in the history
* 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
rin-yato authored Mar 24, 2024
1 parent 02fb9d7 commit 862afd6
Show file tree
Hide file tree
Showing 11 changed files with 3,701 additions and 1,816 deletions.
5,091 changes: 3,287 additions & 1,804 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@
"test:watch": "jest --watch",
"db:generate": "drizzle-kit generate:sqlite",
"db:migrate": "tsx src/db/migrate.ts",
"generate-random-key": "tsx src/cli/generate-aes-key.ts"
"generate-random-key": "tsx src/cli/generate-aes-key.ts",
"staged": "npm run tsc && npm run lint && jest"
},
"overrides": {
"@libsql/client": "^0.5.3"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.540.0",
"@codemirror/lang-sql": "^6.5.5",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@justmiracle/result": "^1.2.0",
"@lezer/common": "^1.2.1",
"@lezer/lr": "^1.4.0",
"@libsql/client": "^0.5.3",
Expand Down
118 changes: 118 additions & 0 deletions src/app/api/upload/route.ts
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 });
});
32 changes: 32 additions & 0 deletions src/constants/http-status.ts
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,
}
15 changes: 15 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export const env = createEnv({
GOOGLE_CLIENT_SECRET: z.string().optional(),

ENCRYPTION_KEY: z.string().min(30),

// R2
// Don't include the bucket name in the URL
R2_URL: z.string().optional(),
R2_PUBLIC_URL: z.string().optional(),
R2_BUCKET: z.string().optional(),
R2_ACCESS_KEY: z.string().optional(),
R2_SECRET_ACCESS_KEY: z.string().optional(),
},
experimental__runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
Expand All @@ -27,5 +35,12 @@ export const env = createEnv({
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,

// R2
R2_URL: process.env.R2_URL,
R2_PUBLIC_URL: process.env.R2_PUBLIC_URL,
R2_BUCKET: process.env.R2_BUCKET,
R2_ACCESS_KEY: process.env.R2_ACCESS_KEY,
R2_SECRET_ACCESS_KEY: process.env.R2_SECRET_ACCESS_KEY,
},
});
17 changes: 17 additions & 0 deletions src/lib/api-error.ts
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;
}
}
10 changes: 6 additions & 4 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ export type Provider = ObjectValues<typeof PROVIDER>;

export const github = new GitHub(
env.GITHUB_CLIENT_ID ?? "",
env.GITHUB_CLIENT_SECRET ?? ""
env.GITHUB_CLIENT_SECRET ?? "",
);

export const google = new Google(
env.GOOGLE_CLIENT_ID ?? "",
env.GOOGLE_CLIENT_SECRET ?? "",
`${env.BASE_URL}/login/google/callback`
`${env.BASE_URL}/login/google/callback`,
);

const adapter = new LibSQLAdapter(connection, {
Expand All @@ -44,6 +44,7 @@ export const lucia = new Lucia(adapter, {
return {
name: attr.name,
picture: attr.picture,
storageUsage: attr.storage_usage,
};
},
});
Expand Down Expand Up @@ -97,15 +98,15 @@ export const getSessionFromCookie = cache(async function () {
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
sessionCookie.attributes,
);
}
if (!result.session) {
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
sessionCookie.attributes,
);
}
// eslint-disable-next-line no-empty
Expand All @@ -123,4 +124,5 @@ declare module "lucia" {
interface DatabaseUserAttributes {
name: string;
picture: string;
storage_usage: number;
}
29 changes: 29 additions & 0 deletions src/lib/hash-edge.ts
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;
};
46 changes: 46 additions & 0 deletions src/lib/r2.ts
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,
};
Loading

0 comments on commit 862afd6

Please sign in to comment.