fix: strictly parse route integers

This commit is contained in:
2026-04-29 00:12:26 -04:00
parent d178f9c9cb
commit 3ea6ee3938
5 changed files with 171 additions and 105 deletions

View File

@@ -1,5 +1,10 @@
import { fetchEbayItems } from "@marketplace-scrapers/core"; import { fetchEbayItems } from "@marketplace-scrapers/core";
import { logger } from "../logger"; import { logger } from "../logger";
import {
emptySearchResponse,
getRequiredSearchQuery,
parseNonNegativeIntegerParam,
} from "./helpers";
/** /**
* GET /api/ebay?q={query}&minPrice={minPrice}&maxPrice={maxPrice}&strictMode={strictMode}&exclusions={exclusions}&keywords={keywords}&buyItNowOnly={buyItNowOnly}&canadaOnly={canadaOnly} * GET /api/ebay?q={query}&minPrice={minPrice}&maxPrice={maxPrice}&strictMode={strictMode}&exclusions={exclusions}&keywords={keywords}&buyItNowOnly={buyItNowOnly}&canadaOnly={canadaOnly}
@@ -9,32 +14,24 @@ export async function ebayRoute(req: Request): Promise<Response> {
try { try {
const reqUrl = new URL(req.url); const reqUrl = new URL(req.url);
const SEARCH_QUERY = const SEARCH_QUERY = getRequiredSearchQuery(req);
req.headers.get("query") || reqUrl.searchParams.get("q") || null; if (SEARCH_QUERY instanceof Response) {
if (!SEARCH_QUERY) return SEARCH_QUERY;
return Response.json(
{
message:
"Request didn't have 'query' header or 'q' search parameter!",
},
{ status: 400 },
);
const minPriceParam = reqUrl.searchParams.get("minPrice");
const minPrice = minPriceParam ? parseInt(minPriceParam, 10) : undefined;
if (minPriceParam && (Number.isNaN(minPrice) || (minPrice ?? 0) < 0)) {
return Response.json(
{ message: "Invalid minPrice parameter" },
{ status: 400 },
);
} }
const maxPriceParam = reqUrl.searchParams.get("maxPrice");
const maxPrice = maxPriceParam ? parseInt(maxPriceParam, 10) : undefined; const minPrice = parseNonNegativeIntegerParam(
if (maxPriceParam && (Number.isNaN(maxPrice) || (maxPrice ?? 0) < 0)) { reqUrl.searchParams,
return Response.json( "minPrice",
{ message: "Invalid maxPrice parameter" }, );
{ status: 400 }, if (minPrice instanceof Response) {
); return minPrice;
}
const maxPrice = parseNonNegativeIntegerParam(
reqUrl.searchParams,
"maxPrice",
);
if (maxPrice instanceof Response) {
return maxPrice;
} }
const strictMode = reqUrl.searchParams.get("strictMode") === "true"; const strictMode = reqUrl.searchParams.get("strictMode") === "true";
const buyItNowOnly = reqUrl.searchParams.get("buyItNowOnly") !== "false"; const buyItNowOnly = reqUrl.searchParams.get("buyItNowOnly") !== "false";
@@ -48,13 +45,12 @@ export async function ebayRoute(req: Request): Promise<Response> {
? keywordsParam.split(",").map((s) => s.trim()) ? keywordsParam.split(",").map((s) => s.trim())
: [SEARCH_QUERY]; : [SEARCH_QUERY];
const maxItemsParam = reqUrl.searchParams.get("maxItems"); const maxItems = parseNonNegativeIntegerParam(
const maxItems = maxItemsParam ? parseInt(maxItemsParam, 10) : undefined; reqUrl.searchParams,
if (maxItemsParam && (Number.isNaN(maxItems) || (maxItems ?? 0) < 0)) { "maxItems",
return Response.json( );
{ message: "Invalid maxItems parameter" }, if (maxItems instanceof Response) {
{ status: 400 }, return maxItems;
);
} }
const hideUnstableResults = const hideUnstableResults =
reqUrl.searchParams.get("unstableFilter") === "true"; reqUrl.searchParams.get("unstableFilter") === "true";
@@ -73,10 +69,7 @@ export async function ebayRoute(req: Request): Promise<Response> {
hideUnstableResults: true, hideUnstableResults: true,
}); });
if (items.results.length === 0 && items.unstableResults.length === 0) { if (items.results.length === 0 && items.unstableResults.length === 0) {
return Response.json( return emptySearchResponse();
{ message: "Search didn't return any results!" },
{ status: 404 },
);
} }
return Response.json(items, { status: 200 }); return Response.json(items, { status: 200 });
} }
@@ -84,11 +77,9 @@ export async function ebayRoute(req: Request): Promise<Response> {
const items = await fetchEbayItems(SEARCH_QUERY, 1, opts); const items = await fetchEbayItems(SEARCH_QUERY, 1, opts);
const isEmpty = !items || items.length === 0; const isEmpty = !items || items.length === 0;
if (isEmpty) if (isEmpty) {
return Response.json( return emptySearchResponse();
{ message: "Search didn't return any results!" }, }
{ status: 404 },
);
return Response.json(items, { status: 200 }); return Response.json(items, { status: 200 });
} catch (error) { } catch (error) {
logger.error("eBay scraping error:", error); logger.error("eBay scraping error:", error);

View File

@@ -1,5 +1,10 @@
import { fetchFacebookItems } from "@marketplace-scrapers/core"; import { fetchFacebookItems } from "@marketplace-scrapers/core";
import { logger } from "../logger"; import { logger } from "../logger";
import {
emptySearchResponse,
getRequiredSearchQuery,
parseNonNegativeIntegerParam,
} from "./helpers";
/** /**
* GET /api/facebook?q={query}&location={location} * GET /api/facebook?q={query}&location={location}
@@ -8,24 +13,19 @@ import { logger } from "../logger";
export async function facebookRoute(req: Request): Promise<Response> { export async function facebookRoute(req: Request): Promise<Response> {
const reqUrl = new URL(req.url); const reqUrl = new URL(req.url);
const SEARCH_QUERY = const SEARCH_QUERY = getRequiredSearchQuery(req);
req.headers.get("query") || reqUrl.searchParams.get("q") || null; if (SEARCH_QUERY instanceof Response) {
if (!SEARCH_QUERY) return SEARCH_QUERY;
return Response.json( }
{
message: "Request didn't have 'query' header or 'q' search parameter!",
},
{ status: 400 },
);
const LOCATION = reqUrl.searchParams.get("location") || "toronto"; const LOCATION = reqUrl.searchParams.get("location") || "toronto";
const maxItemsParam = reqUrl.searchParams.get("maxItems"); const maxItems = parseNonNegativeIntegerParam(
const maxItems = maxItemsParam ? parseInt(maxItemsParam, 10) : 25; reqUrl.searchParams,
if (maxItemsParam && (Number.isNaN(maxItems) || maxItems < 0)) { "maxItems",
return Response.json( 25,
{ message: "Invalid maxItems parameter" }, );
{ status: 400 }, if (maxItems instanceof Response) {
); return maxItems;
} }
const hideUnstableResults = const hideUnstableResults =
reqUrl.searchParams.get("unstableFilter") === "true"; reqUrl.searchParams.get("unstableFilter") === "true";
@@ -42,20 +42,15 @@ export async function facebookRoute(req: Request): Promise<Response> {
}, },
); );
if (items.results.length === 0 && items.unstableResults.length === 0) { if (items.results.length === 0 && items.unstableResults.length === 0) {
return Response.json( return emptySearchResponse();
{ message: "Search didn't return any results!" },
{ status: 404 },
);
} }
return Response.json(items, { status: 200 }); return Response.json(items, { status: 200 });
} }
const items = await fetchFacebookItems(SEARCH_QUERY, 1, LOCATION, maxItems); const items = await fetchFacebookItems(SEARCH_QUERY, 1, LOCATION, maxItems);
if (!items || items.length === 0) if (!items || items.length === 0) {
return Response.json( return emptySearchResponse();
{ message: "Search didn't return any results!" }, }
{ status: 404 },
);
return Response.json(items, { status: 200 }); return Response.json(items, { status: 200 });
} catch (error) { } catch (error) {
logger.error("Facebook scraping error:", error); logger.error("Facebook scraping error:", error);

View File

@@ -0,0 +1,39 @@
export function getRequiredSearchQuery(req: Request): string | Response {
const reqUrl = new URL(req.url);
const query = req.headers.get("query") || reqUrl.searchParams.get("q");
if (!query) {
return Response.json(
{
message: "Request didn't have 'query' header or 'q' search parameter!",
},
{ status: 400 },
);
}
return query;
}
export function parseNonNegativeIntegerParam(
searchParams: URLSearchParams,
name: string,
defaultValue?: number,
): number | undefined | Response {
const rawValue = searchParams.get(name);
if (rawValue === null) {
return defaultValue;
}
const value = Number(rawValue);
if (!Number.isInteger(value) || value < 0) {
return Response.json(
{ message: `Invalid ${name} parameter` },
{ status: 400 },
);
}
return value;
}
export function emptySearchResponse(): Response {
return Response.json(
{ message: "Search didn't return any results!" },
{ status: 404 },
);
}

View File

@@ -1,5 +1,10 @@
import { fetchKijijiItems } from "@marketplace-scrapers/core"; import { fetchKijijiItems } from "@marketplace-scrapers/core";
import { logger } from "../logger"; import { logger } from "../logger";
import {
emptySearchResponse,
getRequiredSearchQuery,
parseNonNegativeIntegerParam,
} from "./helpers";
/** /**
* GET /api/kijiji?q={query} * GET /api/kijiji?q={query}
@@ -8,39 +13,32 @@ import { logger } from "../logger";
export async function kijijiRoute(req: Request): Promise<Response> { export async function kijijiRoute(req: Request): Promise<Response> {
const reqUrl = new URL(req.url); const reqUrl = new URL(req.url);
const SEARCH_QUERY = const SEARCH_QUERY = getRequiredSearchQuery(req);
req.headers.get("query") || reqUrl.searchParams.get("q") || null; if (SEARCH_QUERY instanceof Response) {
if (!SEARCH_QUERY) return SEARCH_QUERY;
return Response.json( }
{
message: "Request didn't have 'query' header or 'q' search parameter!",
},
{ status: 400 },
);
const maxPagesParam = reqUrl.searchParams.get("maxPages"); const maxPages = parseNonNegativeIntegerParam(
const maxPages = maxPagesParam ? parseInt(maxPagesParam, 10) : 5; reqUrl.searchParams,
if (maxPagesParam && (Number.isNaN(maxPages) || maxPages < 0)) { "maxPages",
return Response.json( 5,
{ message: "Invalid maxPages parameter" }, );
{ status: 400 }, if (maxPages instanceof Response) {
); return maxPages;
} }
const priceMinParam = reqUrl.searchParams.get("priceMin"); const priceMin = parseNonNegativeIntegerParam(
const priceMin = priceMinParam ? parseInt(priceMinParam, 10) : undefined; reqUrl.searchParams,
if (priceMinParam && (Number.isNaN(priceMin) || (priceMin ?? 0) < 0)) { "priceMin",
return Response.json( );
{ message: "Invalid priceMin parameter" }, if (priceMin instanceof Response) {
{ status: 400 }, return priceMin;
);
} }
const priceMaxParam = reqUrl.searchParams.get("priceMax"); const priceMax = parseNonNegativeIntegerParam(
const priceMax = priceMaxParam ? parseInt(priceMaxParam, 10) : undefined; reqUrl.searchParams,
if (priceMaxParam && (Number.isNaN(priceMax) || (priceMax ?? 0) < 0)) { "priceMax",
return Response.json( );
{ message: "Invalid priceMax parameter" }, if (priceMax instanceof Response) {
{ status: 400 }, return priceMax;
);
} }
const hideUnstableResults = const hideUnstableResults =
reqUrl.searchParams.get("unstableFilter") === "true"; reqUrl.searchParams.get("unstableFilter") === "true";
@@ -75,10 +73,7 @@ export async function kijijiRoute(req: Request): Promise<Response> {
{ hideUnstableResults: true }, { hideUnstableResults: true },
); );
if (items.results.length === 0 && items.unstableResults.length === 0) { if (items.results.length === 0 && items.unstableResults.length === 0) {
return Response.json( return emptySearchResponse();
{ message: "Search didn't return any results!" },
{ status: 404 },
);
} }
return Response.json(items, { status: 200 }); return Response.json(items, { status: 200 });
} }
@@ -90,11 +85,9 @@ export async function kijijiRoute(req: Request): Promise<Response> {
searchOptions, searchOptions,
{}, {},
); );
if (!items || items.length === 0) if (!items || items.length === 0) {
return Response.json( return emptySearchResponse();
{ message: "Search didn't return any results!" }, }
{ status: 404 },
);
return Response.json(items, { status: 200 }); return Response.json(items, { status: 200 });
} catch (error) { } catch (error) {
logger.error("Kijiji scraping error:", error); logger.error("Kijiji scraping error:", error);

View File

@@ -513,6 +513,18 @@ describe("API routes", () => {
expect(body.message).toBe("Invalid maxItems parameter"); expect(body.message).toBe("Invalid maxItems parameter");
}); });
test("ebayRoute rejects non-integer minPrice", async () => {
const { ebayRoute } = await import("../src/routes/ebay");
const response = await ebayRoute(
new Request("http://localhost/api/ebay?q=laptop&minPrice=10abc"),
);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("Invalid minPrice parameter");
});
test("ebayRoute returns 400 for invalid minPrice", async () => { test("ebayRoute returns 400 for invalid minPrice", async () => {
const { ebayRoute } = await import("../src/routes/ebay"); const { ebayRoute } = await import("../src/routes/ebay");
@@ -525,6 +537,18 @@ describe("API routes", () => {
expect(body.message).toBe("Invalid minPrice parameter"); expect(body.message).toBe("Invalid minPrice parameter");
}); });
test("ebayRoute rejects non-integer maxPrice", async () => {
const { ebayRoute } = await import("../src/routes/ebay");
const response = await ebayRoute(
new Request("http://localhost/api/ebay?q=laptop&maxPrice=10abc"),
);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("Invalid maxPrice parameter");
});
test("ebayRoute returns 400 for invalid maxPrice", async () => { test("ebayRoute returns 400 for invalid maxPrice", async () => {
const { ebayRoute } = await import("../src/routes/ebay"); const { ebayRoute } = await import("../src/routes/ebay");
@@ -537,6 +561,18 @@ describe("API routes", () => {
expect(body.message).toBe("Invalid maxPrice parameter"); expect(body.message).toBe("Invalid maxPrice parameter");
}); });
test("kijijiRoute rejects decimal maxPages", async () => {
const { kijijiRoute } = await import("../src/routes/kijiji");
const response = await kijijiRoute(
new Request("http://localhost/api/kijiji?q=laptop&maxPages=1.5"),
);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("Invalid maxPages parameter");
});
test("kijijiRoute returns 400 for invalid maxPages", async () => { test("kijijiRoute returns 400 for invalid maxPages", async () => {
const { kijijiRoute } = await import("../src/routes/kijiji"); const { kijijiRoute } = await import("../src/routes/kijiji");
@@ -573,6 +609,18 @@ describe("API routes", () => {
expect(body.message).toBe("Invalid priceMax parameter"); expect(body.message).toBe("Invalid priceMax parameter");
}); });
test("facebookRoute rejects non-integer maxItems", async () => {
const { facebookRoute } = await import("../src/routes/facebook");
const response = await facebookRoute(
new Request("http://localhost/api/facebook?q=laptop&maxItems=10abc"),
);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("Invalid maxItems parameter");
});
test("facebookRoute returns 400 for negative maxItems", async () => { test("facebookRoute returns 400 for negative maxItems", async () => {
const { facebookRoute } = await import("../src/routes/facebook"); const { facebookRoute } = await import("../src/routes/facebook");