Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion frontend/__tests__/utils/misc.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -123,6 +123,46 @@ describe("misc.ts", () => {
});
});

describe("escapeHTML", () => {
it("should escape HTML characters correctly", () => {
const tests = [
{
input: "hello world",
expected: "hello world",
},
{
input: "<script>alert('xss')</script>",
expected: "&lt;script&gt;alert(&#39;xss&#39;)&lt;&#x2F;script&gt;",
},
{
input: 'Hello "world" & friends',
expected: "Hello &quot;world&quot; &amp; friends",
},
{
input: "Click `here` to continue",
expected: "Click &#x60;here&#x60; 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 = [
Expand Down
22 changes: 14 additions & 8 deletions frontend/src/ts/elements/input-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,6 @@ export type Validation<T> = {

/** 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
Expand Down Expand Up @@ -144,8 +141,9 @@ export type ValidationOptions<T> = (T extends string
};

export type ValidatedHtmlInputElement = HTMLInputElement & {
isValid: () => boolean | undefined;
getValidationResult: () => ValidationResult;
setValue: (val: string | null) => void;
triggerValidation: () => void;
};
/**
* adds an 'InputIndicator` to the given `inputElement` and updates its status depending on the given validation
Expand Down Expand Up @@ -177,9 +175,11 @@ export function validateWithIndicator<T>(
},
});

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 {
Expand All @@ -197,16 +197,20 @@ export function validateWithIndicator<T>(
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"));
}
};
result.triggerValidation = () => {
inputElement.dispatchEvent(new Event("input"));
};

return result;
}
Expand All @@ -223,6 +227,8 @@ export type ConfigInputOptions<K extends ConfigKey, T = ConfigType[K]> = {
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;
};
};

Expand Down
5 changes: 4 additions & 1 deletion frontend/src/ts/elements/modes-notice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -79,7 +80,9 @@ export async function update(): Promise<void> {
const isLong = CustomTextState.isCustomTextLong();
if (Config.mode === "custom" && customTextName !== "" && isLong) {
$(".pageTest #testModesNotice").append(
`<div class="textButton noInteraction"><i class="fas fa-book"></i>${customTextName} (shift + enter to save progress)</div>`
`<div class="textButton noInteraction"><i class="fas fa-book"></i>${escapeHTML(
customTextName
)} (shift + enter to save progress)</div>`
);
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/ts/modals/edit-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ async function apply(): Promise<void> {
return;
}

if (presetNameEl?.isValid() === false) {
if (presetNameEl?.getValidationResult().status === "failed") {
Notifications.add("Preset name is not valid", 0);
return;
}
Expand Down
98 changes: 37 additions & 61 deletions frontend/src/ts/modals/save-custom-text.ts
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -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<IncomingData>): Promise<void> {
state.textToSave = [];
void modal.show({
Expand All @@ -28,10 +59,6 @@ export async function show(options: ShowOptions<IncomingData>): Promise<void> {
});
}

function hide(): void {
void modal.hide();
}

function save(): boolean {
const name = $("#saveCustomTextModal .textName").val() as string;
const checkbox = $("#saveCustomTextModal .isLongText").prop(
Expand Down Expand Up @@ -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<void> {
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<IncomingData>({
dialogId: "saveCustomTextModal",
setup,
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/ts/pages/about.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ async function fill(): Promise<void> {
const supportersEl = document.querySelector(".pageAbout .supporters");
let supportersHTML = "";
for (const supporter of supporters ?? []) {
supportersHTML += `<div>${supporter}</div>`;
supportersHTML += `<div>${Misc.escapeHTML(supporter)}</div>`;
}
if (supportersEl) {
supportersEl.innerHTML = supportersHTML;
Expand All @@ -160,7 +160,7 @@ async function fill(): Promise<void> {
const contributorsEl = document.querySelector(".pageAbout .contributors");
let contributorsHTML = "";
for (const contributor of contributors ?? []) {
contributorsHTML += `<div>${contributor}</div>`;
contributorsHTML += `<div>${Misc.escapeHTML(contributor)}</div>`;
}
if (contributorsEl) {
contributorsEl.innerHTML = contributorsHTML;
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/ts/pages/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
22 changes: 13 additions & 9 deletions frontend/src/ts/utils/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,18 +164,22 @@ export function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

export function escapeHTML(str: string): string {
export function escapeHTML<T extends string | null | undefined>(str: T): T {
if (str === null || str === undefined) {
return str;
}
str = str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");

return str;

const escapeMap: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
"/": "&#x2F;",
"`": "&#x60;",
};

return str.replace(/[&<>"'/`]/g, (char) => escapeMap[char] as string) as T;
}

export function isUsernameValid(name: string): boolean {
Expand Down
Loading