From 6ae8f10694878456fe1812919d2c04337f03286e Mon Sep 17 00:00:00 2001 From: Mathias Oterhals Myklebust <24361490+mathiazom@users.noreply.github.com> Date: Fri, 27 Sep 2024 07:42:06 +0000 Subject: [PATCH] v3 - proper slug change validation (#698) * fix(slug): prevent slug change after publication * refactor(slug): extract validation logic out of schema definition --- studio/components/CustomCallToActions.tsx | 5 +- studio/schemas/schemaTypes/slug.ts | 114 +++++++++++++++------- studio/utils/documentUtils.ts | 22 ++++- 3 files changed, 102 insertions(+), 39 deletions(-) diff --git a/studio/components/CustomCallToActions.tsx b/studio/components/CustomCallToActions.tsx index 13c5c13ee..e03794cbf 100644 --- a/studio/components/CustomCallToActions.tsx +++ b/studio/components/CustomCallToActions.tsx @@ -10,6 +10,7 @@ import { import { fetchWithToken } from "studio/lib/fetchWithToken"; import { LANDING_PAGE_REF_QUERY } from "studio/lib/queries/navigation"; +import { buildPublishedId } from "studio/utils/documentUtils"; type CustomCallToActionsProps = ArrayOfObjectsInputProps< { _key: string }, @@ -44,9 +45,7 @@ const CustomCallToActions: React.FC = (props) => { useEffect(() => { if (!landingPageId) return; - const currentPageId = documentId?.startsWith("drafts.") - ? documentId.split("drafts.")[1] - : documentId; + const currentPageId = buildPublishedId(documentId); const isLanding = landingPageId === currentPageId; diff --git a/studio/schemas/schemaTypes/slug.ts b/studio/schemas/schemaTypes/slug.ts index d4b980386..dc3e15f0f 100644 --- a/studio/schemas/schemaTypes/slug.ts +++ b/studio/schemas/schemaTypes/slug.ts @@ -1,24 +1,72 @@ -import { SlugValidationContext, defineField } from "sanity"; +import { + SlugValidationContext, + SlugValue, + ValidationContext, + defineField, + isSlug, +} from "sanity"; -import { isPublished } from "studio/utils/documentUtils"; +import { apiVersion } from "studio/env"; +import { + buildDraftId, + buildPublishedId, + isPublished, +} from "studio/utils/documentUtils"; -async function isSlugUniqueAcrossAllDocuments( +function isSlugUniqueAcrossAllDocuments( slug: string, - context: SlugValidationContext, + { document, getClient }: SlugValidationContext, ) { - const { document, getClient } = context; - const client = getClient({ apiVersion: "2022-12-07" }); - const id = document?._id.replace(/^drafts\./, ""); - const params = { - draft: `drafts.${id}`, - published: id, - slug, - }; - const SLUG_QUERY = - "!defined(*[!(_id in [$draft, $published]) && slug.current == $slug][0]._id)"; + if (document === undefined) { + return true; + } + return getClient({ apiVersion }).fetch( + "!defined(*[!(_id in [$draft, $published]) && slug.current == $slug][0]._id)", + { + draft: buildDraftId(document._id), + published: buildPublishedId(document._id), + slug, + }, + ); +} + +/** + Validate that slug has not been changed after initial publication + */ +async function validateSlugNotChangedAfterPublication( + value: SlugValue | undefined, + { document, getClient }: ValidationContext, +) { + if (document === undefined || isPublished(document)) { + return true; + } + const publishedDocument = await getClient({ apiVersion }).getDocument( + buildPublishedId(document._id), + ); + if ( + publishedDocument !== undefined && + "slug" in publishedDocument && + isSlug(publishedDocument.slug) && + publishedDocument.slug.current !== value?.current + ) { + return "Can not be changed after publication"; + } + return true; +} - const result = await client.fetch(SLUG_QUERY, params); - return result; +function slugify(input: string): string { + return ( + input + .toLowerCase() + // replace æøå according to https://sprakradet.no/spraksporsmal-og-svar/ae-o-og-a-i-internasjonal-sammenheng/ + .replace(/[æ,å]/g, "a") + .replace(/ø/g, "o") + // remove non-whitespace URL-unsafe chars (section 2.3 in https://www.ietf.org/rfc/rfc3986.txt) + .replace(/[^a-zA-Z0-9-_.~\s]/g, "") + .trim() + .replace(/\s+/g, "-") + .slice(0, SLUG_MAX_LENGTH) + ); } const SLUG_MAX_LENGTH = 200; @@ -36,34 +84,30 @@ function createSlugField(source: string) { options: { source, maxLength: SLUG_MAX_LENGTH, - slugify: (input) => - input - .toLowerCase() - // replace æøå according to https://sprakradet.no/spraksporsmal-og-svar/ae-o-og-a-i-internasjonal-sammenheng/ - .replace(/[æ,å]/g, "a") - .replace(/ø/g, "o") - .replace(/[^a-zA-Z0-9-_.~\s]/g, "") // remove non-whitespace URL-unsafe chars (section 2.3 in https://www.ietf.org/rfc/rfc3986.txt) - .trim() - .replace(/\s+/g, "-") - .slice(0, SLUG_MAX_LENGTH), + slugify, isUnique: isSlugUniqueAcrossAllDocuments, }, validation: (rule) => - rule.required().custom((value) => { - if (value?.current === undefined) return true; - return ( - encodeURIComponent(value.current) === value.current || - "Slug can only consist of latin letters (a-z, A-Z), digits (0-9), hyphen (-), underscore (_), full stop (.) and tilde (~)" - ); - }), - readOnly: (ctx) => { + rule + .required() + .custom(validateSlugNotChangedAfterPublication) + .custom((value) => { + if (value?.current === undefined) return true; + return ( + encodeURIComponent(value.current) === value.current || + "Slug can only consist of latin letters (a-z, A-Z), digits (0-9), hyphen (-), underscore (_), full stop (.) and tilde (~)" + ); + }), + readOnly: ({ document }) => { /* make slugs read-only after initial publish to avoid breaking shared links + if document is already draft, this is handled through validation instead + if new slugs are needed, redirects can be used instead */ - return ctx.document !== undefined && isPublished(ctx.document); + return document !== undefined && isPublished(document); }, }); } diff --git a/studio/utils/documentUtils.ts b/studio/utils/documentUtils.ts index 26427312e..b1ea23891 100644 --- a/studio/utils/documentUtils.ts +++ b/studio/utils/documentUtils.ts @@ -1,9 +1,29 @@ import type { SanityDocument } from "@sanity/types"; +export const DRAFTS_PREFIX = "drafts."; + export function isPublished(document: SanityDocument) { return !isDraft(document); } export function isDraft(document: SanityDocument) { - return document._id.startsWith("drafts.") || document._rev === undefined; + return isDraftId(document._id) || document._rev === undefined; +} + +export function buildDraftId(documentId: string): string { + return isDraftId(documentId) ? documentId : `${DRAFTS_PREFIX}${documentId}`; +} + +export function buildPublishedId(documentId: string): string { + return isPublishedId(documentId) + ? documentId + : documentId.slice(DRAFTS_PREFIX.length); +} + +function isPublishedId(documentId: string) { + return !isDraftId(documentId); +} + +function isDraftId(documentId: string) { + return documentId.startsWith(DRAFTS_PREFIX); }