Skip to content

Commit

Permalink
Add URL Slug Validation (#291)
Browse files Browse the repository at this point in the history
For both entity and static pages, this pull request creates a function
to check whether getPath() returns a valid or invalid (undefined or
containing unsafe characters) URL slug value. If it's invalid, the page
settings modal does not allow editing of the URL slug. If it's valid,
then the modal does allow editing. However, if the user tries to save an
invalid URL slug in the modal, then an error message is displayed and
the Save button is disabled until new user input.

For an entity page, a valid URL slug also includes any string that
contains ${document.field} where "field" is variable.

J=SLAP-2826
TEST=auto, manual

Checked that URL behavior works as expected in the test site.

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Oliver Shi <[email protected]>
Co-authored-by: Jacob <[email protected]>
Co-authored-by: Jacob Wartofsky <[email protected]>
  • Loading branch information
5 people authored Aug 17, 2023
1 parent bbf7d09 commit 55aa516
Show file tree
Hide file tree
Showing 13 changed files with 299 additions and 117 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import useStudioStore from "../../store/useStudioStore";
import AddPageContext from "./AddPageContext";
import TemplateExpressionFormatter from "../../utils/TemplateExpressionFormatter";
import { GetPathVal, PropValueKind } from "@yext/studio-plugin";
import PageDataValidator from "../../utils/PageDataValidator";

type BasicPageData = {
pageName: string;
Expand All @@ -22,6 +23,10 @@ export default function BasicPageDataCollector({
);
const { state } = useContext(AddPageContext);
const isEntityPage = isPagesJSRepo && !state.isStatic;
const pageDataValidator = useMemo(
() => new PageDataValidator(isEntityPage),
[isEntityPage]
);

const formData: FormData<BasicPageData> = useMemo(
() => ({
Expand All @@ -37,10 +42,18 @@ export default function BasicPageDataCollector({

const onConfirm = useCallback(
async (data: BasicPageData) => {
const getPathValue = data.url
? createGetPathVal(data.url, isEntityPage)
: undefined;
const validationResult = pageDataValidator.validate({
...data,
url: getPathValue?.value,
});
if (!validationResult.valid) {
setErrorMessage(validationResult.errorMessages.join("\r\n"));
return false;
}
try {
const getPathValue = data.url
? createGetPathVal(data.url, isEntityPage)
: undefined;
await handleConfirm(data.pageName, getPathValue);
return true;
} catch (err: unknown) {
Expand All @@ -52,7 +65,7 @@ export default function BasicPageDataCollector({
}
}
},
[handleConfirm, isEntityPage]
[handleConfirm, isEntityPage, pageDataValidator]
);

const transformOnChangeValue = useCallback(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import useStudioStore from "../../store/useStudioStore";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import FormModal, { FormData } from "../common/FormModal";
import { GetPathVal, PropValueKind, ResponseType } from "@yext/studio-plugin";
import TemplateExpressionFormatter from "../../utils/TemplateExpressionFormatter";
Expand All @@ -10,6 +10,7 @@ import StreamScopeParser, {
import { PageSettingsModalProps } from "./PageSettingsButton";
import { StaticPageSettings } from "./StaticPageModal";
import { streamScopeFormData } from "../AddPageButton/StreamScopeCollector";
import PageDataValidator from "../../utils/PageDataValidator";
import { toast } from "react-toastify";
import { isEqual } from "lodash";

Expand Down Expand Up @@ -38,28 +39,34 @@ export default function EntityPageModal({
store.pages.updateStreamScope,
store.actions.generateTestData,
]);
const isPathUndefined = !currGetPathValue;
const [errorMessage, setErrorMessage] = useState<string>("");
const pageDataValidator = useMemo(() => new PageDataValidator(true), []);
const isURLEditable = useMemo(
() => pageDataValidator.checkIsURLEditable(currGetPathValue?.value),
[currGetPathValue?.value, pageDataValidator]
);

const initialFormValue: EntityPageSettings = useMemo(
() => ({
url: getUrlDisplayValue(currGetPathValue),
url: isURLEditable ? getUrlDisplayValue(currGetPathValue) : "",
...StreamScopeParser.convertStreamScopeToForm(streamScope),
}),
[currGetPathValue, streamScope]
[currGetPathValue, streamScope, isURLEditable]
);

const entityFormData: FormData<EntityPageSettings> = useMemo(
() => ({
url: {
description: "URL Slug",
optional: isPathUndefined,
placeholder: isPathUndefined
? "<URL slug is defined by developer>"
: "",
optional: !isURLEditable,
placeholder: isURLEditable
? ""
: "<URL slug is not editable in Studio. Consult a developer>",
disabled: !isURLEditable,
},
...streamScopeFormData,
}),
[isPathUndefined]
[isURLEditable]
);

const handleModalSave = useCallback(
Expand All @@ -68,6 +75,13 @@ export default function EntityPageModal({
kind: PropValueKind.Expression,
value: TemplateExpressionFormatter.getRawValue(form.url),
};
const validationResult = pageDataValidator.validate({
url: getPathValue.value,
});
if (!validationResult.valid) {
setErrorMessage(validationResult.errorMessages.join("\r\n"));
return false;
}
if (form.url || currGetPathValue) {
updateGetPathValue(pageName, getPathValue);
}
Expand All @@ -91,6 +105,7 @@ export default function EntityPageModal({
updateStreamScope,
currGetPathValue,
pageName,
pageDataValidator,
generateTestData,
streamScope,
]
Expand All @@ -103,6 +118,7 @@ export default function EntityPageModal({
instructions="Use the optional fields below to specify which entities this page can access. Values should be separated by commas. Changing the scope of the stream (entity IDs, entity type IDs, and saved filter IDs) may result in entity data references being invalid or out of date."
formData={entityFormData}
initialFormValue={initialFormValue}
errorMessage={errorMessage}
requireChangesToSubmit={true}
handleClose={handleClose}
handleConfirm={handleModalSave}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import useStudioStore from "../../store/useStudioStore";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import FormModal, { FormData } from "../common/FormModal";
import { GetPathVal, PropValueKind } from "@yext/studio-plugin";
import { PageSettingsModalProps } from "./PageSettingsButton";
import PageDataValidator from "../../utils/PageDataValidator";

export type StaticPageSettings = {
url: string;
Expand All @@ -21,7 +22,12 @@ export default function StaticPageModal({
store.pages.pages[pageName].pagesJS?.getPathValue,
store.pages.updateGetPathValue,
]);
const isPathUndefined = !currGetPathValue;
const [errorMessage, setErrorMessage] = useState<string>("");
const pageDataValidator = useMemo(() => new PageDataValidator(), []);
const isURLEditable = useMemo(
() => pageDataValidator.checkIsURLEditable(currGetPathValue?.value),
[currGetPathValue?.value, pageDataValidator]
);

const initialFormValue: StaticPageSettings = useMemo(
() => ({ url: currGetPathValue?.value ?? "" }),
Expand All @@ -32,13 +38,14 @@ export default function StaticPageModal({
() => ({
url: {
description: "URL Slug",
optional: isPathUndefined,
placeholder: isPathUndefined
? "<URL slug is defined by developer>"
: "",
optional: !isURLEditable,
placeholder: isURLEditable
? ""
: "<URL slug is not editable in Studio. Consult a developer>",
disabled: !isURLEditable,
},
}),
[isPathUndefined]
[isURLEditable]
);

const handleModalSave = useCallback(
Expand All @@ -47,10 +54,17 @@ export default function StaticPageModal({
kind: PropValueKind.Literal,
value: form.url,
};
const validationResult = pageDataValidator.validate({
url: getPathValue.value,
});
if (!validationResult.valid) {
setErrorMessage(validationResult.errorMessages.join("\r\n"));
return false;
}
updateGetPathValue(pageName, getPathValue);
return true;
},
[updateGetPathValue, pageName]
[updateGetPathValue, pageName, pageDataValidator]
);

return (
Expand All @@ -59,6 +73,7 @@ export default function StaticPageModal({
title="Page Settings"
formData={staticFormData}
initialFormValue={initialFormValue}
errorMessage={errorMessage}
requireChangesToSubmit={true}
handleClose={handleClose}
handleConfirm={handleModalSave}
Expand Down
4 changes: 4 additions & 0 deletions packages/studio/src/components/common/FormModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type FormData<T extends Form> = {
optional?: boolean;
placeholder?: string;
tooltip?: string;
disabled?: boolean;
};
};

Expand Down Expand Up @@ -149,6 +150,7 @@ function FormField({
description,
placeholder,
tooltip,
disabled,
}: {
field: string;
value: string;
Expand All @@ -157,6 +159,7 @@ function FormField({
description: string;
placeholder?: string;
tooltip?: string;
disabled?: boolean;
}): JSX.Element {
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
Expand All @@ -182,6 +185,7 @@ function FormField({
placeholder={placeholder}
value={value}
onChange={handleChange}
disabled={disabled}
/>
</>
);
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/components/common/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ export default function Modal({
{body}
<div className={footerClasses}>
{errorMessage && (
<div className="flex justify-start text-red-600">{errorMessage}</div>
<div className="flex justify-start whitespace-pre-wrap text-red-600">
{errorMessage}
</div>
)}
<div className="flex justify-end">
<button className="ml-2" onClick={handleClose}>
Expand Down
25 changes: 2 additions & 23 deletions packages/studio/src/store/StudioActions/CreatePageAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,30 +74,9 @@ function validate(
getPathValue?: GetPathVal
) {
if (isPagesJSRepo && !getPathValue) {
throw new Error("Error adding page: a getPath value is required.");
}
if (!pageName) {
throw new Error("Error adding page: a pageName is required.");
throw new Error("A getPath value is required.");
}
if (!filepath.startsWith(pagesPath)) {
throw new Error(`Error adding page: pageName is invalid: ${pageName}`);
}
const errorChars = pageName.match(/[\\/?%*:|"<>]/g);
if (errorChars) {
throw new Error(
`Error adding page: pageName ${pageName} cannot contain the characters: ${[
...new Set(errorChars),
]}`
);
}
if (pageName.endsWith(".")) {
throw new Error(
`Error adding page: pageName ${pageName} cannot end with a period.`
);
}
if (pageName.length > 255) {
throw new Error(
"Error adding page: pageName must be 255 characters or less."
);
throw new Error(`Page name is invalid: ${pageName}`);
}
}
4 changes: 1 addition & 3 deletions packages/studio/src/store/slices/pages/createPageSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@ export const createPageSlice: SliceCreator<PageSlice> = (set, get) => {
const pageActions = {
addPage: (pageName: string, page: PageState) => {
if (get().pages[pageName]) {
throw new Error(
`Error adding page: page name "${pageName}" is already used.`
);
throw new Error(`Page name "${pageName}" is already used.`);
}

set((store) => {
Expand Down
87 changes: 87 additions & 0 deletions packages/studio/src/utils/PageDataValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
export interface ValidationResult {
valid: boolean;
errorMessages: string[];
}

/**
* PageDataValidator contains various static utility methods
* for validation of user-inputted page data.
*/
export default class PageDataValidator {
private isEntityPage = false;

constructor(isEntityPage?: boolean) {
if (isEntityPage) {
this.isEntityPage = isEntityPage;
}
}

checkIsURLEditable(url?: string) {
if (!url) {
return false;
}
const result: ValidationResult = this.validate({ url });
return result.valid;
}
/**
* Throws an error if the user-inputted page data is invalid.
*/
validate(pageData: { pageName?: string; url?: string }): ValidationResult {
const errorMessages: string[] = [];
if (pageData.pageName !== undefined)
errorMessages.push(...this.validatePageName(pageData.pageName));
if (pageData.url !== undefined)
errorMessages.push(
...this.validateURLSlug(pageData.url, this.isEntityPage)
);
return {
valid: errorMessages.length === 0,
errorMessages: errorMessages,
} as ValidationResult;
}

/**
* Throws an error if the page name is invalid.
*/
private validatePageName(pageName: string) {
const errorMessages: string[] = [];
if (!pageName) {
errorMessages.push("A page name is required.");
}
const errorChars = pageName.match(/[\\/?%*:|"<>]/g);
if (errorChars) {
errorMessages.push(
`Page name cannot contain the characters: ${[
...new Set(errorChars),
].join("")}`
);
}
if (pageName.endsWith(".")) {
errorMessages.push(`Page name cannot end with a period.`);
}
if (pageName.length > 255) {
errorMessages.push("Page name must be 255 characters or less.");
}
return errorMessages;
}

/**
* Throws an error if the URL Slug is invalid.
*/
private validateURLSlug(input: string, isEntityPage?: boolean) {
const cleanInput = isEntityPage
? input.replace(/\${document\..*?}/g, "")
: input;
const blackListURLChars = new RegExp(/[ <>""''|\\{}[\]]/g);
const errorChars = cleanInput.match(blackListURLChars);
const errorMessages: string[] = [];
if (errorChars) {
errorMessages.push(
`URL slug contains invalid characters: ${[...new Set(errorChars)].join(
""
)}`
);
}
return errorMessages;
}
}
Loading

0 comments on commit 55aa516

Please sign in to comment.