diff --git a/api/index.js b/api/index.js index 2029367ca3eb9..d158ad6516aa1 100644 --- a/api/index.js +++ b/api/index.js @@ -8,7 +8,36 @@ import { renderError, } from "../src/common/utils.js"; import { fetchStats } from "../src/fetchers/stats-fetcher.js"; -import { isLocaleAvailable } from "../src/translations.js"; +import { validateQueryStringParams } from "../src/common/validate.js"; + +const QUERYSTRING_PARAMS_DATA_TYPE_MAP = new Map([ + ["username", "string"], + ["hide", "enum-array"], + ["hide_title", "boolean"], + ["hide_border", "boolean"], + ["card_width", "number"], + ["hide_rank", "boolean"], + ["show_icons", "boolean"], + ["include_all_commits", "boolean"], + ["line_height", "number"], + ["title_color", "string"], + ["ring_color", "string"], + ["icon_color", "string"], + ["text_color", "string"], + ["text_bold", "boolean"], + ["bg_color", "string"], + ["theme", "enum"], + ["cache_seconds", "number"], + ["exclude_repo", "array"], + ["custom_title", "string"], + ["locale", "enum"], + ["disable_animations", "boolean"], + ["border_radius", "number"], + ["border_color", "string"], + ["number_format", "enum"], + ["rank_icon", "enum"], + ["show", "enum-array"], +]); export default async (req, res) => { const { @@ -53,19 +82,9 @@ export default async (req, res) => { ); } - if (locale && !isLocaleAvailable(locale)) { - return res.send( - renderError("Something went wrong", "Language not found", { - title_color, - text_color, - bg_color, - border_color, - theme, - }), - ); - } - try { + validateQueryStringParams(req.query, QUERYSTRING_PARAMS_DATA_TYPE_MAP); + const showStats = parseArray(show); const stats = await fetchStats( username, diff --git a/src/common/validate.d.ts b/src/common/validate.d.ts new file mode 100644 index 0000000000000..c23f1463193a6 --- /dev/null +++ b/src/common/validate.d.ts @@ -0,0 +1,22 @@ +export type DataTypes = + | "enum" + | "enum-array" + | "array" + | "string" + | "boolean" + | "number"; + +export function validateQueryStringParam( + expectedDataType: DateTypes, + param: string, + value: string, +): boolean; + +export function validateQueryStringParams( + queryStringParams: object, + queryStringParamsDataTypeMap: Map, +): void; + +export class InvalidQueryStringParamsError extends Error { + constructor(message: string, secondaryMessage: string); +} diff --git a/src/common/validate.js b/src/common/validate.js new file mode 100644 index 0000000000000..0caaf87b79fc3 --- /dev/null +++ b/src/common/validate.js @@ -0,0 +1,128 @@ +// @ts-check + +import { availableLocales } from "../translations.js"; +import { themes } from "../../themes/index.js"; +import { parseArray } from "./utils.js"; + +/** + * Class for handling invalid query string param errors. + */ +export class InvalidQueryStringParamsError extends Error { + /** + * Constructor for InvalidQueryStringParamsError. + * + * @param {string} message - The error message. + * @param {string} secondaryMessage - The secondary error message. + */ + constructor(message, secondaryMessage) { + super(message); + this.secondaryMessage = secondaryMessage; + } +} + +const QUERYSTRING_PARAMS_ENUM_VALUES = { + hide: ["stars", "commits", "prs", "issues", "contribs"], + theme: Object.keys(themes), + locale: availableLocales, + number_format: ["short", "long"], + rank_icon: ["github", "percentile", "default"], + show: [ + "reviews", + "discussions_started", + "discussions_answered", + "prs_merged", + "prs_merged_percentage", + ], +}; + +/** + * @typedef {import("./validate").DataTypes} DataTypes The data types. + */ + +/** + * Returns the secondary error message for an invalid query string param. + * + * @param {string} param - The invalid query string param. + * @param {Map} queryStringParamsDataTypeMap - The query string params data type map. + * @returns {string} The secondary error message. + */ +const getInvalidQueryStringParamsErrorSecondaryMessage = ( + param, + queryStringParamsDataTypeMap, +) => { + const expectedDataType = queryStringParamsDataTypeMap.get(param); + if (expectedDataType === "enum" || expectedDataType === "enum-array") { + return `Expected: ${QUERYSTRING_PARAMS_ENUM_VALUES[param].join(", ")}`; + } else if (expectedDataType === "number") { + return "Expected: a number"; + } else if (expectedDataType === "boolean") { + return "Expected: true or false"; + } else if (expectedDataType === "array") { + return "Expected: an array"; + } else if (expectedDataType === "string") { + return "Expected: a string"; + } else { + throw new Error("Unexpected behavior"); + } +}; + +/** + * Validates a query string param. + * + * @param {DataTypes} expectedDataType - The expected data type of the query string param. + * @param {string} param - The query string param. + * @param {string} value - The query string param value. + * @returns {boolean} Whether the query string param is valid. + */ +export const validateQueryStringParam = (expectedDataType, param, value) => { + if (expectedDataType === "enum") { + return QUERYSTRING_PARAMS_ENUM_VALUES[param].includes(value); + } else if (expectedDataType === "enum-array") { + return parseArray(value).every((value) => + QUERYSTRING_PARAMS_ENUM_VALUES[param].includes(value), + ); + } else if (expectedDataType === "number") { + return !isNaN(parseFloat(value)); + } else if (expectedDataType === "boolean") { + return value === "true" || value === "false"; + } else if (expectedDataType === "array") { + return Array.isArray(parseArray(value)); + } else if (expectedDataType === "string") { + return typeof value === "string"; + } else { + return false; + } +}; + +/** + * Validates the query string params. + * Throws an error if a query string param is invalid. + * Does not return anything. + * + * @param {object} queryStringParams - The query string params. + * @param {Map} queryStringParamsDataTypeMap - The query string params data type map. + * @returns {void} + */ +export const validateQueryStringParams = ( + queryStringParams, + queryStringParamsDataTypeMap, +) => { + for (const [param, value] of Object.entries(queryStringParams)) { + const expectedDataType = queryStringParamsDataTypeMap.get(param); + if (!expectedDataType) { + // Absence of data type means that the query string param is not supported. + // Currently we allow addition of extra query string params. + continue; + } + if (validateQueryStringParam(expectedDataType, param, value)) { + continue; + } + throw new InvalidQueryStringParamsError( + `Invalid query string param \`${param}\` value: ${value}`, + getInvalidQueryStringParamsErrorSecondaryMessage( + param, + queryStringParamsDataTypeMap, + ), + ); + } +}; diff --git a/tests/api.test.js b/tests/api.test.js index eee9a1a0a61af..e4e118374e484 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -6,6 +6,7 @@ import { calculateRank } from "../src/calculateRank.js"; import { renderStatsCard } from "../src/cards/stats-card.js"; import { CONSTANTS, renderError } from "../src/common/utils.js"; import { expect, it, describe, afterEach } from "@jest/globals"; +import { availableLocales } from "../src/translations.js"; const stats = { name: "Anurag Hazra", @@ -140,8 +141,8 @@ describe("Test /api/", () => { { username: "anuraghazra", hide: "issues,prs,contribs", - show_icons: true, - hide_border: true, + show_icons: "true", + hide_border: "true", line_height: 100, title_color: "fff", icon_color: "fff", @@ -270,8 +271,8 @@ describe("Test /api/", () => { { username: "anuraghazra", hide: "issues,prs,contribs", - show_icons: true, - hide_border: true, + show_icons: "true", + hide_border: "true", line_height: 100, title_color: "fff", ring_color: "0000ff", @@ -318,7 +319,10 @@ describe("Test /api/", () => { expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml"); expect(res.send).toBeCalledWith( - renderError("Something went wrong", "Language not found"), + renderError( + "Invalid query string param `locale` value: asdf", + `Expected: ${availableLocales.join(", ")}`, + ), ); }); @@ -328,7 +332,7 @@ describe("Test /api/", () => { .reply(200, { error: "Some test error message" }); const { req, res } = faker( - { username: "anuraghazra", include_all_commits: true }, + { username: "anuraghazra", include_all_commits: "true" }, data_stats, ); diff --git a/tests/validate.test.js b/tests/validate.test.js new file mode 100644 index 0000000000000..c650f60b44471 --- /dev/null +++ b/tests/validate.test.js @@ -0,0 +1,256 @@ +// @ts-check + +import { expect, it, describe } from "@jest/globals"; +import { + validateQueryStringParam, + validateQueryStringParams, + InvalidQueryStringParamsError, +} from "../src/common/validate"; +import { themes } from "../themes/index.js"; + +/** + * @typedef {import("../src/common/validate").DataTypes} DataTypes The data types. + */ + +/** + * @type {Map} The query string params data type map. + */ +const QUERYSTRING_PARAMS_DATA_TYPE_MAP = new Map([ + ["username", "string"], + ["hide", "enum-array"], + ["hide_title", "boolean"], + ["hide_border", "boolean"], + ["card_width", "number"], + ["hide_rank", "boolean"], + ["show_icons", "boolean"], + ["include_all_commits", "boolean"], + ["line_height", "number"], + ["title_color", "string"], + ["ring_color", "string"], + ["icon_color", "string"], + ["text_color", "string"], + ["text_bold", "boolean"], + ["bg_color", "string"], + ["theme", "enum"], + ["cache_seconds", "number"], + ["exclude_repo", "array"], + ["custom_title", "string"], + ["locale", "enum"], + ["disable_animations", "boolean"], + ["border_radius", "number"], + ["border_color", "string"], + ["number_format", "enum"], + ["rank_icon", "enum"], + ["show", "enum-array"], +]); + +describe("test query string param validation", () => { + // Tests for `validateQueryStringParam` function. + it("should validate a string", () => { + expect(validateQueryStringParam("string", "username", "anuraghazra")).toBe( + true, + ); + }); + it("should validate a boolean", () => { + expect(validateQueryStringParam("boolean", "hide_title", "true")).toBe( + true, + ); + expect(validateQueryStringParam("boolean", "hide_title", "false")).toBe( + true, + ); + expect(validateQueryStringParam("boolean", "hide_title", "invalid")).toBe( + false, + ); + }); + it("should validate a number", () => { + expect(validateQueryStringParam("number", "card_width", "300")).toBe(true); + expect(validateQueryStringParam("number", "card_width", "300.000")).toBe( + true, + ); + expect(validateQueryStringParam("number", "card_width", "invalid")).toBe( + false, + ); + }); + it("should validate an enum", () => { + expect(validateQueryStringParam("enum", "theme", "dark")).toBe(true); + expect(validateQueryStringParam("enum", "theme", "merko")).toBe(true); + expect(validateQueryStringParam("enum", "theme", "invalid")).toBe(false); + + expect(validateQueryStringParam("enum", "locale", "en")).toBe(true); + expect(validateQueryStringParam("enum", "locale", "invalid")).toBe(false); + + expect(validateQueryStringParam("enum", "number_format", "short")).toBe( + true, + ); + expect(validateQueryStringParam("enum", "number_format", "invalid")).toBe( + false, + ); + + expect(validateQueryStringParam("enum", "rank_icon", "github")).toBe(true); + expect(validateQueryStringParam("enum", "rank_icon", "invalid")).toBe( + false, + ); + }); + it("should validate an enum-array", () => { + expect( + validateQueryStringParam("enum-array", "hide", "stars,commits,prs"), + ).toBe(true); + expect( + validateQueryStringParam( + "enum-array", + "show", + "reviews,discussions_started", + ), + ).toBe(true); + expect( + validateQueryStringParam( + "enum-array", + "hide", + "stars,commits,prs,invalid", + ), + ).toBe(false); + expect(validateQueryStringParam("enum-array", "hide", "invalid")).toBe( + false, + ); + }); + it("should validate an array", () => { + expect( + validateQueryStringParam("array", "exclude_repo", "repo1,repo2"), + ).toBe(true); + }); + + // Tests for `validateQueryStringParams` function. + it("should validate query string params", () => { + expect( + validateQueryStringParams( + { + username: "anuraghazra", + hide: "stars,commits,prs", + hide_title: "true", + hide_border: "true", + card_width: "300", + hide_rank: "true", + show_icons: "true", + include_all_commits: "true", + line_height: "25", + title_color: "fff", + ring_color: "fff", + icon_color: "fff", + text_color: "fff", + text_bold: "true", + bg_color: "fff", + theme: "dark", + cache_seconds: "300", + exclude_repo: "repo1,repo2", + custom_title: "My Custom Title", + locale: "en", + disable_animations: "true", + border_radius: "0", + number_format: "long", + border_color: "fff", + rank_icon: "github", + show: "reviews,discussions_started", + }, + QUERYSTRING_PARAMS_DATA_TYPE_MAP, + ), + // This function returns void on successful run and throws an error on invalid query string params. + ).toBe(undefined); + }); + it("should validate query string params with extra params", () => { + expect( + validateQueryStringParams( + { + username: "anuraghazra", + hide: "stars,commits,prs", + hide_title: "true", + hide_border: "true", + card_width: "300", + hide_rank: "true", + show_icons: "true", + include_all_commits: "true", + line_height: "25", + title_color: "fff", + ring_color: "fff", + icon_color: "fff", + text_color: "fff", + text_bold: "true", + bg_color: "fff", + theme: "dark", + cache_seconds: "300", + exclude_repo: "repo1,repo2", + custom_title: "My Custom Title", + locale: "en", + disable_animations: "true", + border_radius: "0", + number_format: "long", + border_color: "fff", + rank_icon: "github", + show: "reviews,discussions_started", + invalid: "invalid", + }, + QUERYSTRING_PARAMS_DATA_TYPE_MAP, + ), + // This function returns void on successful run and throws an error on invalid query string params. + ).toBe(undefined); + }); + it("should throw correct error on invalid boolean param", () => { + expect(() => + validateQueryStringParams( + { + hide_title: "invalid", + }, + QUERYSTRING_PARAMS_DATA_TYPE_MAP, + ), + ).toThrow( + new InvalidQueryStringParamsError( + "Invalid query string param `hide_title` value: invalid", + "Expected: true or false", + ), + ); + }); + it("should throw correct error on invalid number param", () => { + expect(() => + validateQueryStringParams( + { + card_width: "invalid", + }, + QUERYSTRING_PARAMS_DATA_TYPE_MAP, + ), + ).toThrow( + new InvalidQueryStringParamsError( + "Invalid query string param `card_width` value: invalid", + "Expected: a number", + ), + ); + }); + it("should throw correct error on invalid enum param", () => { + expect(() => + validateQueryStringParams( + { + theme: "invalid", + }, + QUERYSTRING_PARAMS_DATA_TYPE_MAP, + ), + ).toThrow( + new InvalidQueryStringParamsError( + "Invalid query string param `theme` value: invalid", + `Expected: ${Object.keys(themes).join(", ")}`, + ), + ); + }); + it("should throw correct error on invalid enum-array param", () => { + expect(() => + validateQueryStringParams( + { + hide: "commits,prs,invalid", + }, + QUERYSTRING_PARAMS_DATA_TYPE_MAP, + ), + ).toThrow( + new InvalidQueryStringParamsError( + "Invalid query string param `hide` value: commits,prs,invalid", + "Expected: stars, commits, prs, issues, contribs", + ), + ); + }); +});