From 5da071320938e1ea8773f8df6558ce42a5323156 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Sep 2025 12:25:48 +0200 Subject: [PATCH 1/7] perf: speed up escapeHTML by ~3x --- frontend/src/ts/utils/misc.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 976cf5d7da0d..5920e7694d2a 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -164,18 +164,22 @@ export function escapeRegExp(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -export function escapeHTML(str: string): string { +export function escapeHTML(str: T): T { if (str === null || str === undefined) { return str; } - str = str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - - return str; + + const escapeMap: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + "/": "/", + "`": "`", + }; + + return str.replace(/[&<>"'/`]/g, (char) => escapeMap[char] as string) as T; } export function isUsernameValid(name: string): boolean { From 0d7d68f8b4b17e86551fe3f16f541f4b2c4305b2 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Sep 2025 12:30:58 +0200 Subject: [PATCH 2/7] test: add unit tests for escapeHTML --- frontend/__tests__/utils/misc.spec.ts | 42 ++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/frontend/__tests__/utils/misc.spec.ts b/frontend/__tests__/utils/misc.spec.ts index fc0a29781085..893282946e25 100644 --- a/frontend/__tests__/utils/misc.spec.ts +++ b/frontend/__tests__/utils/misc.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { getErrorMessage, isObject } from "../../src/ts/utils/misc"; +import { getErrorMessage, isObject, escapeHTML } from "../../src/ts/utils/misc"; import { getLanguageDisplayString, removeLanguageSize, @@ -123,6 +123,46 @@ describe("misc.ts", () => { }); }); + describe("escapeHTML", () => { + it("should escape HTML characters correctly", () => { + const tests = [ + { + input: "hello world", + expected: "hello world", + }, + { + input: "", + expected: "<script>alert('xss')</script>", + }, + { + input: 'Hello "world" & friends', + expected: "Hello "world" & friends", + }, + { + input: "Click `here` to continue", + expected: "Click `here` to continue", + }, + { + input: null, + expected: null, + }, + { + input: undefined, + expected: undefined, + }, + { + input: "", + expected: "", + }, + ]; + + tests.forEach((test) => { + const result = escapeHTML(test.input); + expect(result).toBe(test.expected); + }); + }); + }); + describe("getErrorMesssage", () => { it("should correctly get the error message", () => { const tests = [ From 505049338ffd11fc2e61173bcf360d6c2bd3dfa3 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Sep 2025 12:31:55 +0200 Subject: [PATCH 3/7] chore(about): escape supporters and contributors --- frontend/src/ts/pages/about.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/ts/pages/about.ts b/frontend/src/ts/pages/about.ts index 9f1c880c9414..0656a87f913e 100644 --- a/frontend/src/ts/pages/about.ts +++ b/frontend/src/ts/pages/about.ts @@ -151,7 +151,7 @@ async function fill(): Promise { const supportersEl = document.querySelector(".pageAbout .supporters"); let supportersHTML = ""; for (const supporter of supporters ?? []) { - supportersHTML += `
${supporter}
`; + supportersHTML += `
${Misc.escapeHTML(supporter)}
`; } if (supportersEl) { supportersEl.innerHTML = supportersHTML; @@ -160,7 +160,7 @@ async function fill(): Promise { const contributorsEl = document.querySelector(".pageAbout .contributors"); let contributorsHTML = ""; for (const contributor of contributors ?? []) { - contributorsHTML += `
${contributor}
`; + contributorsHTML += `
${Misc.escapeHTML(contributor)}
`; } if (contributorsEl) { contributorsEl.innerHTML = contributorsHTML; From 9e5e4831a845a6f2c626a402226a60652520245e Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Sep 2025 14:35:46 +0200 Subject: [PATCH 4/7] impr(input-validation): add trigger validation function !nuf --- frontend/src/ts/elements/input-validation.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/ts/elements/input-validation.ts b/frontend/src/ts/elements/input-validation.ts index 73a595679d28..c07c61760c05 100644 --- a/frontend/src/ts/elements/input-validation.ts +++ b/frontend/src/ts/elements/input-validation.ts @@ -146,6 +146,7 @@ export type ValidationOptions = (T extends string export type ValidatedHtmlInputElement = HTMLInputElement & { isValid: () => boolean | undefined; setValue: (val: string | null) => void; + triggerValidation: () => void; }; /** * adds an 'InputIndicator` to the given `inputElement` and updates its status depending on the given validation @@ -207,6 +208,9 @@ export function validateWithIndicator( inputElement.dispatchEvent(new Event("input")); } }; + result.triggerValidation = () => { + inputElement.dispatchEvent(new Event("input")); + }; return result; } From 42f6a16c6255d66cbb2be279131454866d98f2c9 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Sep 2025 14:45:13 +0200 Subject: [PATCH 5/7] impr(modes-notice): escape custom text name for safe HTML rendering !nuf --- frontend/src/ts/elements/modes-notice.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/ts/elements/modes-notice.ts b/frontend/src/ts/elements/modes-notice.ts index ebd21d05e791..aa2888565dcd 100644 --- a/frontend/src/ts/elements/modes-notice.ts +++ b/frontend/src/ts/elements/modes-notice.ts @@ -10,6 +10,7 @@ import * as CustomTextState from "../states/custom-text-name"; import { getLanguageDisplayString } from "../utils/strings"; import Format from "../utils/format"; import { getActiveFunboxNames } from "../test/funbox/list"; +import { escapeHTML } from "../utils/misc"; ConfigEvent.subscribe((eventKey) => { const configKeys: ConfigEvent.ConfigEventKey[] = [ @@ -79,7 +80,9 @@ export async function update(): Promise { const isLong = CustomTextState.isCustomTextLong(); if (Config.mode === "custom" && customTextName !== "" && isLong) { $(".pageTest #testModesNotice").append( - `
${customTextName} (shift + enter to save progress)
` + `
${escapeHTML( + customTextName + )} (shift + enter to save progress)
` ); } From f025b121cbe437e29de432b4aa72e0de22c755b7 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 24 Sep 2025 14:46:19 +0200 Subject: [PATCH 6/7] impr(save custom text modal): add validation for custom text name input --- frontend/src/ts/modals/save-custom-text.ts | 98 ++++++++-------------- 1 file changed, 37 insertions(+), 61 deletions(-) diff --git a/frontend/src/ts/modals/save-custom-text.ts b/frontend/src/ts/modals/save-custom-text.ts index d63e77f946f0..af3b78f9f7fa 100644 --- a/frontend/src/ts/modals/save-custom-text.ts +++ b/frontend/src/ts/modals/save-custom-text.ts @@ -1,11 +1,13 @@ import * as CustomText from "../test/custom-text"; import * as Notifications from "../elements/notifications"; import * as CustomTextState from "../states/custom-text-name"; -import { InputIndicator } from "../elements/input-indicator"; -import { debounce } from "throttle-debounce"; import AnimatedModal, { ShowOptions } from "../utils/animated-modal"; +import { validateWithIndicator } from "../elements/input-validation"; +import { z } from "zod"; -let indicator: InputIndicator | undefined; +type IncomingData = { + text: string[]; +}; type State = { textToSave: string[]; @@ -15,6 +17,35 @@ const state: State = { textToSave: [], }; +const validatedInput = validateWithIndicator( + $("#saveCustomTextModal .textName")[0] as HTMLInputElement, + { + debounceDelay: 500, + schema: z + .string() + .min(1) + .max(32) + .regex(/^[\w\s-]+$/, { + message: + "Name can only contain letters, numbers, spaces, underscores and hyphens", + }), + isValid: async (value) => { + const checkbox = $("#saveCustomTextModal .isLongText").prop( + "checked" + ) as boolean; + const names = CustomText.getCustomTextNames(checkbox); + return !names.includes(value) ? true : "Duplicate name"; + }, + callback: (result) => { + if (result.status === "success") { + $("#saveCustomTextModal button.save").prop("disabled", false); + } else { + $("#saveCustomTextModal button.save").prop("disabled", true); + } + }, + } +); + export async function show(options: ShowOptions): Promise { state.textToSave = []; void modal.show({ @@ -28,10 +59,6 @@ export async function show(options: ShowOptions): Promise { }); } -function hide(): void { - void modal.hide(); -} - function save(): boolean { const name = $("#saveCustomTextModal .textName").val() as string; const checkbox = $("#saveCustomTextModal .isLongText").prop( @@ -59,69 +86,18 @@ function save(): boolean { } } -function updateIndicatorAndButton(): void { - const val = $("#saveCustomTextModal .textName").val() as string; - const checkbox = $("#saveCustomTextModal .isLongText").prop( - "checked" - ) as boolean; - - if (!val) { - indicator?.hide(); - $("#saveCustomTextModal button.save").prop("disabled", true); - } else { - const names = CustomText.getCustomTextNames(checkbox); - if (names.includes(val)) { - indicator?.show("unavailable"); - $("#saveCustomTextModal button.save").prop("disabled", true); - } else { - indicator?.show("available"); - $("#saveCustomTextModal button.save").prop("disabled", false); - } - } -} - -const updateInputAndButtonDebounced = debounce(500, updateIndicatorAndButton); - async function setup(modalEl: HTMLElement): Promise { - indicator = new InputIndicator($("#saveCustomTextModal .textName"), { - available: { - icon: "fa-check", - level: 1, - }, - unavailable: { - icon: "fa-times", - level: -1, - }, - loading: { - icon: "fa-circle-notch", - spinIcon: true, - level: 0, - }, - }); modalEl.addEventListener("submit", (e) => { e.preventDefault(); - if (save()) hide(); - }); - modalEl.querySelector(".textName")?.addEventListener("input", (e) => { - const val = (e.target as HTMLInputElement).value; - if (val.length > 0) { - indicator?.show("loading"); - updateInputAndButtonDebounced(); + if (validatedInput.isValid() === true && save()) { + void modal.hide(); } }); modalEl.querySelector(".isLongText")?.addEventListener("input", (e) => { - const val = (e.target as HTMLInputElement).value; - if (val.length > 0) { - indicator?.show("loading"); - updateInputAndButtonDebounced(); - } + validatedInput.triggerValidation(); }); } -type IncomingData = { - text: string[]; -}; - const modal = new AnimatedModal({ dialogId: "saveCustomTextModal", setup, From de847fc314cfc39852fa374f53844477f92cb72e Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 24 Sep 2025 16:00:37 +0200 Subject: [PATCH 7/7] refactor(input validation): rework getting current validation status (@miodec) (#6988) Rename to diffrentiate between the predicate `isValid` and the element `isValid` Return `ValidationResult` instead of just a boolean Update usage to the new format Move config specific `resetIfEmpty` to the `ConfigInputOptions` type --- frontend/src/ts/elements/input-validation.ts | 18 ++++++++++-------- frontend/src/ts/modals/edit-preset.ts | 2 +- frontend/src/ts/modals/save-custom-text.ts | 2 +- frontend/src/ts/pages/login.ts | 6 ++++-- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/frontend/src/ts/elements/input-validation.ts b/frontend/src/ts/elements/input-validation.ts index c07c61760c05..a1971bd622f8 100644 --- a/frontend/src/ts/elements/input-validation.ts +++ b/frontend/src/ts/elements/input-validation.ts @@ -32,9 +32,6 @@ export type Validation = { /** custom debounce delay for `isValid` call. defaults to 100 */ debounceDelay?: number; - - /** Resets the value to the current config if empty */ - resetIfEmpty?: false; }; // oxlint-disable-next-line no-explicit-any @@ -144,7 +141,7 @@ export type ValidationOptions = (T extends string }; export type ValidatedHtmlInputElement = HTMLInputElement & { - isValid: () => boolean | undefined; + getValidationResult: () => ValidationResult; setValue: (val: string | null) => void; triggerValidation: () => void; }; @@ -178,9 +175,11 @@ export function validateWithIndicator( }, }); - let isValid: boolean | undefined = undefined; + let currentStatus: ValidationResult = { + status: "checking", + }; const callback = (result: ValidationResult): void => { - isValid = result.status === "success" || result.status === "warning"; + currentStatus = result; if (result.status === "failed" || result.status === "warning") { indicator.show(result.status, result.errorMessage); } else { @@ -198,11 +197,12 @@ export function validateWithIndicator( inputElement.addEventListener("input", handler); const result = inputElement as ValidatedHtmlInputElement; - result.isValid = () => isValid; + result.getValidationResult = () => { + return currentStatus; + }; result.setValue = (val: string | null) => { inputElement.value = val ?? ""; if (val === null) { - isValid = undefined; indicator.hide(); } else { inputElement.dispatchEvent(new Event("input")); @@ -227,6 +227,8 @@ export type ConfigInputOptions = { schema: boolean; /** optional callback is called for each change of the validation result */ validationCallback?: (result: ValidationResult) => void; + /** Resets the value to the current config if empty */ + resetIfEmpty?: false; }; }; diff --git a/frontend/src/ts/modals/edit-preset.ts b/frontend/src/ts/modals/edit-preset.ts index 6f9cdd91a117..cf0e6a258c2e 100644 --- a/frontend/src/ts/modals/edit-preset.ts +++ b/frontend/src/ts/modals/edit-preset.ts @@ -262,7 +262,7 @@ async function apply(): Promise { return; } - if (presetNameEl?.isValid() === false) { + if (presetNameEl?.getValidationResult().status === "failed") { Notifications.add("Preset name is not valid", 0); return; } diff --git a/frontend/src/ts/modals/save-custom-text.ts b/frontend/src/ts/modals/save-custom-text.ts index af3b78f9f7fa..6bf9cd8d6ed3 100644 --- a/frontend/src/ts/modals/save-custom-text.ts +++ b/frontend/src/ts/modals/save-custom-text.ts @@ -89,7 +89,7 @@ function save(): boolean { async function setup(modalEl: HTMLElement): Promise { modalEl.addEventListener("submit", (e) => { e.preventDefault(); - if (validatedInput.isValid() === true && save()) { + if (validatedInput.getValidationResult().status === "success" && save()) { void modal.hide(); } }); diff --git a/frontend/src/ts/pages/login.ts b/frontend/src/ts/pages/login.ts index 632b6f5944fc..715c9c4d4a85 100644 --- a/frontend/src/ts/pages/login.ts +++ b/frontend/src/ts/pages/login.ts @@ -170,7 +170,8 @@ validateWithIndicator(emailVerifyInputEl, { debounceDelay: 0, callback: (result) => { registerForm.email = - emailInputEl.isValid() && result.status === "success" + emailInputEl.getValidationResult().status === "success" && + result.status === "success" ? emailInputEl.value : undefined; updateSignupButton(); @@ -204,7 +205,8 @@ validateWithIndicator(passwordVerifyInputEl, { debounceDelay: 0, callback: (result) => { registerForm.password = - passwordInputEl.isValid() && result.status === "success" + passwordInputEl.getValidationResult().status === "success" && + result.status === "success" ? passwordInputEl.value : undefined; updateSignupButton();