diff --git a/apps/web/helpers/__tests__/coerceUUIDParams.test.ts b/apps/web/helpers/__tests__/coerceUUIDParams.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bd0e0402d9cd3e08afa9839ddef318e984eb921 --- /dev/null +++ b/apps/web/helpers/__tests__/coerceUUIDParams.test.ts @@ -0,0 +1,32 @@ +import { createUUID } from '@holi/core/helpers' +import { coerceUUIDParams } from '@holi/web/helpers/coerceUUIDParams' + +describe('coerceUUIDParams', () => { + it('should replace invalid UUID param', () => { + const uuid = createUUID() + const invalid = `${uuid},` + + const url = coerceUUIDParams([invalid], `/path/${invalid}`) + + expect(url).toEqual(`/path/${uuid}`) + }) + + it('should replace multiple invalid UUID params', () => { + const uuid1 = createUUID() + const uuid2 = createUUID() + const invalid1 = `${uuid1},` + const invalid2 = `${uuid2}👀` + + const url = coerceUUIDParams([invalid1, invalid2], `/path/${invalid2}/${invalid1}`) + + expect(url).toEqual(`/path/${uuid2}/${uuid1}`) + }) + + it('should return undefined if all params are valid', () => { + const uuid = createUUID() + + const url = coerceUUIDParams([uuid], `/path/${uuid}`) + + expect(url).toBeUndefined() + }) +}) diff --git a/apps/web/helpers/__tests__/createServerSideProps.test.ts b/apps/web/helpers/__tests__/createServerSideProps.test.ts index 43bd163f3a9a130273eacd5cfd28de36f067ee0d..3d87a1cf0b5b34009a3c32736af71c9c911162fa 100644 --- a/apps/web/helpers/__tests__/createServerSideProps.test.ts +++ b/apps/web/helpers/__tests__/createServerSideProps.test.ts @@ -5,6 +5,7 @@ import type { NextPageContext } from 'next' import { WebApolloClientOptions } from '@holi/api/graphql/client' import { createServerSideProps } from '@holi/web/helpers/createServerSideProps' import { HoliPageProps } from '@holi/web/types' +import { createUUID } from '@holi/core/helpers' const mockLoadTranslations = jest.fn() jest.mock('ni18n', () => ({ @@ -259,4 +260,42 @@ describe('createServerSideProps', () => { expect(new HoliPageProps(props).user).toEqual(expectedUserProps) }) + + it('should redirect on invalid UUID params before executing any queries', async () => { + const uuid1 = createUUID() + const uuid2 = createUUID() + const param1 = `${uuid1},` + const param2 = `${uuid2}👀` + const url = `/path/${param1}/${param2}` + const expectedUrl = `/path/${uuid1}/${uuid2}` + + // @ts-ignore + const props = await createServerSideProps({ req: { url } }, [query1], { uuidParams: [param1, param2] }) + + expect(mockQueryFunction).not.toHaveBeenCalled() + expect(props).toEqual({ + redirect: { + destination: expectedUrl, + permanent: true, + }, + }) + }) + + it('should not redirect on valid UUID params', async () => { + const param1 = createUUID() + const param2 = createUUID() + const url = `/path/${param1}/${param2}` + const customProps = { uuidParams: [param1, param2] } + + // @ts-ignore + const props = await createServerSideProps({ req: { url } }, [query1], customProps) + + expect(mockQueryFunction).toHaveBeenCalledWith(query1) + expect(props).toEqual({ + props: { + ...expectedProps, + customProps, + }, + }) + }) }) diff --git a/apps/web/helpers/coerceUUIDParams.ts b/apps/web/helpers/coerceUUIDParams.ts new file mode 100644 index 0000000000000000000000000000000000000000..681ddf1b82d94ea8866904baabf4389457159efc --- /dev/null +++ b/apps/web/helpers/coerceUUIDParams.ts @@ -0,0 +1,30 @@ +import { coerceToUUIDString } from '@holi/core/helpers' + +const getSingleParam = (path: string | string[] | undefined) => { + if (Array.isArray(path)) { + return path.length ? path[0] : '' + } + return path +} + +export const coerceUUIDParams = (uuidParams: (string | string[] | undefined)[], url?: string): string | undefined => { + const replacements = uuidParams + .map((param) => { + const singleParam = getSingleParam(param) + if (!singleParam) { + return undefined + } + const validParam = coerceToUUIDString(singleParam) + if (validParam !== singleParam) { + return [singleParam, validParam] + } + return undefined + }) + .filter((replacement) => !!replacement) + + if (!replacements.length) { + return undefined + } + + return replacements.reduce((url, [invalid, valid]) => url?.replace(invalid, valid), url) +} diff --git a/apps/web/helpers/createServerSideProps.ts b/apps/web/helpers/createServerSideProps.ts index 4284b02d036d9786332f5cd1a6d62c5e969de414..feb5c2f1a8209736c08a469738a7722967083972 100644 --- a/apps/web/helpers/createServerSideProps.ts +++ b/apps/web/helpers/createServerSideProps.ts @@ -10,6 +10,7 @@ import { getOrigin } from '@holi/web/helpers/getOrigin' import { getValidLocale } from '@holi/web/helpers/getValidLocale' import { createApolloPageProps } from '@holi/web/helpers/useApolloWebClient' import { type CustomProps, HoliPageProps, type NextJSPageProps } from '@holi/web/types' +import { coerceUUIDParams } from '@holi/web/helpers/coerceUUIDParams' interface SkippableQueryOptions extends QueryOptions { skip?: boolean @@ -46,6 +47,18 @@ export const createServerSideProps = async ( return minimalProps } + if (customProps?.uuidParams) { + const redirectUrl = coerceUUIDParams(customProps?.uuidParams, req.url) + if (redirectUrl) { + return { + redirect: { + destination: redirectUrl, + permanent: true, + }, + } + } + } + const client = initializeWebApolloClient({ headers: req.headers as Record<string, string>, locale }) // Pre-fetch authenticated user if logged in diff --git a/apps/web/types.ts b/apps/web/types.ts index 0e74c373c174f03f0942d760e156f6ec9df138e4..ca368c82ee8132ef814f7e45bbe56bc7edd51507 100644 --- a/apps/web/types.ts +++ b/apps/web/types.ts @@ -6,11 +6,11 @@ import type { SeoUrls } from '@holi/core/components/HoliHead/types' const APOLLO_STATE_PROP_NAME = '__APOLLO_STATE__' const POSTHOG_BOOTSTRAP_PROP_NAME = '__POSTHOG_BOOTSTRAP__' const USER_PROP_NAME = '__USER__' - export interface CustomProps { seoTitle?: string urls?: SeoUrls blockIndexing?: boolean + uuidParams?: (string | string[] | undefined)[] } export interface UserPageProps {