Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions src/app/api/upload-plot-images/route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
181 changes: 181 additions & 0 deletions src/app/api/upload-plot-images/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, number[]>();

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<FileResult> => {
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 });
}
}
Loading