{
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)
`
);
}
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 d63e77f946f0..6bf9cd8d6ed3 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.getValidationResult().status === "success" && 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,
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;
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();
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 {