diff --git a/src/app/api/upload-plot-images/route.test.ts b/src/app/api/upload-plot-images/route.test.ts new file mode 100644 index 0000000..46cba6b --- /dev/null +++ b/src/app/api/upload-plot-images/route.test.ts @@ -0,0 +1,181 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("viem", () => ({ + recoverMessageAddress: vi.fn(), +})); + +vi.mock("../../../../lib/filebase", () => ({ + uploadBinaryWithRetry: vi.fn(), +})); + +import { POST } from "./route"; +import { recoverMessageAddress } from "viem"; +import { uploadBinaryWithRetry } from "../../../../lib/filebase"; +import { NextRequest } from "next/server"; + +const mockedRecover = vi.mocked(recoverMessageAddress); +const mockedUpload = vi.mocked(uploadBinaryWithRetry); + +// Valid JPEG magic bytes +const JPEG_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...new Array(100).fill(0)]); +// Valid WebP magic bytes +const WEBP_BYTES = new Uint8Array([ + 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, + 0x57, 0x45, 0x42, 0x50, ...new Array(100).fill(0), +]); + +function makeRequest(formData: FormData): NextRequest { + const req = new NextRequest("http://localhost/api/upload-plot-images", { + method: "POST", + body: formData, + }); + return req; +} + +function makeSignedFormData(opts: { + files?: { name: string; type: string; content: Uint8Array }[]; +}) { + const fd = new FormData(); + const ts = Date.now(); + fd.set("message", `PlotLink: Upload plot images\nTimestamp: ${ts}`); + fd.set("signature", "0xfakesig"); + if (opts.files) { + for (const f of opts.files) { + fd.append("files", new File([f.content], f.name, { type: f.type })); + } + } + return fd; +} + +let walletCounter = 0; +function uniqueWallet(): `0x${string}` { + walletCounter++; + return `0x${walletCounter.toString().padStart(40, "0")}`; +} + +describe("POST /api/upload-plot-images", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedRecover.mockImplementation(async () => uniqueWallet()); + mockedUpload.mockResolvedValue("QmFakeCid123456789012345678901234567890123456"); + }); + + it("rejects missing signature", async () => { + const fd = new FormData(); + fd.append("files", new File([JPEG_BYTES], "img.jpg", { type: "image/jpeg" })); + const res = await POST(makeRequest(fd)); + expect(res.status).toBe(401); + }); + + it("rejects when no files provided", async () => { + const fd = makeSignedFormData({ files: [] }); + const res = await POST(makeRequest(fd)); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toMatch(/No files/); + }); + + it("rejects more than 20 files", async () => { + const files = Array.from({ length: 21 }, (_, i) => ({ + name: `img${i}.jpg`, + type: "image/jpeg", + content: JPEG_BYTES, + })); + const fd = makeSignedFormData({ files }); + const res = await POST(makeRequest(fd)); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toMatch(/Too many files/); + }); + + it("returns per-file error for invalid mime type", async () => { + const fd = makeSignedFormData({ + files: [ + { name: "good.jpg", type: "image/jpeg", content: JPEG_BYTES }, + { name: "bad.png", type: "image/png", content: new Uint8Array(100) }, + ], + }); + const res = await POST(makeRequest(fd)); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.results[0].cid).toBeDefined(); + expect(json.results[1].error).toMatch(/Invalid file type/); + }); + + it("returns per-file error for magic byte mismatch", async () => { + const fd = makeSignedFormData({ + files: [ + { name: "fake.jpg", type: "image/jpeg", content: new Uint8Array(100) }, + ], + }); + const res = await POST(makeRequest(fd)); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.results[0].error).toMatch(/does not match/); + }); + + it("uploads valid files and returns ordered results", async () => { + const fd = makeSignedFormData({ + files: [ + { name: "a.jpg", type: "image/jpeg", content: JPEG_BYTES }, + { name: "b.webp", type: "image/webp", content: WEBP_BYTES }, + ], + }); + const res = await POST(makeRequest(fd)); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.results).toHaveLength(2); + expect(json.results[0].index).toBe(0); + expect(json.results[0].cid).toBeDefined(); + expect(json.results[0].mimeType).toBe("image/jpeg"); + expect(json.results[0].sizeBytes).toBeGreaterThan(0); + expect(json.results[1].index).toBe(1); + expect(json.results[1].mimeType).toBe("image/webp"); + }); + + it("accepts file_0, file_1 naming convention", async () => { + const fd = new FormData(); + const ts = Date.now(); + fd.set("message", `PlotLink: Upload plot images\nTimestamp: ${ts}`); + fd.set("signature", "0xfakesig"); + fd.append("file_0", new File([JPEG_BYTES], "a.jpg", { type: "image/jpeg" })); + fd.append("file_1", new File([WEBP_BYTES], "b.webp", { type: "image/webp" })); + const res = await POST(makeRequest(fd)); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.results).toHaveLength(2); + expect(json.results[0].index).toBe(0); + expect(json.results[1].index).toBe(1); + }); + + it("accepts files[] naming convention", async () => { + const fd = new FormData(); + const ts = Date.now(); + fd.set("message", `PlotLink: Upload plot images\nTimestamp: ${ts}`); + fd.set("signature", "0xfakesig"); + fd.append("files[]", new File([JPEG_BYTES], "a.jpg", { type: "image/jpeg" })); + fd.append("files[]", new File([WEBP_BYTES], "b.webp", { type: "image/webp" })); + const res = await POST(makeRequest(fd)); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.results).toHaveLength(2); + }); + + it("rate limits at 3 batch requests per minute", async () => { + const fixedWallet: `0x${string}` = "0xRATELIMITTEST000000000000000000000000000"; + mockedRecover.mockResolvedValue(fixedWallet); + for (let i = 0; i < 3; i++) { + const fd = makeSignedFormData({ + files: [{ name: "img.jpg", type: "image/jpeg", content: JPEG_BYTES }], + }); + const res = await POST(makeRequest(fd)); + expect(res.status).toBe(200); + } + const fd = makeSignedFormData({ + files: [{ name: "img.jpg", type: "image/jpeg", content: JPEG_BYTES }], + }); + const res = await POST(makeRequest(fd)); + expect(res.status).toBe(429); + }); +}); diff --git a/src/app/api/upload-plot-images/route.ts b/src/app/api/upload-plot-images/route.ts new file mode 100644 index 0000000..9656574 --- /dev/null +++ b/src/app/api/upload-plot-images/route.ts @@ -0,0 +1,181 @@ +import { NextRequest, NextResponse } from "next/server"; +import { recoverMessageAddress } from "viem"; +import { uploadBinaryWithRetry } from "../../../../lib/filebase"; + +const MAX_FILE_SIZE = 1024 * 1024; // 1MB +const MAX_FILES_PER_BATCH = 20; +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_MAX = 3; +const SIGNATURE_MAX_AGE_MS = 5 * 60 * 1000; + +const ALLOWED_MIME_TYPES = new Set(["image/webp", "image/jpeg"]); + +const walletBatchLog = new Map(); + +function checkRateLimit(wallet: string): boolean { + const now = Date.now(); + const timestamps = walletBatchLog.get(wallet) ?? []; + const recent = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW_MS); + if (recent.length >= RATE_LIMIT_MAX) return false; + recent.push(now); + walletBatchLog.set(wallet, recent); + return true; +} + +function validateMagicBytes(buffer: Uint8Array, mimeType: string): boolean { + if (mimeType === "image/jpeg") { + return buffer.length >= 3 && + buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; + } + if (mimeType === "image/webp") { + return buffer.length >= 12 && + buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && + buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50; + } + return false; +} + +interface FileResult { + index: number; + cid?: string; + url?: string; + mimeType?: string; + sizeBytes?: number; + error?: string; +} + +export async function POST(req: NextRequest) { + try { + const formData = await req.formData(); + + const rawMessage = formData.get("message"); + const signature = formData.get("signature"); + + if (typeof rawMessage !== "string" || typeof signature !== "string") { + return NextResponse.json( + { error: "Missing wallet signature. Please connect your wallet and try again." }, + { status: 401 } + ); + } + + const message = rawMessage.replace(/\r\n/g, "\n"); + + const timestampMatch = message.match(/Timestamp:\s*(\d+)/); + if (!timestampMatch) { + return NextResponse.json( + { error: "Invalid message format." }, + { status: 401 } + ); + } + + const timestamp = Number(timestampMatch[1]); + const expectedMessage = `PlotLink: Upload plot images\nTimestamp: ${timestamp}`; + if (message !== expectedMessage) { + return NextResponse.json( + { error: "Invalid message format." }, + { status: 401 } + ); + } + + const age = Date.now() - timestamp; + if (age > SIGNATURE_MAX_AGE_MS || age < -30_000) { + return NextResponse.json( + { error: "Signature expired. Please try again." }, + { status: 401 } + ); + } + + const signer = await recoverMessageAddress({ + message, + signature: signature as `0x${string}`, + }); + const walletKey = signer.toLowerCase(); + + if (!checkRateLimit(walletKey)) { + return NextResponse.json( + { error: "Rate limit exceeded. Max 3 batch uploads per minute." }, + { status: 429 } + ); + } + + const files: File[] = []; + const indexedFiles: { index: number; file: File }[] = []; + for (const [key, value] of formData.entries()) { + if (!(value instanceof File)) continue; + if (key === "files" || key === "files[]") { + files.push(value); + } else { + const m = key.match(/^file[_\[]?(\d+)\]?$/); + if (m) { + indexedFiles.push({ index: Number(m[1]), file: value }); + } + } + } + if (indexedFiles.length > 0) { + indexedFiles.sort((a, b) => a.index - b.index); + for (const { file } of indexedFiles) { + files.push(file); + } + } + + if (files.length === 0) { + return NextResponse.json( + { error: "No files provided" }, + { status: 400 } + ); + } + + if (files.length > MAX_FILES_PER_BATCH) { + return NextResponse.json( + { error: `Too many files. Maximum ${MAX_FILES_PER_BATCH} per batch.` }, + { status: 400 } + ); + } + + const results: FileResult[] = await Promise.all( + files.map(async (file, index): Promise => { + if (file.size > MAX_FILE_SIZE) { + return { index, error: "File too large. Maximum size is 1MB." }; + } + + const declaredType = file.type; + if (!ALLOWED_MIME_TYPES.has(declaredType)) { + return { index, error: "Invalid file type. Only WebP and JPEG are accepted." }; + } + + const arrayBuffer = await file.arrayBuffer(); + const bytes = new Uint8Array(arrayBuffer); + + if (!validateMagicBytes(bytes, declaredType)) { + return { index, error: "File content does not match declared type." }; + } + + try { + const ext = declaredType === "image/webp" ? ".webp" : ".jpg"; + const key = `plotlink/plot-images/${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}${ext}`; + + const cid = await uploadBinaryWithRetry( + Buffer.from(arrayBuffer), + key, + declaredType + ); + + return { + index, + cid, + url: `https://ipfs.filebase.io/ipfs/${cid}`, + mimeType: declaredType, + sizeBytes: bytes.length, + }; + } catch { + return { index, error: "Upload failed" }; + } + }) + ); + + return NextResponse.json({ results }); + } catch (err) { + const message = err instanceof Error ? err.message : "Upload failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +}