diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 000000000..3d865e965 --- /dev/null +++ b/app/api/upload/route.ts @@ -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; diff --git a/lib/arweave/__tests__/uploadFileHandler.test.ts b/lib/arweave/__tests__/uploadFileHandler.test.ts new file mode 100644 index 000000000..659059cf5 --- /dev/null +++ b/lib/arweave/__tests__/uploadFileHandler.test.ts @@ -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" }); + }); +}); diff --git a/lib/arweave/uploadFileHandler.ts b/lib/arweave/uploadFileHandler.ts new file mode 100644 index 000000000..51218f846 --- /dev/null +++ b/lib/arweave/uploadFileHandler.ts @@ -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 { + 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); + + 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", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +}