From 9345511500640e4f94524aaa1488034bd3c8ab5c Mon Sep 17 00:00:00 2001 From: jikrana Date: Wed, 12 Aug 2026 08:50:27 +0530 Subject: [PATCH 1/3] feat: add runtime prop validation --- src/components/SupportUsButton.tsx | 12 +- src/utils/validateProps.ts | 207 +++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 src/utils/validateProps.ts diff --git a/src/components/SupportUsButton.tsx b/src/components/SupportUsButton.tsx index edca532..62fef2f 100644 --- a/src/components/SupportUsButton.tsx +++ b/src/components/SupportUsButton.tsx @@ -2,7 +2,7 @@ import React, { useRef } from "react"; import type { supportUsButtonProps } from "../types/index"; import type { Theme } from "../types/index"; import { useParentStyles } from "../hooks/useParentStyles"; - +import { validateProps } from "../utils/validateProps"; function sRgbLuminance(c: number): number { const norm = c / 255; return norm <= 0.04045 ? norm / 12.92 : Math.pow((norm + 0.055) / 1.055, 2.4); @@ -81,6 +81,16 @@ function SupportUsButton({ RightY2: "1000", }, }: supportUsButtonProps): React.JSX.Element { + validateProps({ + Theme, + organizationInformation, + sponsors, + ctaSection, + projectInformation, + Logo, + className, + border, + }); const containerRef = useRef(null); const isAuto = Theme === "auto" || Theme === "inherit"; const parentStyles = useParentStyles(containerRef, isAuto); diff --git a/src/utils/validateProps.ts b/src/utils/validateProps.ts new file mode 100644 index 0000000..44fdd9c --- /dev/null +++ b/src/utils/validateProps.ts @@ -0,0 +1,207 @@ +import type { supportUsButtonProps } from "../types"; + +const VALID_THEMES = ["auto", "inherit", "light", "dark"] as const; + +const VALID_SPONSOR_TIERS = [ + "Platinum", + "Gold", + "Silver", + "Bronze", +] as const; + +function warn(message: string): void { + console.warn(`[SupportUsButton] ${message}`); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function validateProps(props: supportUsButtonProps): void { + // -------------------------------- + // Theme + // -------------------------------- + + if (!VALID_THEMES.includes(props.Theme as (typeof VALID_THEMES)[number])) { + warn( + `Invalid Theme "${String( + props.Theme + )}". Expected one of: ${VALID_THEMES.join(", ")}.` + ); + } + + // -------------------------------- + // organizationInformation + // -------------------------------- + + if (!isObject(props.organizationInformation)) { + warn("organizationInformation must be an object."); + } else { + if ( + typeof props.organizationInformation.name !== "string" || + !props.organizationInformation.name.trim() + ) { + warn("organizationInformation.name must be a non-empty string."); + } + + if (typeof props.organizationInformation.desc !== "string") { + warn("organizationInformation.desc must be a string."); + } + + if (typeof props.organizationInformation.image !== "string") { + warn("organizationInformation.image must be a string."); + } + + if ( + typeof props.organizationInformation.link !== "string" || + !props.organizationInformation.link.trim() + ) { + warn("organizationInformation.link must be a non-empty string."); + } + } + + // -------------------------------- + // projectInformation + // -------------------------------- + + if (props.projectInformation !== undefined) { + if (!isObject(props.projectInformation)) { + warn("projectInformation must be an object when provided."); + } else { + if ( + typeof props.projectInformation.name !== "string" || + !props.projectInformation.name.trim() + ) { + warn("projectInformation.name must be a non-empty string."); + } + + if (typeof props.projectInformation.description !== "string") { + warn("projectInformation.description must be a string."); + } + + if (typeof props.projectInformation.image !== "string") { + warn("projectInformation.image must be a string."); + } + } + } + + // -------------------------------- + // sponsors + // -------------------------------- + + if (props.sponsors !== undefined) { + if (!Array.isArray(props.sponsors)) { + warn("sponsors must be an array when provided."); + } else { + props.sponsors.forEach((sponsor, index) => { + if (!isObject(sponsor)) { + warn(`sponsors[${index}] must be an object.`); + return; + } + + if ( + typeof sponsor.name !== "string" || + !sponsor.name.trim() + ) { + warn( + `sponsors[${index}].name must be a non-empty string.` + ); + } + + if ( + sponsor.sponsorshipTier !== undefined && + !VALID_SPONSOR_TIERS.includes( + sponsor.sponsorshipTier as (typeof VALID_SPONSOR_TIERS)[number] + ) + ) { + warn( + `sponsors[${index}].sponsorshipTier must be one of: ${VALID_SPONSOR_TIERS.join( + ", " + )}.` + ); + } + }); + } + } + + // -------------------------------- + // ctaSection + // -------------------------------- + + if (!isObject(props.ctaSection)) { + warn("ctaSection must be an object."); + } else if (!Array.isArray(props.ctaSection.sponsorLink)) { + warn("ctaSection.sponsorLink must be an array."); + } else if (props.ctaSection.sponsorLink.length === 0) { + warn( + "ctaSection.sponsorLink should contain at least one link." + ); + } else { + props.ctaSection.sponsorLink.forEach((link, index) => { + if (!isObject(link)) { + warn( + `ctaSection.sponsorLink[${index}] must be an object.` + ); + return; + } + + if (typeof link.name !== "string" || !link.name.trim()) { + warn( + `ctaSection.sponsorLink[${index}].name must be a non-empty string.` + ); + } + + if (typeof link.url !== "string" || !link.url.trim()) { + warn( + `ctaSection.sponsorLink[${index}].url must be a non-empty string.` + ); + } + }); + } + + // -------------------------------- + // Logo + // -------------------------------- + + if (props.Logo !== undefined && typeof props.Logo !== "boolean") { + warn("Logo must be a boolean when provided."); + } + + // -------------------------------- + // className + // -------------------------------- + + if ( + props.className !== undefined && + typeof props.className !== "string" + ) { + warn("className must be a string when provided."); + } + + // -------------------------------- + // border + // -------------------------------- + + if (props.border !== undefined) { + if (!isObject(props.border)) { + warn("border must be an object when provided."); + } else { + const borderKeys = [ + "TopX1", + "TopX2", + "BottomX1", + "BottomX2", + "LeftY1", + "LeftY2", + "RightY1", + "RightY2", + ] as const; + + borderKeys.forEach((key) => { + if (typeof props.border?.[key] !== "string") { + warn(`border.${key} must be a string.`); + } + }); + } + } +} \ No newline at end of file From c75b4d35c7f4079fb0b93f93aff536363c16b35e Mon Sep 17 00:00:00 2001 From: jikrana Date: Thu, 13 Aug 2026 08:41:54 +0530 Subject: [PATCH 2/3] fix: sanitize props and gate warn() by env --- src/components/SupportUsButton.tsx | 57 +++-- src/utils/validateProps.ts | 378 +++++++++++++++++++++++------ 2 files changed, 330 insertions(+), 105 deletions(-) diff --git a/src/components/SupportUsButton.tsx b/src/components/SupportUsButton.tsx index 62fef2f..debcbc7 100644 --- a/src/components/SupportUsButton.tsx +++ b/src/components/SupportUsButton.tsx @@ -62,38 +62,37 @@ function validateUrl(url?: string): string | undefined { } // Main component function that renders the support us button, taking in various props for customization and rendering different sections such as hero, organization information, sponsors, and call-to-action based on the provided data and selected theme and button variant -function SupportUsButton({ - Theme = "auto", - organizationInformation, - sponsors, - ctaSection, - projectInformation, - Logo = true, - className = "", - border = { - TopX1: "-1000", - TopX2: "1000", - BottomX1: "-1000", - BottomX2: "1000", - LeftY1: "-1000", - LeftY2: "1000", - RightY1: "-1000", - RightY2: "1000", - }, -}: supportUsButtonProps): React.JSX.Element { - validateProps({ - Theme, - organizationInformation, - sponsors, - ctaSection, - projectInformation, - Logo, - className, - border, - }); +function SupportUsButton( + props: supportUsButtonProps, +): React.JSX.Element { + const validatedProps = validateProps(props ?? ({} as supportUsButtonProps)); + + const { + Theme = "auto", + organizationInformation, + sponsors = [], + ctaSection, + projectInformation, + Logo = true, + className = "", + } = validatedProps ?? ({} as supportUsButtonProps); + + const border = validatedProps.border ?? { + TopX1: "-1000", + TopX2: "1000", + BottomX1: "-1000", + BottomX2: "1000", + LeftY1: "-1000", + LeftY2: "1000", + RightY1: "-1000", + RightY2: "1000", + }; const containerRef = useRef(null); + const isAuto = Theme === "auto" || Theme === "inherit"; + const parentStyles = useParentStyles(containerRef, isAuto); + const darkThemeActive = Theme === "dark" || (isAuto && isDarkColor(parentStyles.backgroundColor)); diff --git a/src/utils/validateProps.ts b/src/utils/validateProps.ts index 44fdd9c..3bd0b11 100644 --- a/src/utils/validateProps.ts +++ b/src/utils/validateProps.ts @@ -1,4 +1,4 @@ -import type { supportUsButtonProps } from "../types"; +import type { supportUsButtonProps } from "../types/index"; const VALID_THEMES = ["auto", "inherit", "light", "dark"] as const; @@ -9,24 +9,95 @@ const VALID_SPONSOR_TIERS = [ "Bronze", ] as const; +const DEFAULT_BORDER = { + TopX1: "-1000", + TopX2: "1000", + BottomX1: "-1000", + BottomX2: "1000", + LeftY1: "-1000", + LeftY2: "1000", + RightY1: "-1000", + RightY2: "1000", +} as const; + +type Border = { + TopX1: string; + TopX2: string; + BottomX1: string; + BottomX2: string; + LeftY1: string; + LeftY2: string; + RightY1: string; + RightY2: string; +}; +declare const process: + | { + env?: { + NODE_ENV?: string; + }; + } + | undefined; + function warn(message: string): void { - console.warn(`[SupportUsButton] ${message}`); + let isProduction = false; + + try { + isProduction = + typeof process !== "undefined" && process.env?.NODE_ENV === "production"; + } catch { + isProduction = false; + } + + if (!isProduction) { + console.warn(`[SupportUsButton] ${message}`); + } } function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null; } -export function validateProps(props: supportUsButtonProps): void { +function isValidUrl(value: unknown): value is string { + if (typeof value !== "string" || !value.trim()) { + return false; + } + + try { + const url = new URL(value); + + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +function isValidBorderValue(value: unknown): value is string { + if (typeof value !== "string" || !value.trim()) { + return false; + } + + return Number.isFinite(Number(value)); +} + +export function validateProps( + props: supportUsButtonProps, +): supportUsButtonProps { + props = props ?? ({} as supportUsButtonProps); // -------------------------------- // Theme // -------------------------------- - if (!VALID_THEMES.includes(props.Theme as (typeof VALID_THEMES)[number])) { + const Theme = VALID_THEMES.includes( + props.Theme as (typeof VALID_THEMES)[number], + ) + ? props.Theme + : "auto"; + + if (Theme !== props.Theme) { warn( `Invalid Theme "${String( - props.Theme - )}". Expected one of: ${VALID_THEMES.join(", ")}.` + props.Theme, + )}". Expected one of: ${VALID_THEMES.join(", ")}. Falling back to "auto".`, ); } @@ -34,53 +105,125 @@ export function validateProps(props: supportUsButtonProps): void { // organizationInformation // -------------------------------- - if (!isObject(props.organizationInformation)) { - warn("organizationInformation must be an object."); + let organizationInformation = props.organizationInformation; + + if (!isObject(organizationInformation)) { + warn( + "organizationInformation must be an object. Falling back to safe defaults.", + ); + + organizationInformation = { + name: "", + desc: "", + image: "", + link: "", + }; } else { - if ( - typeof props.organizationInformation.name !== "string" || - !props.organizationInformation.name.trim() - ) { - warn("organizationInformation.name must be a non-empty string."); + const name = + typeof organizationInformation.name === "string" + ? organizationInformation.name.trim() + : ""; + + const desc = + typeof organizationInformation.desc === "string" + ? organizationInformation.desc + : ""; + + const image = + typeof organizationInformation.image === "string" + ? organizationInformation.image + : ""; + + const link = isValidUrl(organizationInformation.link) + ? organizationInformation.link + : ""; + + if (!name) { + warn( + "organizationInformation.name must be a non-empty string. Falling back to an empty value.", + ); } - if (typeof props.organizationInformation.desc !== "string") { - warn("organizationInformation.desc must be a string."); + if (typeof organizationInformation.desc !== "string") { + warn( + "organizationInformation.desc must be a string. Falling back to an empty value.", + ); } - if (typeof props.organizationInformation.image !== "string") { - warn("organizationInformation.image must be a string."); + if (typeof organizationInformation.image !== "string") { + warn( + "organizationInformation.image must be a string. Falling back to an empty value.", + ); } - if ( - typeof props.organizationInformation.link !== "string" || - !props.organizationInformation.link.trim() - ) { - warn("organizationInformation.link must be a non-empty string."); + if (organizationInformation.link !== link) { + warn( + "organizationInformation.link must be a valid http(s) URL. Falling back to an empty value.", + ); } + + organizationInformation = { + ...organizationInformation, + name, + desc, + image, + link, + }; } // -------------------------------- // projectInformation // -------------------------------- - if (props.projectInformation !== undefined) { - if (!isObject(props.projectInformation)) { - warn("projectInformation must be an object when provided."); + let projectInformation = props.projectInformation; + + if (projectInformation !== undefined) { + if (!isObject(projectInformation)) { + warn( + "projectInformation must be an object when provided. The project section will not be rendered.", + ); + + projectInformation = undefined; } else { - if ( - typeof props.projectInformation.name !== "string" || - !props.projectInformation.name.trim() - ) { - warn("projectInformation.name must be a non-empty string."); - } + const name = + typeof projectInformation.name === "string" + ? projectInformation.name.trim() + : ""; - if (typeof props.projectInformation.description !== "string") { - warn("projectInformation.description must be a string."); - } + const description = + typeof projectInformation.description === "string" + ? projectInformation.description + : ""; + + const image = + typeof projectInformation.image === "string" + ? projectInformation.image + : ""; + + if (!name) { + warn( + "projectInformation.name must be a non-empty string. The project section will not be rendered.", + ); + projectInformation = undefined; + } else { + if (typeof projectInformation.description !== "string") { + warn( + "projectInformation.description must be a string. Falling back to an empty value.", + ); + } - if (typeof props.projectInformation.image !== "string") { - warn("projectInformation.image must be a string."); + if (typeof projectInformation.image !== "string") { + warn( + "projectInformation.image must be a string. Falling back to an empty value.", + ); + } + + projectInformation = { + ...projectInformation, + name, + description, + image, + }; } } } @@ -89,104 +232,169 @@ export function validateProps(props: supportUsButtonProps): void { // sponsors // -------------------------------- - if (props.sponsors !== undefined) { - if (!Array.isArray(props.sponsors)) { - warn("sponsors must be an array when provided."); + let sponsors = props.sponsors; + + if (sponsors !== undefined) { + if (!Array.isArray(sponsors)) { + warn( + "sponsors must be an array when provided. Falling back to an empty array.", + ); + + sponsors = []; } else { - props.sponsors.forEach((sponsor, index) => { + sponsors = sponsors.filter((sponsor, index) => { if (!isObject(sponsor)) { - warn(`sponsors[${index}] must be an object.`); - return; + warn( + `sponsors[${index}] must be an object. This sponsor will be ignored.`, + ); + return false; } - if ( - typeof sponsor.name !== "string" || - !sponsor.name.trim() - ) { + const name = + typeof sponsor.name === "string" ? sponsor.name.trim() : ""; + + if (!name) { warn( - `sponsors[${index}].name must be a non-empty string.` + `sponsors[${index}].name must be a non-empty string. This sponsor will be ignored.`, ); + return false; } + let sponsorshipTier = sponsor.sponsorshipTier; + if ( - sponsor.sponsorshipTier !== undefined && + sponsorshipTier !== undefined && !VALID_SPONSOR_TIERS.includes( - sponsor.sponsorshipTier as (typeof VALID_SPONSOR_TIERS)[number] + sponsorshipTier as (typeof VALID_SPONSOR_TIERS)[number], ) ) { warn( `sponsors[${index}].sponsorshipTier must be one of: ${VALID_SPONSOR_TIERS.join( - ", " - )}.` + ", ", + )}. The invalid tier will be removed.`, ); + + sponsorshipTier = undefined; } + + sponsor.name = name; + + if (sponsorshipTier === undefined) { + delete sponsor.sponsorshipTier; + } else { + sponsor.sponsorshipTier = sponsorshipTier; + } + + return true; }); } + } else { + sponsors = []; } // -------------------------------- // ctaSection // -------------------------------- - if (!isObject(props.ctaSection)) { - warn("ctaSection must be an object."); - } else if (!Array.isArray(props.ctaSection.sponsorLink)) { - warn("ctaSection.sponsorLink must be an array."); - } else if (props.ctaSection.sponsorLink.length === 0) { + let ctaSection = props.ctaSection; + + if (!isObject(ctaSection)) { warn( - "ctaSection.sponsorLink should contain at least one link." + "ctaSection must be an object. Falling back to an empty sponsor link list.", ); + + ctaSection = { + sponsorLink: [], + }; + } else if (!Array.isArray(ctaSection.sponsorLink)) { + warn( + "ctaSection.sponsorLink must be an array. Falling back to an empty sponsor link list.", + ); + + ctaSection = { + ...ctaSection, + sponsorLink: [], + }; } else { - props.ctaSection.sponsorLink.forEach((link, index) => { + const sponsorLink = ctaSection.sponsorLink.filter((link, index) => { if (!isObject(link)) { warn( - `ctaSection.sponsorLink[${index}] must be an object.` + `ctaSection.sponsorLink[${index}] must be an object. This link will be ignored.`, ); - return; + return false; } - if (typeof link.name !== "string" || !link.name.trim()) { + const name = typeof link.name === "string" ? link.name.trim() : ""; + + if (!name) { warn( - `ctaSection.sponsorLink[${index}].name must be a non-empty string.` + `ctaSection.sponsorLink[${index}].name must be a non-empty string. This link will be ignored.`, ); + return false; } - if (typeof link.url !== "string" || !link.url.trim()) { + if (!isValidUrl(link.url)) { warn( - `ctaSection.sponsorLink[${index}].url must be a non-empty string.` + `ctaSection.sponsorLink[${index}].url must be a valid http(s) URL. This link will be ignored.`, ); + return false; } + + link.name = name; + + return true; }); + + if (sponsorLink.length === 0) { + warn( + "ctaSection.sponsorLink should contain at least one valid link.", + ); + } + + ctaSection = { + ...ctaSection, + sponsorLink, + }; } // -------------------------------- // Logo // -------------------------------- - if (props.Logo !== undefined && typeof props.Logo !== "boolean") { - warn("Logo must be a boolean when provided."); + let Logo = props.Logo; + + if (Logo !== undefined && typeof Logo !== "boolean") { + warn("Logo must be a boolean when provided. Falling back to true."); + Logo = true; } // -------------------------------- // className // -------------------------------- - if ( - props.className !== undefined && - typeof props.className !== "string" - ) { - warn("className must be a string when provided."); + let className = props.className; + + if (className !== undefined && typeof className !== "string") { + warn("className must be a string when provided. Falling back to an empty string."); + className = ""; } + // -------------------------------- // border // -------------------------------- + let border: Border = { + ...DEFAULT_BORDER, + }; + if (props.border !== undefined) { if (!isObject(props.border)) { - warn("border must be an object when provided."); + warn( + "border must be an object when provided. Falling back to default border values.", + ); } else { - const borderKeys = [ + const borderKeys: (keyof Border)[] = [ "TopX1", "TopX2", "BottomX1", @@ -195,13 +403,31 @@ export function validateProps(props: supportUsButtonProps): void { "LeftY2", "RightY1", "RightY2", - ] as const; + ]; borderKeys.forEach((key) => { - if (typeof props.border?.[key] !== "string") { - warn(`border.${key} must be a string.`); + const value = props.border?.[key]; + + if (isValidBorderValue(value)) { + border[key] = value; + } else { + warn( + `border.${key} must be a numeric string. Falling back to "${DEFAULT_BORDER[key]}".`, + ); } }); } } + + return { + ...props, + Theme, + organizationInformation, + projectInformation, + sponsors, + ctaSection, + Logo, + className, + border, + }; } \ No newline at end of file From 9a16571b271b6c2bce97538d378c0ba506f84cf8 Mon Sep 17 00:00:00 2001 From: jikrana Date: Thu, 13 Aug 2026 09:03:06 +0530 Subject: [PATCH 3/3] fix: clone sponsor/link objects instead of mutating caller props --- src/utils/validateProps.ts | 41 +++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/utils/validateProps.ts b/src/utils/validateProps.ts index 3bd0b11..27d8fe2 100644 --- a/src/utils/validateProps.ts +++ b/src/utils/validateProps.ts @@ -242,12 +242,12 @@ export function validateProps( sponsors = []; } else { - sponsors = sponsors.filter((sponsor, index) => { + sponsors = sponsors.reduce((normalized, sponsor, index) => { if (!isObject(sponsor)) { warn( `sponsors[${index}] must be an object. This sponsor will be ignored.`, ); - return false; + return normalized; } const name = @@ -257,7 +257,7 @@ export function validateProps( warn( `sponsors[${index}].name must be a non-empty string. This sponsor will be ignored.`, ); - return false; + return normalized; } let sponsorshipTier = sponsor.sponsorshipTier; @@ -277,16 +277,19 @@ export function validateProps( sponsorshipTier = undefined; } - sponsor.name = name; + // Build a brand-new sponsor object instead of mutating the + // caller-owned one — omit sponsorshipTier from the spread first, + // then add it back only if it's valid. + const { sponsorshipTier: _originalTier, ...sponsorRest } = sponsor; - if (sponsorshipTier === undefined) { - delete sponsor.sponsorshipTier; - } else { - sponsor.sponsorshipTier = sponsorshipTier; - } + normalized.push( + sponsorshipTier === undefined + ? { ...sponsorRest, name } + : { ...sponsorRest, name, sponsorshipTier }, + ); - return true; - }); + return normalized; + }, [] as typeof sponsors); } } else { sponsors = []; @@ -316,12 +319,12 @@ export function validateProps( sponsorLink: [], }; } else { - const sponsorLink = ctaSection.sponsorLink.filter((link, index) => { + const sponsorLink = ctaSection.sponsorLink.reduce((normalized, link, index) => { if (!isObject(link)) { warn( `ctaSection.sponsorLink[${index}] must be an object. This link will be ignored.`, ); - return false; + return normalized; } const name = typeof link.name === "string" ? link.name.trim() : ""; @@ -330,20 +333,22 @@ export function validateProps( warn( `ctaSection.sponsorLink[${index}].name must be a non-empty string. This link will be ignored.`, ); - return false; + return normalized; } if (!isValidUrl(link.url)) { warn( `ctaSection.sponsorLink[${index}].url must be a valid http(s) URL. This link will be ignored.`, ); - return false; + return normalized; } - link.name = name; + // Build a brand-new link object instead of mutating the + // caller-owned one. + normalized.push({ ...link, name }); - return true; - }); + return normalized; + }, [] as typeof ctaSection.sponsorLink); if (sponsorLink.length === 0) { warn(