Skip to content

Commit 759d29e

Browse files
committed
Use signed URLs for uploads
1 parent 7e711cf commit 759d29e

File tree

5 files changed

+245
-9
lines changed

5 files changed

+245
-9
lines changed
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import mime from "mime-types";
2+
import { eq } from "drizzle-orm";
3+
import { type NextRequest, NextResponse } from "next/server";
4+
import { blob } from "@/drizzle/schema";
5+
import { headObject } from "@/lib/blobStore";
6+
import { database } from "@/lib/utils/useDatabase";
7+
import { getOwner } from "@/lib/utils/useOwner";
8+
9+
export type ConfirmResponse = {
10+
url: string;
11+
};
12+
13+
export async function POST(request: NextRequest) {
14+
const { userId } = await getOwner();
15+
16+
const body = await request.json();
17+
const { fileId } = body as { fileId: string };
18+
19+
if (!fileId) {
20+
return NextResponse.json(
21+
{ error: "Missing fileId" },
22+
{ status: 400 },
23+
);
24+
}
25+
26+
const db = database();
27+
28+
try {
29+
const [blobRecord] = await db
30+
.select()
31+
.from(blob)
32+
.where(eq(blob.id, fileId))
33+
.limit(1);
34+
35+
if (!blobRecord) {
36+
return NextResponse.json({ error: "File not found" }, { status: 404 });
37+
}
38+
39+
if (blobRecord.createdByUser !== userId) {
40+
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
41+
}
42+
43+
if (blobRecord.status === "confirmed") {
44+
const extension = mime.extension(blobRecord.contentType);
45+
return NextResponse.json<ConfirmResponse>({
46+
url: `${process.env.NEXT_PUBLIC_APP_URL}/api/blob/${fileId}/file.${extension}`,
47+
});
48+
}
49+
50+
const exists = await headObject(blobRecord.key);
51+
if (!exists) {
52+
return NextResponse.json(
53+
{ error: "File not uploaded to storage" },
54+
{ status: 400 },
55+
);
56+
}
57+
58+
await db
59+
.update(blob)
60+
.set({
61+
status: "confirmed",
62+
updatedAt: new Date(),
63+
})
64+
.where(eq(blob.id, fileId))
65+
.execute();
66+
67+
const extension = mime.extension(blobRecord.contentType);
68+
return NextResponse.json<ConfirmResponse>({
69+
url: `${process.env.NEXT_PUBLIC_APP_URL}/api/blob/${fileId}/file.${extension}`,
70+
});
71+
} catch (error) {
72+
console.error("Error confirming upload", error);
73+
return NextResponse.json(
74+
{ error: "Failed to confirm upload" },
75+
{ status: 500 },
76+
);
77+
}
78+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { randomUUID } from "node:crypto";
2+
import { type NextRequest, NextResponse } from "next/server";
3+
import { blob } from "@/drizzle/schema";
4+
import { getUploadUrl } from "@/lib/blobStore";
5+
import { database } from "@/lib/utils/useDatabase";
6+
import { getOwner } from "@/lib/utils/useOwner";
7+
8+
const MAX_FILE_SIZE = 10 * 1024 * 1024;
9+
const ALLOWED_TYPES = [
10+
"image/jpeg",
11+
"image/png",
12+
"image/gif",
13+
"image/webp",
14+
"image/svg+xml",
15+
"application/pdf",
16+
];
17+
18+
export type PresignResponse = {
19+
uploadUrl: string;
20+
fileId: string;
21+
};
22+
23+
export async function POST(request: NextRequest) {
24+
const { ownerId, userId } = await getOwner();
25+
26+
const body = await request.json();
27+
const { fileName, contentType, contentSize } = body as {
28+
fileName: string;
29+
contentType: string;
30+
contentSize: number;
31+
};
32+
33+
if (!fileName || !contentType || !contentSize) {
34+
return NextResponse.json(
35+
{ error: "Missing required fields" },
36+
{ status: 400 },
37+
);
38+
}
39+
40+
if (!ALLOWED_TYPES.includes(contentType)) {
41+
return NextResponse.json({ error: "File type not allowed" }, { status: 400 });
42+
}
43+
44+
if (contentSize > MAX_FILE_SIZE) {
45+
return NextResponse.json(
46+
{ error: "File size exceeds maximum allowed (10MB)" },
47+
{ status: 400 },
48+
);
49+
}
50+
51+
const db = database();
52+
53+
try {
54+
const fileId = randomUUID();
55+
const key = `${ownerId}/${fileId}`;
56+
57+
const uploadUrl = await getUploadUrl(key, contentType);
58+
59+
await db
60+
.insert(blob)
61+
.values({
62+
id: fileId,
63+
key,
64+
name: fileName,
65+
contentType,
66+
contentSize,
67+
status: "pending",
68+
createdByUser: userId,
69+
createdAt: new Date(),
70+
updatedAt: new Date(),
71+
})
72+
.execute();
73+
74+
return NextResponse.json<PresignResponse>({
75+
uploadUrl,
76+
fileId,
77+
});
78+
} catch (error) {
79+
console.error("Error generating presigned URL", error);
80+
return NextResponse.json(
81+
{ error: "Failed to generate upload URL" },
82+
{ status: 500 },
83+
);
84+
}
85+
}

components/editor/index.tsx

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,46 @@ const Editor = memo(function Editor({
110110
initialContent === "loading" ? undefined : metadata || initialContent,
111111
uploadFile: allowImageUpload
112112
? async (file) => {
113-
const result: { url: string } = await fetch(
114-
`/api/blob?name=${file.name}`,
115-
{
116-
method: "PUT",
117-
body: file,
118-
},
119-
).then((res) => res.json());
120-
return result.url;
113+
const presignRes = await fetch("/api/blob/presign", {
114+
method: "POST",
115+
headers: { "Content-Type": "application/json" },
116+
body: JSON.stringify({
117+
fileName: file.name,
118+
contentType: file.type,
119+
contentSize: file.size,
120+
}),
121+
});
122+
123+
if (!presignRes.ok) {
124+
const error = await presignRes.json();
125+
throw new Error(error.error || "Failed to get upload URL");
126+
}
127+
128+
const { uploadUrl, fileId } = await presignRes.json();
129+
130+
const uploadRes = await fetch(uploadUrl, {
131+
method: "PUT",
132+
headers: { "Content-Type": file.type },
133+
body: file,
134+
});
135+
136+
if (!uploadRes.ok) {
137+
throw new Error("Failed to upload file to storage");
138+
}
139+
140+
const confirmRes = await fetch("/api/blob/confirm", {
141+
method: "POST",
142+
headers: { "Content-Type": "application/json" },
143+
body: JSON.stringify({ fileId }),
144+
});
145+
146+
if (!confirmRes.ok) {
147+
const error = await confirmRes.json();
148+
throw new Error(error.error || "Failed to confirm upload");
149+
}
150+
151+
const { url } = await confirmRes.json();
152+
return url;
121153
}
122154
: undefined,
123155
},

drizzle/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ export const blob = pgTable("blob", {
158158
name: text("name").notNull(),
159159
contentType: text("contentType").notNull(),
160160
contentSize: integer("contentSize").notNull(),
161+
status: text("status").notNull().default("confirmed"),
161162
createdByUser: text("createdByUser")
162163
.notNull()
163164
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }),

lib/blobStore/index.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
DeleteObjectCommand,
33
GetObjectCommand,
4+
HeadObjectCommand,
45
ListObjectsV2Command,
56
PutObjectCommand,
67
S3Client,
@@ -78,4 +79,43 @@ const listFiles = async (prefix: string, maxKeys?: number) => {
7879
return response.Contents || [];
7980
};
8081

81-
export { bytesToMegabytes, deleteFile, getFileUrl, getUrl, listFiles, upload };
82+
const getUploadUrl = async (
83+
key: string,
84+
contentType: string,
85+
): Promise<string> => {
86+
const command = new PutObjectCommand({
87+
Bucket: process.env.S3_BUCKET_NAME,
88+
Key: key,
89+
ContentType: contentType,
90+
});
91+
92+
const signedUrl = await getSignedUrl(blobStorage, command, {
93+
expiresIn: 300,
94+
});
95+
96+
return signedUrl;
97+
};
98+
99+
const headObject = async (key: string): Promise<boolean> => {
100+
try {
101+
const command = new HeadObjectCommand({
102+
Bucket: process.env.S3_BUCKET_NAME,
103+
Key: key,
104+
});
105+
await blobStorage.send(command);
106+
return true;
107+
} catch {
108+
return false;
109+
}
110+
};
111+
112+
export {
113+
bytesToMegabytes,
114+
deleteFile,
115+
getFileUrl,
116+
getUploadUrl,
117+
getUrl,
118+
headObject,
119+
listFiles,
120+
upload,
121+
};

0 commit comments

Comments
 (0)