-
Notifications
You must be signed in to change notification settings - Fork 9
feat(api): migrate POST /api/upload from chat (Arweave bytes proxy) #492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: test
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||
| import { uploadFileHandler } from "@/lib/arweave/uploadFileHandler"; | ||
|
|
||
| /** | ||
| * OPTIONS handler for CORS preflight requests. | ||
| * | ||
| * @returns A NextResponse with CORS headers. | ||
| */ | ||
| export async function OPTIONS() { | ||
| return new NextResponse(null, { | ||
| status: 200, | ||
| headers: getCorsHeaders(), | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * POST /api/upload | ||
| * | ||
| * Uploads a file (multipart/form-data, field name `file`) to Arweave and | ||
| * returns the gateway URL. | ||
| * | ||
| * @param request - The incoming request carrying the file. | ||
| * @returns A NextResponse with `{ success, fileName, fileType, fileSize, url }` on 200 | ||
| * or `{ success: false, error }` on 500. | ||
| */ | ||
| export async function POST(request: NextRequest) { | ||
| return uploadFileHandler(request); | ||
| } | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
| export const fetchCache = "force-no-store"; | ||
| export const revalidate = 0; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { NextRequest } from "next/server"; | ||
| import { uploadFileHandler } from "@/lib/arweave/uploadFileHandler"; | ||
| import { uploadToArweave } from "@/lib/arweave/uploadToArweave"; | ||
| import { getFetchableUrl } from "@/lib/arweave/getFetchableUrl"; | ||
|
|
||
| vi.mock("@/lib/networking/getCorsHeaders", () => ({ | ||
| getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), | ||
| })); | ||
|
|
||
| vi.mock("@/lib/arweave/uploadToArweave", () => ({ | ||
| uploadToArweave: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock("@/lib/arweave/getFetchableUrl", () => ({ | ||
| getFetchableUrl: vi.fn(), | ||
| })); | ||
|
|
||
| const buildRequest = (formData: FormData) => | ||
| new NextRequest("https://example.com/api/upload", { | ||
| method: "POST", | ||
| body: formData, | ||
| }); | ||
|
|
||
| describe("uploadFileHandler", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it("uploads the file and returns the gateway URL", async () => { | ||
| vi.mocked(uploadToArweave).mockResolvedValue({ id: "tx_abc" } as never); | ||
| vi.mocked(getFetchableUrl).mockReturnValue("https://arweave.net/tx_abc"); | ||
|
|
||
| const file = new File([new Uint8Array([1, 2, 3, 4])], "hello.png", { | ||
| type: "image/png", | ||
| }); | ||
| const formData = new FormData(); | ||
| formData.append("file", file); | ||
|
|
||
| const response = await uploadFileHandler(buildRequest(formData)); | ||
| const body = await response.json(); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(uploadToArweave).toHaveBeenCalledWith(expect.any(Buffer), "image/png"); | ||
| const [bufferArg] = vi.mocked(uploadToArweave).mock.calls[0]; | ||
| expect((bufferArg as Buffer).length).toBe(4); | ||
| expect(getFetchableUrl).toHaveBeenCalledWith("ar://tx_abc"); | ||
| expect(body).toEqual({ | ||
| success: true, | ||
| fileName: "hello.png", | ||
| fileType: "image/png", | ||
| fileSize: 4, | ||
| url: "https://arweave.net/tx_abc", | ||
| }); | ||
| }); | ||
|
|
||
| it("falls back to application/octet-stream when file.type is empty", async () => { | ||
| vi.mocked(uploadToArweave).mockResolvedValue({ id: "tx_xyz" } as never); | ||
| vi.mocked(getFetchableUrl).mockReturnValue("https://arweave.net/tx_xyz"); | ||
|
|
||
| const file = new File([new Uint8Array([9])], "blob.bin", { type: "" }); | ||
| const formData = new FormData(); | ||
| formData.append("file", file); | ||
|
|
||
| const response = await uploadFileHandler(buildRequest(formData)); | ||
| const body = await response.json(); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(uploadToArweave).toHaveBeenCalledWith(expect.any(Buffer), "application/octet-stream"); | ||
| expect(body.fileType).toBe("application/octet-stream"); | ||
| }); | ||
|
|
||
| it("returns 500 with success:false when no file is provided", async () => { | ||
| const formData = new FormData(); | ||
| const response = await uploadFileHandler(buildRequest(formData)); | ||
| const body = await response.json(); | ||
|
|
||
| expect(response.status).toBe(500); | ||
| expect(body).toEqual({ success: false, error: "No file provided" }); | ||
| expect(uploadToArweave).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("returns 500 with success:false when uploadToArweave throws", async () => { | ||
| vi.mocked(uploadToArweave).mockRejectedValue(new Error("network down")); | ||
|
|
||
| const file = new File([new Uint8Array([1])], "x.png", { type: "image/png" }); | ||
| const formData = new FormData(); | ||
| formData.append("file", file); | ||
|
|
||
| const response = await uploadFileHandler(buildRequest(formData)); | ||
| const body = await response.json(); | ||
|
|
||
| expect(response.status).toBe(500); | ||
| expect(body).toEqual({ success: false, error: "network down" }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,58 @@ | ||||||||||||||||||||||||||||||||||||||||
| import { NextRequest, NextResponse } from "next/server"; | ||||||||||||||||||||||||||||||||||||||||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||||||||||||||||||||||||||||||||||||||||
| import { uploadToArweave } from "@/lib/arweave/uploadToArweave"; | ||||||||||||||||||||||||||||||||||||||||
| import { getFetchableUrl } from "@/lib/arweave/getFetchableUrl"; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||
| * Handles POST /api/upload — uploads a file to Arweave and returns a gateway URL. | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * Mirrors the chat-side response shape exactly so callers can migrate | ||||||||||||||||||||||||||||||||||||||||
| * with a single base-URL swap. | ||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||
| * @param request - The incoming request carrying multipart/form-data with a `file` field. | ||||||||||||||||||||||||||||||||||||||||
| * @returns A NextResponse with `{ success, fileName, fileType, fileSize, url }` on 200 | ||||||||||||||||||||||||||||||||||||||||
| * or `{ success: false, error }` on 500. | ||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||
| export async function uploadFileHandler(request: NextRequest): Promise<NextResponse> { | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| const formData = await request.formData(); | ||||||||||||||||||||||||||||||||||||||||
| const file = formData.get("file"); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (!file || typeof file === "string") { | ||||||||||||||||||||||||||||||||||||||||
| throw new Error("No file provided"); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const fileBuffer = Buffer.from(await file.arrayBuffer()); | ||||||||||||||||||||||||||||||||||||||||
| const fileSize = fileBuffer.length; | ||||||||||||||||||||||||||||||||||||||||
| const fileType = file.type || "application/octet-stream"; | ||||||||||||||||||||||||||||||||||||||||
| const fileName = file.name; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const transaction = await uploadToArweave(fileBuffer, fileType); | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+25
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Enforce a maximum file size before buffering bytes.
Suggested fix+const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; // 25MB
...
-const fileBuffer = Buffer.from(await file.arrayBuffer());
+if (file.size > MAX_UPLOAD_BYTES) {
+ throw new Error("File too large");
+}
+const fileBuffer = Buffer.from(await file.arrayBuffer());📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||
| success: true, | ||||||||||||||||||||||||||||||||||||||||
| fileName, | ||||||||||||||||||||||||||||||||||||||||
| fileType, | ||||||||||||||||||||||||||||||||||||||||
| fileSize, | ||||||||||||||||||||||||||||||||||||||||
| url: getFetchableUrl(`ar://${transaction.id}`), | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||
| status: 200, | ||||||||||||||||||||||||||||||||||||||||
| headers: getCorsHeaders(), | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||
| console.error("/api/upload error", error); | ||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||
| success: false, | ||||||||||||||||||||||||||||||||||||||||
| error: error instanceof Error ? error.message : "Unknown error", | ||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Do not expose raw exception messages in 500 responses; return a generic error string instead. (Based on your team's feedback about preventing internal error detail leaks in API 500 responses.) Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||
| status: 500, | ||||||||||||||||||||||||||||||||||||||||
| headers: getCorsHeaders(), | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add auth validation before delegating to the upload handler.
POSTcurrently accepts uploads without callingvalidateAuthContext(). That leaves the endpoint open for unauthenticated writes.Suggested fix
import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { uploadFileHandler } from "@/lib/arweave/uploadFileHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; ... export async function POST(request: NextRequest) { + const auth = await validateAuthContext(request); + if (!auth.valid) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401, headers: getCorsHeaders() }, + ); + } return uploadFileHandler(request); }As per coding guidelines, "Always use
validateAuthContext()for authentication in API routes; it supports bothx-api-keyheader andAuthorization: Bearertoken authentication".📝 Committable suggestion
🤖 Prompt for AI Agents