From ae124a6ef0700326ff9eb09341121eebcf8579e6 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Wed, 23 Aug 2023 10:35:23 +0200 Subject: [PATCH 01/19] Use Next.JS Site-Preview mode --- .env | 2 +- demo/api/schema.gql | 7 ++ demo/api/src/app.module.ts | 2 +- demo/api/src/auth/auth.module.ts | 57 +++++----- demo/api/src/config/config.ts | 1 + demo/api/src/config/environment-variables.ts | 4 + demo/site/src/header/PageLink.tsx | 3 +- demo/site/src/pages/[[...path]].tsx | 103 ++++++------------ demo/site/src/pages/_app.tsx | 5 +- demo/site/src/pages/api/preview.ts | 35 ++++++ demo/site/src/pages/preview/[[...path]].tsx | 22 ---- demo/site/src/util/createGraphQLClient.ts | 8 +- .../src/preview/site/SitePreview.tsx | 56 +++++----- .../src/preview/site/buildPreviewUrl.spec.ts | 22 ---- .../src/preview/site/buildPreviewUrl.ts | 7 -- packages/api/cms-api/schema.gql | 7 ++ .../src/auth/resolver/auth.resolver.ts | 43 +++++++- packages/eslint-config/nextjs.js | 5 - packages/site/cms-site/package.json | 2 +- packages/site/cms-site/src/index.ts | 3 - packages/site/cms-site/src/link/Link.tsx | 6 +- .../src/preview/BlockPreviewProvider.tsx | 2 - .../cms-site/src/preview/PreviewContext.tsx | 7 -- .../site/cms-site/src/preview/utils.spec.ts | 53 --------- packages/site/cms-site/src/preview/utils.ts | 71 ------------ .../cms-site/src/router/useRouter.test.tsx | 37 ------- .../site/cms-site/src/router/useRouter.tsx | 18 --- .../src/sitePreview/SitePreviewPage.tsx | 14 --- .../src/sitePreview/SitePreviewProvider.tsx | 35 +----- 29 files changed, 210 insertions(+), 427 deletions(-) create mode 100644 demo/site/src/pages/api/preview.ts delete mode 100644 demo/site/src/pages/preview/[[...path]].tsx delete mode 100644 packages/admin/cms-admin/src/preview/site/buildPreviewUrl.spec.ts delete mode 100644 packages/admin/cms-admin/src/preview/site/buildPreviewUrl.ts delete mode 100644 packages/site/cms-site/src/preview/utils.spec.ts delete mode 100644 packages/site/cms-site/src/preview/utils.ts delete mode 100644 packages/site/cms-site/src/router/useRouter.test.tsx delete mode 100644 packages/site/cms-site/src/router/useRouter.tsx delete mode 100644 packages/site/cms-site/src/sitePreview/SitePreviewPage.tsx diff --git a/.env b/.env index 87c2bf6309..b51564cf09 100644 --- a/.env +++ b/.env @@ -42,6 +42,7 @@ API_PORT=4000 API_URL=http://localhost:$API_PORT API_URL_INTERNAL=http://localhost:$API_PORT CORS_ALLOWED_ORIGINS="^http:\/\/localhost:\d+,^http://192.168.\d+.\d+:80[0-9]{2}" +HMAC_SECRET=ACeLwPZhA3M9iyLAE946GXFydRz8YagKLH8c6sdwaJbe7WHQnmgEouebJZR4RxJJ # blob storage BLOB_STORAGE_DRIVER="file" @@ -64,7 +65,6 @@ SITE_PORT=3000 SITE_URL=http://localhost:$SITE_PORT SITE_PRELOGIN_ENABLED=false SITE_PRELOGIN_PASSWORD=password -PREVIEW_URL=$SITE_URL/preview # no gtm in dev mode NEXT_PUBLIC_GTM_ID= NEXT_PUBLIC_SITE_DOMAIN=main diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 5f46b83ac6..679b754317 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -2,6 +2,11 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ +type Hmac { + timestamp: Float! + hash: String! +} + type Dependency { rootId: String! rootGraphqlObjectType: String! @@ -592,6 +597,8 @@ type Query { userPermissionsAvailablePermissions: [String!]! userPermissionsContentScopes(userId: String!, skipManual: Boolean): [JSONObject!]! userPermissionsAvailableContentScopes: [JSONObject!]! + hmacCreate: Hmac! + hmacValidate(timestamp: Float!, hash: String!): Boolean! buildTemplates: [BuildTemplate!]! builds(limit: Float): [Build!]! autoBuildStatus: AutoBuildStatus! diff --git a/demo/api/src/app.module.ts b/demo/api/src/app.module.ts index e1c9d747f1..82ea911150 100644 --- a/demo/api/src/app.module.ts +++ b/demo/api/src/app.module.ts @@ -76,7 +76,7 @@ export class AppModule { }), inject: [BLOCKS_MODULE_TRANSFORMER_DEPENDENCIES], }), - AuthModule, + AuthModule.forRoot(config), ContentScopeModule.forRoot({ canAccessScope(requestScope: ContentScope, user: CurrentUserInterface) { if (!user.domains) return true; //all domains diff --git a/demo/api/src/auth/auth.module.ts b/demo/api/src/auth/auth.module.ts index ed849aead8..13de07c9c3 100644 --- a/demo/api/src/auth/auth.module.ts +++ b/demo/api/src/auth/auth.module.ts @@ -1,31 +1,38 @@ import { createAuthResolver, createCometAuthGuard, createStaticAuthedUserStrategy } from "@comet/cms-api"; -import { Module } from "@nestjs/common"; +import { DynamicModule, Module } from "@nestjs/common"; import { APP_GUARD } from "@nestjs/core"; +import { Config } from "@src/config/config"; import { CurrentUser } from "./current-user"; import { UserService } from "./user.service"; -@Module({ - providers: [ - createStaticAuthedUserStrategy({ - staticAuthedUser: { - id: "1", - name: "Test Admin", - email: "demo@comet-dxp.com", - language: "en", - role: "admin", - domains: ["main", "secondary"], - }, - }), - createAuthResolver({ - currentUser: CurrentUser, - }), - { - provide: APP_GUARD, - useClass: createCometAuthGuard(["static-authed-user"]), - }, - UserService, - ], - exports: [UserService], -}) -export class AuthModule {} +@Module({}) +export class AuthModule { + static forRoot(config: Config): DynamicModule { + return { + module: AuthModule, + providers: [ + createStaticAuthedUserStrategy({ + staticAuthedUser: { + id: "1", + name: "Test Admin", + email: "demo@comet-dxp.com", + language: "en", + role: "admin", + domains: ["main", "secondary"], + }, + }), + createAuthResolver({ + currentUser: CurrentUser, + hmacSecret: config.hmacSecret, + }), + { + provide: APP_GUARD, + useClass: createCometAuthGuard(["static-authed-user"]), + }, + UserService, + ], + exports: [UserService], + }; + } +} diff --git a/demo/api/src/config/config.ts b/demo/api/src/config/config.ts index ffa979bcce..2f859b6f84 100644 --- a/demo/api/src/config/config.ts +++ b/demo/api/src/config/config.ts @@ -18,6 +18,7 @@ export function createConfig(processEnv: NodeJS.ProcessEnv) { apiUrl: envVars.API_URL, apiPort: envVars.API_PORT, corsAllowedOrigins: envVars.CORS_ALLOWED_ORIGINS.split(","), + hmacSecret: envVars.HMAC_SECRET, imgproxy: { ...cometConfig.imgproxy, salt: envVars.IMGPROXY_SALT, diff --git a/demo/api/src/config/environment-variables.ts b/demo/api/src/config/environment-variables.ts index bb2f51399e..4fa2edd49e 100644 --- a/demo/api/src/config/environment-variables.ts +++ b/demo/api/src/config/environment-variables.ts @@ -51,6 +51,10 @@ export class EnvironmentVariables { @IsInt() IMGPROXY_QUALITY = 80; + @IsString() + @MinLength(16) + HMAC_SECRET: string; + @IsString() @MinLength(16) DAM_SECRET: string; diff --git a/demo/site/src/header/PageLink.tsx b/demo/site/src/header/PageLink.tsx index 1a9017e082..1efb3cb2b2 100644 --- a/demo/site/src/header/PageLink.tsx +++ b/demo/site/src/header/PageLink.tsx @@ -1,8 +1,9 @@ -import { Link, useRouter } from "@comet/cms-site"; +import { Link } from "@comet/cms-site"; import { LinkBlock } from "@src/blocks/LinkBlock"; import { GQLPredefinedPage } from "@src/graphql.generated"; import { predefinedPagePaths } from "@src/predefinedPages/predefinedPagePaths"; import { gql } from "graphql-request"; +import { useRouter } from "next/router"; import * as React from "react"; import { GQLPageLinkFragment } from "./PageLink.generated"; diff --git a/demo/site/src/pages/[[...path]].tsx b/demo/site/src/pages/[[...path]].tsx index c6198b271c..50a920eae9 100644 --- a/demo/site/src/pages/[[...path]].tsx +++ b/demo/site/src/pages/[[...path]].tsx @@ -4,18 +4,12 @@ import NotFound404 from "@src/pages/404"; import PageTypePage, { pageQuery as PageTypePageQuery } from "@src/pageTypes/Page"; import createGraphQLClient from "@src/util/createGraphQLClient"; import { gql } from "graphql-request"; -import { - GetServerSidePropsContext, - GetServerSidePropsResult, - GetStaticPaths, - GetStaticProps, - GetStaticPropsContext, - GetStaticPropsResult, - InferGetStaticPropsType, -} from "next"; +import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next"; +import { ParsedUrlQuery } from "querystring"; import * as React from "react"; import { GQLPagesQuery, GQLPagesQueryVariables, GQLPageTypeQuery, GQLPageTypeQueryVariables } from "./[[...path]].generated"; +import { PreviewData } from "./api/preview"; interface PageProps { documentType: string; @@ -34,6 +28,7 @@ export default function Page(props: InferGetStaticPropsType; } @@ -53,67 +48,41 @@ const pageTypes = { }, }; -export const getStaticProps: GetStaticProps = async (context) => { - const getUniversalProps = createGetUniversalProps(); - return getUniversalProps(context); -}; - -interface CreateGetUniversalPropsOptions { - includeInvisiblePages?: boolean; - includeInvisibleBlocks?: boolean; - previewDamUrls?: boolean; -} - -// a function to create a universal function which can be used as getStaticProps or getServerSideProps (preview) -export function createGetUniversalProps({ - includeInvisiblePages = false, - includeInvisibleBlocks = false, - previewDamUrls = false, -}: CreateGetUniversalPropsOptions = {}) { - return async function getUniversalProps({ - params, - locale = defaultLanguage, - }: Context): Promise< - Context extends GetStaticPropsContext ? GetStaticPropsResult : GetServerSidePropsResult - > { - const path = params?.path ?? ""; - const contentScope = { domain, language: locale }; - - //fetch pageType - const data = await createGraphQLClient({ includeInvisiblePages, includeInvisibleBlocks, previewDamUrls }).request< - GQLPageTypeQuery, - GQLPageTypeQueryVariables - >(pageTypeQuery, { - path: `/${Array.isArray(path) ? path.join("/") : path}`, - contentScope, - }); - if (!data.pageTreeNodeByPath?.documentType) { - // eslint-disable-next-line no-console - console.log("got no data from api", data, path); - return { notFound: true }; - } - const pageId = data.pageTreeNodeByPath.id; +export const getStaticProps: GetStaticProps = async ({ + params, + previewData, + locale = defaultLanguage, +}) => { + const path = params?.path ?? ""; + const contentScope = { domain, language: locale }; + //fetch pageType + const data = await createGraphQLClient(previewData).request(pageTypeQuery, { + path: `/${Array.isArray(path) ? path.join("/") : path}`, + contentScope, + }); + if (!data.pageTreeNodeByPath?.documentType) { + // eslint-disable-next-line no-console + console.log("got no data from api", data, path); + return { notFound: true }; + } + const pageId = data.pageTreeNodeByPath.id; - //pageType dependent query - const { query: queryForPageType } = pageTypes[data.pageTreeNodeByPath.documentType]; - const pageTypeData = await createGraphQLClient({ includeInvisiblePages, includeInvisibleBlocks, previewDamUrls }).request( - queryForPageType, - { - pageId, - domain: contentScope.domain, - language: contentScope.language, - }, - ); + //pageType dependent query + const { query: queryForPageType } = pageTypes[data.pageTreeNodeByPath.documentType]; + const pageTypeData = await createGraphQLClient(previewData).request(queryForPageType, { + pageId, + domain: contentScope.domain, + language: contentScope.language, + }); - return { - props: { - ...pageTypeData, - documentType: data.pageTreeNodeByPath.documentType, - id: pageId, - }, - }; + return { + props: { + ...pageTypeData, + documentType: data.pageTreeNodeByPath.documentType, + id: pageId, + }, }; -} +}; const pagesQuery = gql` query Pages($contentScope: PageTreeNodeScopeInput!) { diff --git a/demo/site/src/pages/_app.tsx b/demo/site/src/pages/_app.tsx index e919bf2b5c..3eb9208691 100644 --- a/demo/site/src/pages/_app.tsx +++ b/demo/site/src/pages/_app.tsx @@ -1,3 +1,4 @@ +import { SitePreviewProvider } from "@comet/cms-site"; import theme, { Theme } from "@src/theme"; import { AppProps, NextWebVitalsMetric } from "next/app"; import Head from "next/head"; @@ -65,7 +66,9 @@ export default function App({ Component, pageProps }: AppProps): JSX.Element { - + + + ); diff --git a/demo/site/src/pages/api/preview.ts b/demo/site/src/pages/api/preview.ts new file mode 100644 index 0000000000..d01d661c55 --- /dev/null +++ b/demo/site/src/pages/api/preview.ts @@ -0,0 +1,35 @@ +import createGraphQLClient from "@src/util/createGraphQLClient"; +import { gql } from "graphql-request"; + +import { GQLHmacValidateQuery, GQLHmacValidateQueryVariables } from "./preview.generated"; + +export default async function handler(req, res) { + const data = await createGraphQLClient().request( + gql` + query HmacValidate($timestamp: Float!, $hash: String!) { + hmacValidate(timestamp: $timestamp, hash: $hash) + } + `, + { + timestamp: parseInt(req.query.timestamp), + hash: req.query.hash, + }, + ); + if (!data.hmacValidate) { + return res.status(401).json({ message: "Validation failed" }); + } + + const previewData: PreviewData = { + includeInvisiblePages: true, + includeInvisibleBlocks: req.query.includeInvisibleBlocks === "true", + previewDamUrls: true, + }; + res.setPreviewData(previewData); + res.redirect(req.query.path ?? "/"); +} + +export interface PreviewData { + includeInvisiblePages: boolean; + includeInvisibleBlocks: boolean; + previewDamUrls: boolean; +} diff --git a/demo/site/src/pages/preview/[[...path]].tsx b/demo/site/src/pages/preview/[[...path]].tsx deleted file mode 100644 index 0333bbf2f1..0000000000 --- a/demo/site/src/pages/preview/[[...path]].tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { parsePreviewParams, SitePreviewPage } from "@comet/cms-site"; -import Page, { createGetUniversalProps, PageUniversalProps } from "@src/pages/[[...path]]"; -import { GetServerSideProps, GetServerSidePropsContext, InferGetServerSidePropsType } from "next"; -import React from "react"; - -export default function AuthenticatedPreviewPage(props: InferGetServerSidePropsType): JSX.Element { - return ( - - - - ); -} - -export const getServerSideProps: GetServerSideProps = async (context: GetServerSidePropsContext) => { - const { includeInvisibleBlocks } = parsePreviewParams(context.query); - const getUniversalProps = createGetUniversalProps({ - includeInvisiblePages: true, - includeInvisibleBlocks, - previewDamUrls: true, - }); - return getUniversalProps(context); -}; diff --git a/demo/site/src/util/createGraphQLClient.ts b/demo/site/src/util/createGraphQLClient.ts index 660d252039..d71392adf5 100644 --- a/demo/site/src/util/createGraphQLClient.ts +++ b/demo/site/src/util/createGraphQLClient.ts @@ -1,10 +1,8 @@ +import { PreviewData } from "@src/pages/api/preview"; import { GraphQLClient } from "graphql-request"; -interface GraphQLClientOptions { - includeInvisiblePages: boolean; - includeInvisibleBlocks: boolean; - previewDamUrls: boolean; -} +type GraphQLClientOptions = PreviewData; + const defaultOptions: GraphQLClientOptions = { includeInvisiblePages: false, includeInvisibleBlocks: false, diff --git a/packages/admin/cms-admin/src/preview/site/SitePreview.tsx b/packages/admin/cms-admin/src/preview/site/SitePreview.tsx index bc51219e0f..491d32dca1 100644 --- a/packages/admin/cms-admin/src/preview/site/SitePreview.tsx +++ b/packages/admin/cms-admin/src/preview/site/SitePreview.tsx @@ -1,3 +1,4 @@ +import { gql, useQuery } from "@apollo/client"; import { CometColor } from "@comet/admin-icons"; import { Public, VpnLock } from "@mui/icons-material"; import { Grid, Tooltip, Typography } from "@mui/material"; @@ -12,16 +13,12 @@ import { Device } from "../common/Device"; import { DeviceToggle } from "../common/DeviceToggle"; import { IFrameViewer } from "../common/IFrameViewer"; import { VisibilityToggle } from "../common/VisibilityToggle"; -import { buildPreviewUrl } from "./buildPreviewUrl"; import { SitePrevewIFrameLocationMessage, SitePreviewIFrameMessageType } from "./iframebridge/SitePreviewIFrameMessage"; import { useSitePreviewIFrameBridge } from "./iframebridge/useSitePreviewIFrameBridge"; import { OpenLinkDialog } from "./OpenLinkDialog"; +import { GQLHmacCreateQuery } from "./SitePreview.generated"; import { ActionsContainer, LogoWrapper, Root, SiteInformation, SiteLink, SiteLinkWrapper } from "./SitePreview.sc"; -interface SitePreviewParams { - includeInvisibleBlocks: boolean; -} - //TODO v4 remove RouteComponentProps interface Props extends RouteComponentProps { resolvePath?: (path: string, scope: ContentScopeInterface) => string; @@ -59,21 +56,11 @@ function SitePreview({ resolvePath, logo = const [showOnlyVisible, setShowOnlyVisible] = useSearchState("showOnlyVisible", (v) => !v || v === "true"); const [linkToOpen, setLinkToOpen] = React.useState(undefined); - const sitePreviewParams: SitePreviewParams = { includeInvisibleBlocks: !showOnlyVisible }; - const formattedSitePreviewParams = JSON.stringify(sitePreviewParams); const { scope } = useContentScope(); const siteConfig = useSiteConfig({ scope }); - const [initialPageUrl, setInitialPageUrl] = React.useState(buildPreviewUrl(siteConfig.previewUrl, previewPath, formattedSitePreviewParams)); - - // update the initialPreviewUrl when previewParams changes - // the iframe is then force-rerendered with the new previewUrl - React.useEffect(() => { - // react-hooks/exhaustive-deps is disabled because the src-prop of iframe is uncontrolled - // the src-value is just the default value, the iframe keeps its own src-state (by clicking links inside the iframe) - setInitialPageUrl(buildPreviewUrl(siteConfig.previewUrl, previewPath, formattedSitePreviewParams)); - }, [formattedSitePreviewParams]); // eslint-disable-line react-hooks/exhaustive-deps + const [initialPath, setInitialPath] = React.useState(previewPath); // prevents the iframe from reloading on every change const intl = useIntl(); @@ -81,17 +68,11 @@ function SitePreview({ resolvePath, logo = // we sync the location back to our admin-url, so we have it and can reload the page without loosing const handlePreviewLocationChange = React.useCallback( (message: SitePrevewIFrameLocationMessage) => { - const pathPrefix = new URL(siteConfig.previewUrl).pathname; - if (message.data.pathname.search(pathPrefix) === 0) { - // this is the original-pathname of the site, we extract it and keep it in "our" url as get-param - let normalizedPathname = message.data.pathname.substr(pathPrefix.length); - if (normalizedPathname == "") normalizedPathname = "/"; - if (previewPath !== normalizedPathname) { - setPreviewPath(normalizedPathname); - } + if (previewPath !== message.data.pathname) { + setPreviewPath(message.data.pathname); } }, - [previewPath, setPreviewPath, siteConfig.previewUrl], + [previewPath, setPreviewPath], ); const handleDeviceChange = (newDevice: Device) => { @@ -101,6 +82,8 @@ function SitePreview({ resolvePath, logo = const handleShowOnlyVisibleChange = () => { const newShowOnlyVisible = !showOnlyVisible; setShowOnlyVisible(String(newShowOnlyVisible)); + setInitialPath(previewPath); + refetch(); }; const siteLink = `${siteConfig.url}${resolvePath ? resolvePath(previewPath, scope) : previewPath}`; @@ -116,6 +99,29 @@ function SitePreview({ resolvePath, logo = } }); + const { data, error, refetch } = useQuery( + gql` + query HmacCreate { + hmacCreate { + timestamp + hash + } + } + `, + { + fetchPolicy: "network-only", + }, + ); + if (error) throw new Error(error.message); + if (!data) return <>; + const params = new URLSearchParams({ + timestamp: data.hmacCreate.timestamp.toString(), + hash: data.hmacCreate.hash, + path: initialPath, + includeInvisibleBlocks: showOnlyVisible ? "false" : "true", + }); + const initialPageUrl = `${siteConfig.url}/api/preview?${params.toString()}`; + return ( diff --git a/packages/admin/cms-admin/src/preview/site/buildPreviewUrl.spec.ts b/packages/admin/cms-admin/src/preview/site/buildPreviewUrl.spec.ts deleted file mode 100644 index 934273ee9e..0000000000 --- a/packages/admin/cms-admin/src/preview/site/buildPreviewUrl.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { buildPreviewUrl } from "./buildPreviewUrl"; - -describe("buildPreviewUrl", () => { - const previewUrl = "https://admin.com/preview/"; - const formattedSiteState = JSON.stringify({ includeInvisibleBlocks: true }); - - it("Should build preview url", () => { - const previewPath = "path=main"; - - const result = buildPreviewUrl(previewUrl, previewPath, formattedSiteState); - - expect(result).toBe(`${previewUrl}${previewPath}?__preview=%7B%22includeInvisibleBlocks%22%3Atrue%7D`); - }); - - it("Should build preview url with query params", () => { - const previewPath = "path=main?query=foo"; - - const result = buildPreviewUrl(previewUrl, previewPath, formattedSiteState); - - expect(result).toBe(`${previewUrl}${previewPath}&__preview=%7B%22includeInvisibleBlocks%22%3Atrue%7D`); - }); -}); diff --git a/packages/admin/cms-admin/src/preview/site/buildPreviewUrl.ts b/packages/admin/cms-admin/src/preview/site/buildPreviewUrl.ts deleted file mode 100644 index 15800ecd3f..0000000000 --- a/packages/admin/cms-admin/src/preview/site/buildPreviewUrl.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function buildPreviewUrl(previewUrl: string, previewPath: string, formattedSiteState: string) { - const url = new URL(`${previewUrl}${previewPath}`); - - url.searchParams.append("__preview", `${formattedSiteState}`); - - return url.toString(); -} diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql index 4fd8eb59ba..49a5c9070a 100644 --- a/packages/api/cms-api/schema.gql +++ b/packages/api/cms-api/schema.gql @@ -13,6 +13,11 @@ type PaginatedDependencies { totalCount: Int! } +type Hmac { + timestamp: Float! + hash: String! +} + type ImageCropArea { focalPoint: FocalPoint! width: Float @@ -345,6 +350,8 @@ type Query { userPermissionsAvailablePermissions: [String!]! userPermissionsContentScopes(userId: String!, skipManual: Boolean): [JSONObject!]! userPermissionsAvailableContentScopes: [JSONObject!]! + hmacCreate: Hmac! + hmacValidate(timestamp: Float!, hash: String!): Boolean! } input RedirectScopeInput { diff --git a/packages/api/cms-api/src/auth/resolver/auth.resolver.ts b/packages/api/cms-api/src/auth/resolver/auth.resolver.ts index ad00762ffa..45084b16f7 100644 --- a/packages/api/cms-api/src/auth/resolver/auth.resolver.ts +++ b/packages/api/cms-api/src/auth/resolver/auth.resolver.ts @@ -1,5 +1,7 @@ import { Type } from "@nestjs/common"; -import { Context, Mutation, Query, Resolver } from "@nestjs/graphql"; +import { Args, ArgsType, Context, Field, Mutation, ObjectType, Query, Resolver } from "@nestjs/graphql"; +import { IsNumber, IsString } from "class-validator"; +import crypto from "crypto"; import { IncomingMessage } from "http"; import { SkipBuild } from "../../builds/skip-build.decorator"; @@ -10,6 +12,19 @@ interface AuthResolverConfig { currentUser: Type; endSessionEndpoint?: string; postLogoutRedirectUri?: string; + hmacSecret: string; +} + +@ObjectType() +@ArgsType() +class Hmac { + @Field(() => Number) + @IsNumber() + timestamp: number; + + @Field(() => String) + @IsString() + hash: string; } export function createAuthResolver(config: AuthResolverConfig): Type { @@ -35,6 +50,32 @@ export function createAuthResolver(config: AuthResolverConfig): Type { } return signOutUrl; } + + @Query(() => Hmac) + hmacCreate(): Hmac { + const timestamp = Math.floor(Date.now() / 1000); + return { + timestamp, + hash: this.createHash(timestamp), + }; + } + + @Query(() => Boolean) + hmacValidate(@Args() args: Hmac): boolean { + // Timestamp must be within the last 5 minutes + if (args.timestamp < Math.floor(Date.now() / 1000) - 60 * 5) { + return false; + } + return this.createHash(args.timestamp) === args.hash; + } + + private createHash(timestamp: number): string { + if (!timestamp) throw new Error("Timestamp is required"); + return crypto + .createHmac("sha256", config.hmacSecret) + .update(timestamp + config.hmacSecret) + .digest("hex"); + } } return AuthResolver; } diff --git a/packages/eslint-config/nextjs.js b/packages/eslint-config/nextjs.js index a0c3d0ca86..34fcf9868c 100644 --- a/packages/eslint-config/nextjs.js +++ b/packages/eslint-config/nextjs.js @@ -15,11 +15,6 @@ module.exports = { importNames: ["default"], message: "Please use Link from @comet/cms-site instead", }, - { - name: "next/router", - importNames: ["useRouter"], - message: "Please use useRouter from @comet/cms-site instead", - }, { name: "next/image", importNames: ["default"], diff --git a/packages/site/cms-site/package.json b/packages/site/cms-site/package.json index e52ed71463..c5739b40fa 100644 --- a/packages/site/cms-site/package.json +++ b/packages/site/cms-site/package.json @@ -21,7 +21,7 @@ "lint": "$npm_execpath generate-block-types && run-p lint:eslint lint:tsc", "lint:eslint": "eslint --max-warnings 0 --ext .ts,.tsx,.js,.jsx,.json,.md src/ package.json", "lint:tsc": "tsc --noEmit", - "test": "jest --verbose=true", + "test": "jest --verbose=true --passWithNoTests", "test:watch": "jest --watch" }, "dependencies": { diff --git a/packages/site/cms-site/src/index.ts b/packages/site/cms-site/src/index.ts index 6bcd744930..1aafeb62f3 100644 --- a/packages/site/cms-site/src/index.ts +++ b/packages/site/cms-site/src/index.ts @@ -19,10 +19,7 @@ export { Link } from "./link/Link"; export { getAuthedUser, hasAuthedUser } from "./preview/auth"; export { BlockPreviewProvider } from "./preview/BlockPreviewProvider"; export { usePreview } from "./preview/usePreview"; -export { parsePreviewParams, parsePreviewState } from "./preview/utils"; export { PreviewSkeleton } from "./previewskeleton/PreviewSkeleton"; -export { useRouter } from "./router/useRouter"; export { sendSitePreviewIFrameMessage } from "./sitePreview/iframebridge/sendSitePreviewIFrameMessage"; export { SitePreviewIFrameMessageType } from "./sitePreview/iframebridge/SitePreviewIFrameMessage"; -export { PreviewPage, SitePreviewPage } from "./sitePreview/SitePreviewPage"; export { SitePreviewProvider } from "./sitePreview/SitePreviewProvider"; diff --git a/packages/site/cms-site/src/link/Link.tsx b/packages/site/cms-site/src/link/Link.tsx index 56914f8da3..2bb3fdb12f 100644 --- a/packages/site/cms-site/src/link/Link.tsx +++ b/packages/site/cms-site/src/link/Link.tsx @@ -2,15 +2,11 @@ import NextLink, { LinkProps as NextLinkProps } from "next/link"; import * as React from "react"; -import { usePreview } from "../preview/usePreview"; - export type LinkProps = React.PropsWithChildren; export const Link = ({ children, href, ...restProps }: LinkProps): JSX.Element => { - const { pathToPreviewPath } = usePreview(); - return ( - + {children} ); diff --git a/packages/site/cms-site/src/preview/BlockPreviewProvider.tsx b/packages/site/cms-site/src/preview/BlockPreviewProvider.tsx index d374758484..c4a5809999 100644 --- a/packages/site/cms-site/src/preview/BlockPreviewProvider.tsx +++ b/packages/site/cms-site/src/preview/BlockPreviewProvider.tsx @@ -8,8 +8,6 @@ export const BlockPreviewProvider: React.FunctionComponent = ({ children }) => { value={{ previewType: "BlockPreview", showPreviewSkeletons: true, - pathToPreviewPath: () => "", - previewPathToPath: () => "", }} > {children} diff --git a/packages/site/cms-site/src/preview/PreviewContext.tsx b/packages/site/cms-site/src/preview/PreviewContext.tsx index 6dcb8b3dfe..abc93a40f3 100644 --- a/packages/site/cms-site/src/preview/PreviewContext.tsx +++ b/packages/site/cms-site/src/preview/PreviewContext.tsx @@ -6,18 +6,11 @@ export type Url = string | UrlObject; export interface PreviewContextOptions { previewType: "SitePreview" | "BlockPreview" | "NoPreview"; showPreviewSkeletons: boolean; - // internal links only - pathToPreviewPath: (originalPagePath: Url) => Url; - - // converts a previewpath to a path - previewPathToPath: (previewPath: string) => string; } export const defaultPreviewContextValue: PreviewContextOptions = { previewType: "NoPreview", showPreviewSkeletons: false, - pathToPreviewPath: (path) => path, - previewPathToPath: (previewUrl: string) => previewUrl, }; export const PreviewContext = React.createContext(defaultPreviewContextValue); diff --git a/packages/site/cms-site/src/preview/utils.spec.ts b/packages/site/cms-site/src/preview/utils.spec.ts deleted file mode 100644 index 20c7623f68..0000000000 --- a/packages/site/cms-site/src/preview/utils.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { UrlObject } from "url"; - -import { createPathToPreviewPath, defaultPreviewPath, parsePreviewParams } from "./utils"; - -describe("Preview utils", () => { - const previewParams = parsePreviewParams({ __preview: JSON.stringify({ includeInvisibleBlocks: false }) }); - const previewPath = defaultPreviewPath; - - it("Should parse preview state", () => { - const state = { includeInvisibleBlocks: true }; - const parsedPreviewState = parsePreviewParams({ __preview: JSON.stringify(state) }); - - expect(parsedPreviewState).toEqual(state); - }); - - it("Should create preview path for string path", () => { - const path = "/main"; - - const result = createPathToPreviewPath({ path, previewPath, previewParams }); - - expect(result).toEqual(`${previewPath}${path}?__preview=%7B%22includeInvisibleBlocks%22%3Afalse%7D`); - }); - - it("Should create preview path for string path with query params", () => { - const path = "/main?query=foo"; - - const result = createPathToPreviewPath({ path, previewPath, previewParams }); - - expect(result).toEqual(`${previewPath}${path}&__preview=%7B%22includeInvisibleBlocks%22%3Afalse%7D`); - }); - - it("Should create preview path for object path", () => { - const pathname = "/[[...path]]"; - const query = { - path: ["foo"], - }; - const path: UrlObject = { - pathname, - query, - }; - - const result = createPathToPreviewPath({ path, previewPath, previewParams }); - - expect(result).toEqual({ - ...path, - pathname: `${previewPath}${pathname}`, - query: { - ...query, - __preview: JSON.stringify(previewParams), - }, - }); - }); -}); diff --git a/packages/site/cms-site/src/preview/utils.ts b/packages/site/cms-site/src/preview/utils.ts deleted file mode 100644 index b6729391e5..0000000000 --- a/packages/site/cms-site/src/preview/utils.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ParsedUrlQuery } from "querystring"; - -import { Url } from "./PreviewContext"; - -export const defaultPreviewPath = "/preview"; - -interface SitePreviewParams { - includeInvisibleBlocks: boolean; -} - -const defaultParams: SitePreviewParams = { - includeInvisibleBlocks: false, -}; - -export function parsePreviewParams(query: ParsedUrlQuery): SitePreviewParams { - let previewParams: SitePreviewParams = defaultParams; - const param = query.__preview; - - if (typeof param === "string") { - try { - previewParams = JSON.parse(param); - } catch { - // Ignore invalid preview state - } - } - - return previewParams; -} - -/** - * @deprecated Use parsePreviewParams instead - */ -const parsePreviewState = parsePreviewParams; - -export { parsePreviewState }; - -export function createPathToPreviewPath({ - path, - previewPath, - previewParams, -}: { - path: Url; - previewPath: string; - previewParams: SitePreviewParams; -}): Url { - if (typeof path === "string") { - const [pathname, search] = `${previewPath}${path}`.split("?"); - const searchParams = new URLSearchParams(search); - - searchParams.append("__preview", JSON.stringify(previewParams)); - - return `${pathname}?${searchParams.toString()}`; - } else { - let query = path.query; - - if (typeof query === "string") { - query += `&__preview=${JSON.stringify(previewParams)}`; - } else if (typeof query === "object") { - query = { - ...query, - __preview: JSON.stringify(previewParams), - }; - } - - return { - ...path, - pathname: `${previewPath}${path.pathname}`, - query, - }; - } -} diff --git a/packages/site/cms-site/src/router/useRouter.test.tsx b/packages/site/cms-site/src/router/useRouter.test.tsx deleted file mode 100644 index a5b1cba89f..0000000000 --- a/packages/site/cms-site/src/router/useRouter.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { renderHook } from "@testing-library/react-hooks"; - -import { Url } from "../preview/PreviewContext"; -import { useRouter } from "./useRouter"; - -const push = jest.fn(); -const replace = jest.fn(); - -jest.mock("next/router", () => ({ - useRouter: () => ({ - push, - replace, - }), -})); - -jest.mock("../preview/usePreview", () => ({ - usePreview: () => ({ - previewPathToPath: jest.fn(), - pathToPreviewPath: (url: Url) => `/preview${url}`, - }), -})); - -describe("useRouter", () => { - it("push", () => { - const { result } = renderHook(() => useRouter()); - result.current.push("/test"); - - expect(push).toBeCalledWith("/preview/test", undefined, undefined); - }); - - it("replace", () => { - const { result } = renderHook(() => useRouter()); - result.current.replace("/test"); - - expect(replace).toBeCalledWith("/preview/test", undefined, undefined); - }); -}); diff --git a/packages/site/cms-site/src/router/useRouter.tsx b/packages/site/cms-site/src/router/useRouter.tsx deleted file mode 100644 index 94aff49f4c..0000000000 --- a/packages/site/cms-site/src/router/useRouter.tsx +++ /dev/null @@ -1,18 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import { NextRouter, useRouter as useNextRouter } from "next/router"; - -import { usePreview } from "../preview/usePreview"; - -export function useRouter(): NextRouter { - const { previewPathToPath, pathToPreviewPath } = usePreview(); - const router = useNextRouter(); - - return { - ...router, - pathname: previewPathToPath(router.pathname), - asPath: previewPathToPath(router.asPath), - route: previewPathToPath(router.route), - push: (url, as, options) => router.push(pathToPreviewPath(url), as, options), - replace: (url, as, options) => router.replace(pathToPreviewPath(url), as, options), - }; -} diff --git a/packages/site/cms-site/src/sitePreview/SitePreviewPage.tsx b/packages/site/cms-site/src/sitePreview/SitePreviewPage.tsx deleted file mode 100644 index f5e798d1b4..0000000000 --- a/packages/site/cms-site/src/sitePreview/SitePreviewPage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import * as React from "react"; - -import { SitePreviewProvider } from "./SitePreviewProvider"; - -export const SitePreviewPage: React.FunctionComponent = ({ children }) => { - return {children}; -}; - -/** - * @deprecated Use SitePreviewPage instead - */ -const PreviewPage = SitePreviewPage; - -export { PreviewPage }; diff --git a/packages/site/cms-site/src/sitePreview/SitePreviewProvider.tsx b/packages/site/cms-site/src/sitePreview/SitePreviewProvider.tsx index e25dd6278d..e5327a8aed 100644 --- a/packages/site/cms-site/src/sitePreview/SitePreviewProvider.tsx +++ b/packages/site/cms-site/src/sitePreview/SitePreviewProvider.tsx @@ -1,9 +1,7 @@ -// eslint-disable-next-line no-restricted-imports import { useRouter } from "next/router"; import * as React from "react"; -import { PreviewContext, Url } from "../preview/PreviewContext"; -import { createPathToPreviewPath, defaultPreviewPath, parsePreviewParams } from "../preview/utils"; +import { PreviewContext } from "../preview/PreviewContext"; import { sendSitePreviewIFrameMessage } from "./iframebridge/sendSitePreviewIFrameMessage"; import { SitePreviewIFrameLocationMessage, SitePreviewIFrameMessageType } from "./iframebridge/SitePreviewIFrameMessage"; @@ -11,14 +9,13 @@ interface Props { previewPath?: string; } -export const SitePreviewProvider: React.FunctionComponent = ({ children, previewPath = defaultPreviewPath }) => { +export const SitePreviewProvider: React.FunctionComponent = ({ children }) => { const router = useRouter(); React.useEffect(() => { function sendUpstreamMessage() { const url = new URL(router.asPath, window.location.origin); const { pathname, searchParams } = url; - searchParams.delete("__preview"); // Remove __preview query parameter -> that's frontend preview internal const message: SitePreviewIFrameLocationMessage = { cometType: SitePreviewIFrameMessageType.SitePreviewLocation, @@ -33,39 +30,11 @@ export const SitePreviewProvider: React.FunctionComponent = ({ children, }; }, [router]); - const previewParams = parsePreviewParams(router.query); - - // maps the original-path to the preview-path - const pathToPreviewPath = React.useCallback( - (path: Url) => { - return createPathToPreviewPath({ path, previewPath, previewParams }); - }, - [previewPath, previewParams], - ); - const previewPathToPath = React.useCallback( - (previewUrl: string) => { - // Parse url - const [pathname, search] = previewUrl.split("?"); - - // remove previewPath Prefix e.g. '/preview' from path - const newPathname = pathname.replace(new RegExp(`^${previewPath}`), ""); - - // remove __preview query parameter from searchParams - const newSearchParams = new URLSearchParams(search); - newSearchParams.delete("__preview"); - const newSearch = newSearchParams.toString(); - - return `${newPathname.length === 0 ? "/" : newPathname}${newSearch.length === 0 ? "" : `?${newSearch}`}`; - }, - [previewPath], - ); return ( {children} From 8d52a2320cbd7ac258e2fed079dc0d24cee716ae Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Mon, 9 Oct 2023 16:53:42 +0200 Subject: [PATCH 02/19] Rename resolvers for SitePreview --- .env | 1 - demo/api/schema.gql | 14 ++--- demo/api/src/auth/auth.module.ts | 1 - demo/api/src/config/config.ts | 1 - demo/api/src/config/environment-variables.ts | 4 -- demo/site/src/pages/api/preview.ts | 10 ++-- .../src/preview/site/SitePreview.tsx | 12 ++-- packages/api/cms-api/schema.gql | 14 ++--- .../src/auth/resolver/auth.resolver.ts | 43 +------------- .../src/page-tree/createPageTreeResolver.ts | 57 ++++++++++++++++++- 10 files changed, 82 insertions(+), 75 deletions(-) diff --git a/.env b/.env index b51564cf09..5e98ce65ce 100644 --- a/.env +++ b/.env @@ -42,7 +42,6 @@ API_PORT=4000 API_URL=http://localhost:$API_PORT API_URL_INTERNAL=http://localhost:$API_PORT CORS_ALLOWED_ORIGINS="^http:\/\/localhost:\d+,^http://192.168.\d+.\d+:80[0-9]{2}" -HMAC_SECRET=ACeLwPZhA3M9iyLAE946GXFydRz8YagKLH8c6sdwaJbe7WHQnmgEouebJZR4RxJJ # blob storage BLOB_STORAGE_DRIVER="file" diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 679b754317..56e2e9663d 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -2,11 +2,6 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ -type Hmac { - timestamp: Float! - hash: String! -} - type Dependency { rootId: String! rootGraphqlObjectType: String! @@ -494,6 +489,11 @@ type PaginatedPageTreeNodes { totalCount: Int! } +type SitePreviewHash { + timestamp: Float! + hash: String! +} + type Redirect implements DocumentInterface { id: ID! updatedAt: DateTime! @@ -597,8 +597,6 @@ type Query { userPermissionsAvailablePermissions: [String!]! userPermissionsContentScopes(userId: String!, skipManual: Boolean): [JSONObject!]! userPermissionsAvailableContentScopes: [JSONObject!]! - hmacCreate: Hmac! - hmacValidate(timestamp: Float!, hash: String!): Boolean! buildTemplates: [BuildTemplate!]! builds(limit: Float): [Build!]! autoBuildStatus: AutoBuildStatus! @@ -609,6 +607,8 @@ type Query { pageTreeNodeList(scope: PageTreeNodeScopeInput!, category: String): [PageTreeNode!]! paginatedPageTreeNodes(scope: PageTreeNodeScopeInput!, category: String, sort: [PageTreeNodeSort!], offset: Int! = 0, limit: Int! = 25): PaginatedPageTreeNodes! pageTreeNodeSlugAvailable(scope: PageTreeNodeScopeInput!, parentId: ID, slug: String!): SlugAvailability! + getSitePreviewHash: SitePreviewHash! + validateSitePreviewHash(timestamp: Float!, hash: String!): Boolean! redirects(scope: RedirectScopeInput!, query: String, type: RedirectGenerationType, active: Boolean, sortColumnName: String, sortDirection: SortDirection! = ASC): [Redirect!]! @deprecated(reason: "Use paginatedRedirects instead. Will be removed in the next version.") paginatedRedirects(scope: RedirectScopeInput!, search: String, filter: RedirectFilter, sort: [RedirectSort!], offset: Int! = 0, limit: Int! = 25): PaginatedRedirects! redirect(id: ID!): Redirect! diff --git a/demo/api/src/auth/auth.module.ts b/demo/api/src/auth/auth.module.ts index 13de07c9c3..3d671befe5 100644 --- a/demo/api/src/auth/auth.module.ts +++ b/demo/api/src/auth/auth.module.ts @@ -24,7 +24,6 @@ export class AuthModule { }), createAuthResolver({ currentUser: CurrentUser, - hmacSecret: config.hmacSecret, }), { provide: APP_GUARD, diff --git a/demo/api/src/config/config.ts b/demo/api/src/config/config.ts index 2f859b6f84..ffa979bcce 100644 --- a/demo/api/src/config/config.ts +++ b/demo/api/src/config/config.ts @@ -18,7 +18,6 @@ export function createConfig(processEnv: NodeJS.ProcessEnv) { apiUrl: envVars.API_URL, apiPort: envVars.API_PORT, corsAllowedOrigins: envVars.CORS_ALLOWED_ORIGINS.split(","), - hmacSecret: envVars.HMAC_SECRET, imgproxy: { ...cometConfig.imgproxy, salt: envVars.IMGPROXY_SALT, diff --git a/demo/api/src/config/environment-variables.ts b/demo/api/src/config/environment-variables.ts index 4fa2edd49e..bb2f51399e 100644 --- a/demo/api/src/config/environment-variables.ts +++ b/demo/api/src/config/environment-variables.ts @@ -51,10 +51,6 @@ export class EnvironmentVariables { @IsInt() IMGPROXY_QUALITY = 80; - @IsString() - @MinLength(16) - HMAC_SECRET: string; - @IsString() @MinLength(16) DAM_SECRET: string; diff --git a/demo/site/src/pages/api/preview.ts b/demo/site/src/pages/api/preview.ts index d01d661c55..534701539f 100644 --- a/demo/site/src/pages/api/preview.ts +++ b/demo/site/src/pages/api/preview.ts @@ -1,13 +1,13 @@ import createGraphQLClient from "@src/util/createGraphQLClient"; import { gql } from "graphql-request"; -import { GQLHmacValidateQuery, GQLHmacValidateQueryVariables } from "./preview.generated"; +import { GQLValidateSitePreviewHashQuery, GQLValidateSitePreviewHashQueryVariables } from "./preview.generated"; export default async function handler(req, res) { - const data = await createGraphQLClient().request( + const data = await createGraphQLClient().request( gql` - query HmacValidate($timestamp: Float!, $hash: String!) { - hmacValidate(timestamp: $timestamp, hash: $hash) + query ValidateSitePreviewHash($timestamp: Float!, $hash: String!) { + validateSitePreviewHash(timestamp: $timestamp, hash: $hash) } `, { @@ -15,7 +15,7 @@ export default async function handler(req, res) { hash: req.query.hash, }, ); - if (!data.hmacValidate) { + if (!data.validateSitePreviewHash) { return res.status(401).json({ message: "Validation failed" }); } diff --git a/packages/admin/cms-admin/src/preview/site/SitePreview.tsx b/packages/admin/cms-admin/src/preview/site/SitePreview.tsx index 491d32dca1..856105d6dc 100644 --- a/packages/admin/cms-admin/src/preview/site/SitePreview.tsx +++ b/packages/admin/cms-admin/src/preview/site/SitePreview.tsx @@ -16,7 +16,7 @@ import { VisibilityToggle } from "../common/VisibilityToggle"; import { SitePrevewIFrameLocationMessage, SitePreviewIFrameMessageType } from "./iframebridge/SitePreviewIFrameMessage"; import { useSitePreviewIFrameBridge } from "./iframebridge/useSitePreviewIFrameBridge"; import { OpenLinkDialog } from "./OpenLinkDialog"; -import { GQLHmacCreateQuery } from "./SitePreview.generated"; +import { GQLGetSitePreviewHashQuery } from "./SitePreview.generated"; import { ActionsContainer, LogoWrapper, Root, SiteInformation, SiteLink, SiteLinkWrapper } from "./SitePreview.sc"; //TODO v4 remove RouteComponentProps @@ -99,10 +99,10 @@ function SitePreview({ resolvePath, logo = } }); - const { data, error, refetch } = useQuery( + const { data, error, refetch } = useQuery( gql` - query HmacCreate { - hmacCreate { + query GetSitePreviewHash { + getSitePreviewHash { timestamp hash } @@ -115,8 +115,8 @@ function SitePreview({ resolvePath, logo = if (error) throw new Error(error.message); if (!data) return <>; const params = new URLSearchParams({ - timestamp: data.hmacCreate.timestamp.toString(), - hash: data.hmacCreate.hash, + timestamp: data.getSitePreviewHash.timestamp.toString(), + hash: data.getSitePreviewHash.hash, path: initialPath, includeInvisibleBlocks: showOnlyVisible ? "false" : "true", }); diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql index 49a5c9070a..11979a9813 100644 --- a/packages/api/cms-api/schema.gql +++ b/packages/api/cms-api/schema.gql @@ -13,11 +13,6 @@ type PaginatedDependencies { totalCount: Int! } -type Hmac { - timestamp: Float! - hash: String! -} - type ImageCropArea { focalPoint: FocalPoint! width: Float @@ -257,6 +252,11 @@ type PaginatedPageTreeNodes { totalCount: Int! } +type SitePreviewHash { + timestamp: Float! + hash: String! +} + type DamFolder { id: ID! name: String! @@ -341,6 +341,8 @@ type Query { pageTreeNodeList(scope: PageTreeNodeScopeInput!, category: String): [PageTreeNode!]! paginatedPageTreeNodes(scope: PageTreeNodeScopeInput! = {}, category: String, sort: [PageTreeNodeSort!], offset: Int! = 0, limit: Int! = 25): PaginatedPageTreeNodes! pageTreeNodeSlugAvailable(scope: PageTreeNodeScopeInput!, parentId: ID, slug: String!): SlugAvailability! + getSitePreviewHash: SitePreviewHash! + validateSitePreviewHash(timestamp: Float!, hash: String!): Boolean! cronJobs: [CronJob!]! currentUser: CurrentUser! userPermissionsUserById(id: String!): User! @@ -350,8 +352,6 @@ type Query { userPermissionsAvailablePermissions: [String!]! userPermissionsContentScopes(userId: String!, skipManual: Boolean): [JSONObject!]! userPermissionsAvailableContentScopes: [JSONObject!]! - hmacCreate: Hmac! - hmacValidate(timestamp: Float!, hash: String!): Boolean! } input RedirectScopeInput { diff --git a/packages/api/cms-api/src/auth/resolver/auth.resolver.ts b/packages/api/cms-api/src/auth/resolver/auth.resolver.ts index 45084b16f7..ad00762ffa 100644 --- a/packages/api/cms-api/src/auth/resolver/auth.resolver.ts +++ b/packages/api/cms-api/src/auth/resolver/auth.resolver.ts @@ -1,7 +1,5 @@ import { Type } from "@nestjs/common"; -import { Args, ArgsType, Context, Field, Mutation, ObjectType, Query, Resolver } from "@nestjs/graphql"; -import { IsNumber, IsString } from "class-validator"; -import crypto from "crypto"; +import { Context, Mutation, Query, Resolver } from "@nestjs/graphql"; import { IncomingMessage } from "http"; import { SkipBuild } from "../../builds/skip-build.decorator"; @@ -12,19 +10,6 @@ interface AuthResolverConfig { currentUser: Type; endSessionEndpoint?: string; postLogoutRedirectUri?: string; - hmacSecret: string; -} - -@ObjectType() -@ArgsType() -class Hmac { - @Field(() => Number) - @IsNumber() - timestamp: number; - - @Field(() => String) - @IsString() - hash: string; } export function createAuthResolver(config: AuthResolverConfig): Type { @@ -50,32 +35,6 @@ export function createAuthResolver(config: AuthResolverConfig): Type { } return signOutUrl; } - - @Query(() => Hmac) - hmacCreate(): Hmac { - const timestamp = Math.floor(Date.now() / 1000); - return { - timestamp, - hash: this.createHash(timestamp), - }; - } - - @Query(() => Boolean) - hmacValidate(@Args() args: Hmac): boolean { - // Timestamp must be within the last 5 minutes - if (args.timestamp < Math.floor(Date.now() / 1000) - 60 * 5) { - return false; - } - return this.createHash(args.timestamp) === args.hash; - } - - private createHash(timestamp: number): string { - if (!timestamp) throw new Error("Timestamp is required"); - return crypto - .createHmac("sha256", config.hmacSecret) - .update(timestamp + config.hmacSecret) - .digest("hex"); - } } return AuthResolver; } diff --git a/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts b/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts index 07bc2ca17c..a420a65f5c 100644 --- a/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts +++ b/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts @@ -1,5 +1,21 @@ import { Inject, Type } from "@nestjs/common"; -import { Args, ArgsType, createUnionType, ID, Info, Mutation, ObjectType, Parent, Query, ResolveField, Resolver, Union } from "@nestjs/graphql"; +import { + Args, + ArgsType, + createUnionType, + Field, + ID, + Info, + Mutation, + ObjectType, + Parent, + Query, + ResolveField, + Resolver, + Union, +} from "@nestjs/graphql"; +import { IsNumber, IsString } from "class-validator"; +import { createHmac, randomBytes } from "crypto"; import { GraphQLError, GraphQLResolveInfo } from "graphql"; import { SubjectEntity } from "../common/decorators/subject-entity.decorator"; @@ -36,12 +52,14 @@ export function createPageTreeResolver({ PageTreeNodeUpdateInput = DefaultPageTreeNodeUpdateInput, Documents, Scope: PassedScope, + sitePreviewSecret = randomBytes(48).toString("hex"), }: { PageTreeNode: Type; PageTreeNodeCreateInput?: Type; PageTreeNodeUpdateInput?: Type; Documents: Type[]; Scope?: Type; + sitePreviewSecret?: string; }): Type { const Scope = PassedScope || EmptyPageTreeNodeScope; @@ -51,6 +69,18 @@ export function createPageTreeResolver({ @ArgsType() class PaginatedPageTreeNodesArgs extends PaginatedPageTreeNodesArgsFactory.create({ Scope }) {} + @ObjectType() + @ArgsType() + class SitePreviewHash { + @Field(() => Number) + @IsNumber() + timestamp: number; + + @Field(() => String) + @IsString() + hash: string; + } + const hasNonEmptyScope = !!PassedScope; function nonEmptyScopeOrNothing(scope: ScopeInterface): ScopeInterface | undefined { @@ -406,6 +436,31 @@ export function createPageTreeResolver({ await this.pageTreeService.updateNodePosition(newNode.id, { pos: input.pos as number, parentId: newNode.parentId }); return this.pageTreeNode(newNode.id); // refetch new version } + + @Query(() => SitePreviewHash) + getSitePreviewHash(): SitePreviewHash { + const timestamp = Math.floor(Date.now() / 1000); + return { + timestamp, + hash: this.createHash(timestamp), + }; + } + + @Query(() => Boolean) + validateSitePreviewHash(@Args() args: SitePreviewHash): boolean { + // Timestamp must be within the last 5 minutes + if (args.timestamp < Math.floor(Date.now() / 1000) - 60 * 5) { + return false; + } + return this.createHash(args.timestamp) === args.hash; + } + + private createHash(timestamp: number): string { + if (!timestamp) throw new Error("Timestamp is required"); + return createHmac("sha256", sitePreviewSecret) + .update(timestamp + sitePreviewSecret) + .digest("hex"); + } } if (hasNonEmptyScope) { From 03332c8cd52b1d4b22b27f3e5d9d4dcd7d6049fc Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Mon, 9 Oct 2023 17:09:45 +0200 Subject: [PATCH 03/19] Use Provider only in preview-mode --- demo/site/src/pages/_app.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/demo/site/src/pages/_app.tsx b/demo/site/src/pages/_app.tsx index 3eb9208691..ac569af3a7 100644 --- a/demo/site/src/pages/_app.tsx +++ b/demo/site/src/pages/_app.tsx @@ -2,6 +2,7 @@ import { SitePreviewProvider } from "@comet/cms-site"; import theme, { Theme } from "@src/theme"; import { AppProps, NextWebVitalsMetric } from "next/app"; import Head from "next/head"; +import { useRouter } from "next/router"; import Script from "next/script"; import * as React from "react"; import { IntlProvider } from "react-intl"; @@ -39,6 +40,7 @@ export function reportWebVitals({ id, name, label, value }: NextWebVitalsMetric) } export default function App({ Component, pageProps }: AppProps): JSX.Element { + const router = useRouter(); return ( // see https://github.com/vercel/next.js/tree/master/examples/with-react-intl // for a complete strategy to couple next with react-intl @@ -66,9 +68,13 @@ export default function App({ Component, pageProps }: AppProps): JSX.Element { - + {router.isPreview ? ( + + + + ) : ( - + )} ); From a7bdfb6629045ef78a9a3076555d0f5fe728b3c3 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Mon, 9 Oct 2023 17:49:27 +0200 Subject: [PATCH 04/19] Update readme --- .../admin/cms-admin/src/preview/README.md | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/admin/cms-admin/src/preview/README.md b/packages/admin/cms-admin/src/preview/README.md index bbd38e8e71..edc3c88d86 100644 --- a/packages/admin/cms-admin/src/preview/README.md +++ b/packages/admin/cms-admin/src/preview/README.md @@ -74,7 +74,7 @@ IFrameBridgePreviewPage (src/pages/preview/admin/page.tsx) ## Site Preview -Similar to real site but live rendered (SSR) and optionally with invisible blocks shown. +Uses Next.js Preview Mode to live render pages (SSR), optionally with invisible blocks shown. ### iframe messages: admin -> site @@ -82,8 +82,8 @@ Similar to real site but live rendered (SSR) and optionally with invisible block ### URL: admin -> site - - page url (real site url prefixed with /preview) - - __preview parameter: { includeInvisibleBlocks: boolean } (JSON encoded) + - page url (via /api/ to enter Next.js Preview Mode) + - __preview parameter: { includeInvisibleBlocks: boolean } (JSON encoded, stored via Next.js setPreviewData) ### iframe messages: site -> admin @@ -96,7 +96,6 @@ Similar to real site but live rendered (SSR) and optionally with invisible block SitePreview: state from Url (get params): path, device, showOnlyVisible - has controls for managing path, device, showOnlyVisible - handles messages coming from iframe (OpenLink, SitePreviewLocation) - - appends authProvider to iframeUrl - handles incoming messages (with useSitePreviewIFrameBridge) IFrameViewer[common] (prop drilling: device) - does scale the iframe according to device (+the device around the iframe) @@ -106,16 +105,11 @@ SitePreview: state from Url (get params): path, device, showOnlyVisible ### Site: States, Contexts and Components ``` -AuthenticatedPreviewPage (src/pages/preview/[...path]].tsx) - SitePreviewPage - - checks login and registers serviceworker - SitePreviewProvider - - messages SitePreviewLocation on location change (sends message directly using sendSitePreviewIFrameMessage helper) - - creates PreviewContext containing - - previewType: "SitePreview", - - showPreviewSkeletons: false, - - pathToPreviewPath: implementation that adds baseUrl (/preview) and __preview params - - previewPathToPath: implementation that removes them - Page (src/pages/[...path]].tsx) - - ExternalLinkBlock messages OpenLink (sends message directly using sendSitePreviewIFrameMessage helper) +SitePreviewProvider + - messages SitePreviewLocation on location change (sends message directly using sendSitePreviewIFrameMessage helper) + - creates PreviewContext containing + - previewType: "SitePreview", + - showPreviewSkeletons: false, + Page (src/pages/[...path]].tsx) + - ExternalLinkBlock messages OpenLink (sends message directly using sendSitePreviewIFrameMessage helper) ``` From e182290217cef93473f84b176822fc3ec55c063f Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Mon, 9 Oct 2023 17:50:39 +0200 Subject: [PATCH 05/19] Revert AuthModule --- demo/api/src/app.module.ts | 2 +- demo/api/src/auth/auth.module.ts | 56 ++++++++++++++------------------ 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/demo/api/src/app.module.ts b/demo/api/src/app.module.ts index 82ea911150..e1c9d747f1 100644 --- a/demo/api/src/app.module.ts +++ b/demo/api/src/app.module.ts @@ -76,7 +76,7 @@ export class AppModule { }), inject: [BLOCKS_MODULE_TRANSFORMER_DEPENDENCIES], }), - AuthModule.forRoot(config), + AuthModule, ContentScopeModule.forRoot({ canAccessScope(requestScope: ContentScope, user: CurrentUserInterface) { if (!user.domains) return true; //all domains diff --git a/demo/api/src/auth/auth.module.ts b/demo/api/src/auth/auth.module.ts index 3d671befe5..ed849aead8 100644 --- a/demo/api/src/auth/auth.module.ts +++ b/demo/api/src/auth/auth.module.ts @@ -1,37 +1,31 @@ import { createAuthResolver, createCometAuthGuard, createStaticAuthedUserStrategy } from "@comet/cms-api"; -import { DynamicModule, Module } from "@nestjs/common"; +import { Module } from "@nestjs/common"; import { APP_GUARD } from "@nestjs/core"; -import { Config } from "@src/config/config"; import { CurrentUser } from "./current-user"; import { UserService } from "./user.service"; -@Module({}) -export class AuthModule { - static forRoot(config: Config): DynamicModule { - return { - module: AuthModule, - providers: [ - createStaticAuthedUserStrategy({ - staticAuthedUser: { - id: "1", - name: "Test Admin", - email: "demo@comet-dxp.com", - language: "en", - role: "admin", - domains: ["main", "secondary"], - }, - }), - createAuthResolver({ - currentUser: CurrentUser, - }), - { - provide: APP_GUARD, - useClass: createCometAuthGuard(["static-authed-user"]), - }, - UserService, - ], - exports: [UserService], - }; - } -} +@Module({ + providers: [ + createStaticAuthedUserStrategy({ + staticAuthedUser: { + id: "1", + name: "Test Admin", + email: "demo@comet-dxp.com", + language: "en", + role: "admin", + domains: ["main", "secondary"], + }, + }), + createAuthResolver({ + currentUser: CurrentUser, + }), + { + provide: APP_GUARD, + useClass: createCometAuthGuard(["static-authed-user"]), + }, + UserService, + ], + exports: [UserService], +}) +export class AuthModule {} From 3a56f113c8087ad4f51591ce332ba444bbfc4438 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Mon, 9 Oct 2023 20:31:53 +0200 Subject: [PATCH 06/19] Add changeset --- .changeset/yellow-seahorses-lick.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/yellow-seahorses-lick.md diff --git a/.changeset/yellow-seahorses-lick.md b/.changeset/yellow-seahorses-lick.md new file mode 100644 index 0000000000..56dca98c5c --- /dev/null +++ b/.changeset/yellow-seahorses-lick.md @@ -0,0 +1,18 @@ +--- +"@comet/cms-admin": major +"@comet/cms-api": major +"@comet/eslint-config": minor +"@comet/cms-site": minor +--- + +Migrate Site-Preview to Next.js Preview Mode + +Requires following changes to site: + +- Import useRouter from next/router (not exported from @comet/cms-site anymore) +- Remove Preview Pages (Pages under preview/ subdirectory which call createGetUniversalProps with preview parameters) +- Remove createGetUniversalProps + - Just implement getStaticProps/getServerSideProps (Preview Mode will SSR automatically) + - Get previewData from context and use it to configure the GraphQL-Client +- Add SitePreviewProvider to App when Preview Mode is active +- Add /api/preview From 8ff0c414af0ddd61bb8704de53ccbc918867eb41 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Tue, 10 Oct 2023 10:11:28 +0200 Subject: [PATCH 07/19] Remove not needed type --- demo/site/src/pages/[[...path]].tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/demo/site/src/pages/[[...path]].tsx b/demo/site/src/pages/[[...path]].tsx index 50a920eae9..923228b974 100644 --- a/demo/site/src/pages/[[...path]].tsx +++ b/demo/site/src/pages/[[...path]].tsx @@ -11,11 +11,10 @@ import * as React from "react"; import { GQLPagesQuery, GQLPagesQueryVariables, GQLPageTypeQuery, GQLPageTypeQueryVariables } from "./[[...path]].generated"; import { PreviewData } from "./api/preview"; -interface PageProps { +type PageProps = GQLPage & { documentType: string; id: string; -} -export type PageUniversalProps = PageProps & GQLPage; +}; export default function Page(props: InferGetStaticPropsType): JSX.Element { if (!pageTypes[props.documentType]) { @@ -48,11 +47,7 @@ const pageTypes = { }, }; -export const getStaticProps: GetStaticProps = async ({ - params, - previewData, - locale = defaultLanguage, -}) => { +export const getStaticProps: GetStaticProps = async ({ params, previewData, locale = defaultLanguage }) => { const path = params?.path ?? ""; const contentScope = { domain, language: locale }; //fetch pageType From 96164840a3e42d39b11ae943e16cad12729e6278 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Tue, 10 Oct 2023 10:11:49 +0200 Subject: [PATCH 08/19] Update readme for preview --- packages/admin/cms-admin/src/preview/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/admin/cms-admin/src/preview/README.md b/packages/admin/cms-admin/src/preview/README.md index edc3c88d86..855f3bf3e2 100644 --- a/packages/admin/cms-admin/src/preview/README.md +++ b/packages/admin/cms-admin/src/preview/README.md @@ -82,8 +82,11 @@ Uses Next.js Preview Mode to live render pages (SSR), optionally with invisible ### URL: admin -> site - - page url (via /api/ to enter Next.js Preview Mode) - - __preview parameter: { includeInvisibleBlocks: boolean } (JSON encoded, stored via Next.js setPreviewData) +Admin opens I-Frame with {previewSiteUrl}/api/preview to enter Next.js Preview Mode and passes the following parameters: + +- path: which pathname to be shown +- includeInvisibleBlocks +- timestamp & hash: is validated to activate Preview Mode - ### iframe messages: site -> admin From e72de1d96df05a5183317fd0f24e758c88ba11e3 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Tue, 10 Oct 2023 10:25:56 +0200 Subject: [PATCH 09/19] Remove cms-site/Link as now next-link can be used --- .changeset/yellow-seahorses-lick.md | 1 + demo/site/src/components/Breadcrumbs.tsx | 2 +- demo/site/src/header/PageLink.tsx | 2 +- demo/site/src/news/blocks/NewsLinkBlock.tsx | 3 ++- packages/eslint-config/nextjs.js | 5 ----- .../site/cms-site/src/blocks/InternalLinkBlock.tsx | 2 +- packages/site/cms-site/src/index.ts | 1 - packages/site/cms-site/src/link/Link.tsx | 13 ------------- 8 files changed, 6 insertions(+), 23 deletions(-) delete mode 100644 packages/site/cms-site/src/link/Link.tsx diff --git a/.changeset/yellow-seahorses-lick.md b/.changeset/yellow-seahorses-lick.md index 56dca98c5c..9b59c17ef1 100644 --- a/.changeset/yellow-seahorses-lick.md +++ b/.changeset/yellow-seahorses-lick.md @@ -10,6 +10,7 @@ Migrate Site-Preview to Next.js Preview Mode Requires following changes to site: - Import useRouter from next/router (not exported from @comet/cms-site anymore) +- Import Link from next/link (there is no export from @comet/cms-site anymore) - Remove Preview Pages (Pages under preview/ subdirectory which call createGetUniversalProps with preview parameters) - Remove createGetUniversalProps - Just implement getStaticProps/getServerSideProps (Preview Mode will SSR automatically) diff --git a/demo/site/src/components/Breadcrumbs.tsx b/demo/site/src/components/Breadcrumbs.tsx index aa42698415..22d67a7039 100644 --- a/demo/site/src/components/Breadcrumbs.tsx +++ b/demo/site/src/components/Breadcrumbs.tsx @@ -1,6 +1,6 @@ -import { Link } from "@comet/cms-site"; import { GridRoot } from "@src/components/common/GridRoot"; import { gql } from "graphql-request"; +import Link from "next/link"; import * as React from "react"; import { GQLBreadcrumbsFragment } from "./Breadcrumbs.generated"; diff --git a/demo/site/src/header/PageLink.tsx b/demo/site/src/header/PageLink.tsx index 1efb3cb2b2..c9ce0f7f10 100644 --- a/demo/site/src/header/PageLink.tsx +++ b/demo/site/src/header/PageLink.tsx @@ -1,8 +1,8 @@ -import { Link } from "@comet/cms-site"; import { LinkBlock } from "@src/blocks/LinkBlock"; import { GQLPredefinedPage } from "@src/graphql.generated"; import { predefinedPagePaths } from "@src/predefinedPages/predefinedPagePaths"; import { gql } from "graphql-request"; +import Link from "next/link"; import { useRouter } from "next/router"; import * as React from "react"; diff --git a/demo/site/src/news/blocks/NewsLinkBlock.tsx b/demo/site/src/news/blocks/NewsLinkBlock.tsx index 89354ba035..476dc8ee26 100644 --- a/demo/site/src/news/blocks/NewsLinkBlock.tsx +++ b/demo/site/src/news/blocks/NewsLinkBlock.tsx @@ -1,5 +1,6 @@ -import { Link, PropsWithData } from "@comet/cms-site"; +import { PropsWithData } from "@comet/cms-site"; import { NewsLinkBlockData } from "@src/blocks.generated"; +import Link from "next/link"; import * as React from "react"; function NewsLinkBlock({ data: { id }, children }: React.PropsWithChildren>): JSX.Element | null { diff --git a/packages/eslint-config/nextjs.js b/packages/eslint-config/nextjs.js index 34fcf9868c..78cfe66fdf 100644 --- a/packages/eslint-config/nextjs.js +++ b/packages/eslint-config/nextjs.js @@ -10,11 +10,6 @@ module.exports = { "error", { paths: [ - { - name: "next/link", - importNames: ["default"], - message: "Please use Link from @comet/cms-site instead", - }, { name: "next/image", importNames: ["default"], diff --git a/packages/site/cms-site/src/blocks/InternalLinkBlock.tsx b/packages/site/cms-site/src/blocks/InternalLinkBlock.tsx index 804e3ddc0d..9b88285f47 100644 --- a/packages/site/cms-site/src/blocks/InternalLinkBlock.tsx +++ b/packages/site/cms-site/src/blocks/InternalLinkBlock.tsx @@ -1,7 +1,7 @@ +import Link from "next/Link"; import * as React from "react"; import { InternalLinkBlockData } from "../blocks.generated"; -import { Link } from "../link/Link"; import { PropsWithData } from "./PropsWithData"; interface InternalLinkBlockProps extends PropsWithData { diff --git a/packages/site/cms-site/src/index.ts b/packages/site/cms-site/src/index.ts index 1aafeb62f3..0bc4c6ab19 100644 --- a/packages/site/cms-site/src/index.ts +++ b/packages/site/cms-site/src/index.ts @@ -15,7 +15,6 @@ export { useIFrameBridge } from "./iframebridge/useIFrameBridge"; export { isWithPreviewPropsData, withPreview, WithPreviewProps } from "./iframebridge/withPreview"; export type { ImageDimensions } from "./image/Image"; export { calculateInheritAspectRatio, generateImageUrl, getMaxDimensionsFromArea, Image, parseAspectRatio } from "./image/Image"; -export { Link } from "./link/Link"; export { getAuthedUser, hasAuthedUser } from "./preview/auth"; export { BlockPreviewProvider } from "./preview/BlockPreviewProvider"; export { usePreview } from "./preview/usePreview"; diff --git a/packages/site/cms-site/src/link/Link.tsx b/packages/site/cms-site/src/link/Link.tsx deleted file mode 100644 index 2bb3fdb12f..0000000000 --- a/packages/site/cms-site/src/link/Link.tsx +++ /dev/null @@ -1,13 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import NextLink, { LinkProps as NextLinkProps } from "next/link"; -import * as React from "react"; - -export type LinkProps = React.PropsWithChildren; - -export const Link = ({ children, href, ...restProps }: LinkProps): JSX.Element => { - return ( - - {children} - - ); -}; From 73e0f385a322faf6de35baac92711019546fe9f3 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Tue, 10 Oct 2023 10:39:04 +0200 Subject: [PATCH 10/19] Use own resolver for SitePreview --- demo/api/schema.gql | 10 ++-- packages/api/cms-api/schema.gql | 7 --- .../src/page-tree/createPageTreeResolver.ts | 57 +------------------ .../cms-api/src/page-tree/page-tree.module.ts | 2 + .../src/page-tree/site-preview.resolver.ts | 45 +++++++++++++++ 5 files changed, 53 insertions(+), 68 deletions(-) create mode 100644 packages/api/cms-api/src/page-tree/site-preview.resolver.ts diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 56e2e9663d..2f9cd5dcf3 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -159,6 +159,11 @@ type PaginatedUserList { totalCount: Int! } +type SitePreviewHash { + timestamp: Float! + hash: String! +} + type Link implements DocumentInterface { id: ID! updatedAt: DateTime! @@ -489,11 +494,6 @@ type PaginatedPageTreeNodes { totalCount: Int! } -type SitePreviewHash { - timestamp: Float! - hash: String! -} - type Redirect implements DocumentInterface { id: ID! updatedAt: DateTime! diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql index 11979a9813..4fd8eb59ba 100644 --- a/packages/api/cms-api/schema.gql +++ b/packages/api/cms-api/schema.gql @@ -252,11 +252,6 @@ type PaginatedPageTreeNodes { totalCount: Int! } -type SitePreviewHash { - timestamp: Float! - hash: String! -} - type DamFolder { id: ID! name: String! @@ -341,8 +336,6 @@ type Query { pageTreeNodeList(scope: PageTreeNodeScopeInput!, category: String): [PageTreeNode!]! paginatedPageTreeNodes(scope: PageTreeNodeScopeInput! = {}, category: String, sort: [PageTreeNodeSort!], offset: Int! = 0, limit: Int! = 25): PaginatedPageTreeNodes! pageTreeNodeSlugAvailable(scope: PageTreeNodeScopeInput!, parentId: ID, slug: String!): SlugAvailability! - getSitePreviewHash: SitePreviewHash! - validateSitePreviewHash(timestamp: Float!, hash: String!): Boolean! cronJobs: [CronJob!]! currentUser: CurrentUser! userPermissionsUserById(id: String!): User! diff --git a/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts b/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts index a420a65f5c..07bc2ca17c 100644 --- a/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts +++ b/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts @@ -1,21 +1,5 @@ import { Inject, Type } from "@nestjs/common"; -import { - Args, - ArgsType, - createUnionType, - Field, - ID, - Info, - Mutation, - ObjectType, - Parent, - Query, - ResolveField, - Resolver, - Union, -} from "@nestjs/graphql"; -import { IsNumber, IsString } from "class-validator"; -import { createHmac, randomBytes } from "crypto"; +import { Args, ArgsType, createUnionType, ID, Info, Mutation, ObjectType, Parent, Query, ResolveField, Resolver, Union } from "@nestjs/graphql"; import { GraphQLError, GraphQLResolveInfo } from "graphql"; import { SubjectEntity } from "../common/decorators/subject-entity.decorator"; @@ -52,14 +36,12 @@ export function createPageTreeResolver({ PageTreeNodeUpdateInput = DefaultPageTreeNodeUpdateInput, Documents, Scope: PassedScope, - sitePreviewSecret = randomBytes(48).toString("hex"), }: { PageTreeNode: Type; PageTreeNodeCreateInput?: Type; PageTreeNodeUpdateInput?: Type; Documents: Type[]; Scope?: Type; - sitePreviewSecret?: string; }): Type { const Scope = PassedScope || EmptyPageTreeNodeScope; @@ -69,18 +51,6 @@ export function createPageTreeResolver({ @ArgsType() class PaginatedPageTreeNodesArgs extends PaginatedPageTreeNodesArgsFactory.create({ Scope }) {} - @ObjectType() - @ArgsType() - class SitePreviewHash { - @Field(() => Number) - @IsNumber() - timestamp: number; - - @Field(() => String) - @IsString() - hash: string; - } - const hasNonEmptyScope = !!PassedScope; function nonEmptyScopeOrNothing(scope: ScopeInterface): ScopeInterface | undefined { @@ -436,31 +406,6 @@ export function createPageTreeResolver({ await this.pageTreeService.updateNodePosition(newNode.id, { pos: input.pos as number, parentId: newNode.parentId }); return this.pageTreeNode(newNode.id); // refetch new version } - - @Query(() => SitePreviewHash) - getSitePreviewHash(): SitePreviewHash { - const timestamp = Math.floor(Date.now() / 1000); - return { - timestamp, - hash: this.createHash(timestamp), - }; - } - - @Query(() => Boolean) - validateSitePreviewHash(@Args() args: SitePreviewHash): boolean { - // Timestamp must be within the last 5 minutes - if (args.timestamp < Math.floor(Date.now() / 1000) - 60 * 5) { - return false; - } - return this.createHash(args.timestamp) === args.hash; - } - - private createHash(timestamp: number): string { - if (!timestamp) throw new Error("Timestamp is required"); - return createHmac("sha256", sitePreviewSecret) - .update(timestamp + sitePreviewSecret) - .digest("hex"); - } } if (hasNonEmptyScope) { diff --git a/packages/api/cms-api/src/page-tree/page-tree.module.ts b/packages/api/cms-api/src/page-tree/page-tree.module.ts index 2661dd5c64..c3d14ed210 100644 --- a/packages/api/cms-api/src/page-tree/page-tree.module.ts +++ b/packages/api/cms-api/src/page-tree/page-tree.module.ts @@ -13,6 +13,7 @@ import { PageTreeNodeBase } from "./entities/page-tree-node-base.entity"; import { defaultReservedPaths, PAGE_TREE_CONFIG, PAGE_TREE_ENTITY, PAGE_TREE_REPOSITORY } from "./page-tree.constants"; import { PageTreeService } from "./page-tree.service"; import { PageTreeReadApiService } from "./page-tree-read-api.service"; +import { SitePreviewResolver } from "./site-preview.resolver"; import type { PageTreeNodeInterface, ScopeInterface } from "./types"; import { PageExistsConstraint } from "./validators/page-exists.validator"; @@ -84,6 +85,7 @@ export class PageTreeModule { inject: [PageTreeService], }, documentSubscriber, + SitePreviewResolver, ], exports: [PageTreeService, PageTreeReadApiService, AttachedDocumentLoaderService], }; diff --git a/packages/api/cms-api/src/page-tree/site-preview.resolver.ts b/packages/api/cms-api/src/page-tree/site-preview.resolver.ts new file mode 100644 index 0000000000..092f23c102 --- /dev/null +++ b/packages/api/cms-api/src/page-tree/site-preview.resolver.ts @@ -0,0 +1,45 @@ +import { Args, ArgsType, Field, ObjectType, Query, Resolver } from "@nestjs/graphql"; +import { IsNumber, IsString } from "class-validator"; +import { createHmac, randomBytes } from "crypto"; + +const sitePreviewSecret = randomBytes(32).toString("hex"); + +@ObjectType() +@ArgsType() +class SitePreviewHash { + @Field(() => Number) + @IsNumber() + timestamp: number; + + @Field(() => String) + @IsString() + hash: string; +} + +@Resolver(() => SitePreviewHash) +export class SitePreviewResolver { + @Query(() => SitePreviewHash) + getSitePreviewHash(): SitePreviewHash { + const timestamp = Math.floor(Date.now() / 1000); + return { + timestamp, + hash: this.createHash(timestamp), + }; + } + + @Query(() => Boolean) + validateSitePreviewHash(@Args() args: SitePreviewHash): boolean { + // Timestamp must be within the last 5 minutes + if (args.timestamp < Math.floor(Date.now() / 1000) - 60 * 5) { + return false; + } + return this.createHash(args.timestamp) === args.hash; + } + + private createHash(timestamp: number): string { + if (!timestamp) throw new Error("Timestamp is required"); + return createHmac("sha256", sitePreviewSecret) + .update(timestamp + sitePreviewSecret) + .digest("hex"); + } +} From bdde3e2603225ce020f5cd45b4c67207ebcee1eb Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Tue, 10 Oct 2023 11:13:30 +0200 Subject: [PATCH 11/19] Require setting secret for SitePreview-Hash --- .changeset/yellow-seahorses-lick.md | 4 ++ demo/api/src/app.module.ts | 2 + .../cms-api/src/page-tree/page-tree.module.ts | 5 +- .../src/page-tree/site-preview.resolver.ts | 52 ++++++++++--------- 4 files changed, 36 insertions(+), 27 deletions(-) diff --git a/.changeset/yellow-seahorses-lick.md b/.changeset/yellow-seahorses-lick.md index 9b59c17ef1..b5f4715d41 100644 --- a/.changeset/yellow-seahorses-lick.md +++ b/.changeset/yellow-seahorses-lick.md @@ -17,3 +17,7 @@ Requires following changes to site: - Get previewData from context and use it to configure the GraphQL-Client - Add SitePreviewProvider to App when Preview Mode is active - Add /api/preview + +Requires following changes to api: + +- Set sitePreviewSecret in PageTreeModule-options (make sure it's the same for the across multiple api-instances) diff --git a/demo/api/src/app.module.ts b/demo/api/src/app.module.ts index e1c9d747f1..4e2e1d87df 100644 --- a/demo/api/src/app.module.ts +++ b/demo/api/src/app.module.ts @@ -28,6 +28,7 @@ import { DbModule } from "@src/db/db.module"; import { LinksModule } from "@src/links/links.module"; import { PagesModule } from "@src/pages/pages.module"; import { PredefinedPage } from "@src/predefined-page/entities/predefined-page.entity"; +import { randomBytes } from "crypto"; import { Request } from "express"; import { AuthModule } from "./auth/auth.module"; @@ -123,6 +124,7 @@ export class AppModule { Documents: [Page, Link, PredefinedPage], Scope: PageTreeNodeScope, reservedPaths: ["/events"], + sitePreviewSecret: randomBytes(32).toString("hex"), }), RedirectsModule.register({ customTargets: { news: NewsLinkBlock }, Scope: RedirectScope }), BlobStorageModule.register({ diff --git a/packages/api/cms-api/src/page-tree/page-tree.module.ts b/packages/api/cms-api/src/page-tree/page-tree.module.ts index c3d14ed210..b3d3f85401 100644 --- a/packages/api/cms-api/src/page-tree/page-tree.module.ts +++ b/packages/api/cms-api/src/page-tree/page-tree.module.ts @@ -13,7 +13,7 @@ import { PageTreeNodeBase } from "./entities/page-tree-node-base.entity"; import { defaultReservedPaths, PAGE_TREE_CONFIG, PAGE_TREE_ENTITY, PAGE_TREE_REPOSITORY } from "./page-tree.constants"; import { PageTreeService } from "./page-tree.service"; import { PageTreeReadApiService } from "./page-tree-read-api.service"; -import { SitePreviewResolver } from "./site-preview.resolver"; +import { createSitePreviewResolver } from "./site-preview.resolver"; import type { PageTreeNodeInterface, ScopeInterface } from "./types"; import { PageExistsConstraint } from "./validators/page-exists.validator"; @@ -28,6 +28,7 @@ interface PageTreeModuleOptions { Documents: Type[]; Scope?: Type; reservedPaths?: string[]; + sitePreviewSecret: string; } @Global() @@ -85,7 +86,7 @@ export class PageTreeModule { inject: [PageTreeService], }, documentSubscriber, - SitePreviewResolver, + createSitePreviewResolver({ secret: options.sitePreviewSecret }), ], exports: [PageTreeService, PageTreeReadApiService, AttachedDocumentLoaderService], }; diff --git a/packages/api/cms-api/src/page-tree/site-preview.resolver.ts b/packages/api/cms-api/src/page-tree/site-preview.resolver.ts index 092f23c102..037547234c 100644 --- a/packages/api/cms-api/src/page-tree/site-preview.resolver.ts +++ b/packages/api/cms-api/src/page-tree/site-preview.resolver.ts @@ -1,8 +1,7 @@ +import { Type } from "@nestjs/common"; import { Args, ArgsType, Field, ObjectType, Query, Resolver } from "@nestjs/graphql"; import { IsNumber, IsString } from "class-validator"; -import { createHmac, randomBytes } from "crypto"; - -const sitePreviewSecret = randomBytes(32).toString("hex"); +import { createHmac } from "crypto"; @ObjectType() @ArgsType() @@ -16,30 +15,33 @@ class SitePreviewHash { hash: string; } -@Resolver(() => SitePreviewHash) -export class SitePreviewResolver { - @Query(() => SitePreviewHash) - getSitePreviewHash(): SitePreviewHash { - const timestamp = Math.floor(Date.now() / 1000); - return { - timestamp, - hash: this.createHash(timestamp), - }; - } +export function createSitePreviewResolver({ secret }: { secret: string }): Type { + @Resolver(() => SitePreviewHash) + class SitePreviewResolver { + @Query(() => SitePreviewHash) + getSitePreviewHash(): SitePreviewHash { + const timestamp = Math.floor(Date.now() / 1000); + return { + timestamp, + hash: this.createHash(timestamp), + }; + } - @Query(() => Boolean) - validateSitePreviewHash(@Args() args: SitePreviewHash): boolean { - // Timestamp must be within the last 5 minutes - if (args.timestamp < Math.floor(Date.now() / 1000) - 60 * 5) { - return false; + @Query(() => Boolean) + validateSitePreviewHash(@Args() args: SitePreviewHash): boolean { + // Timestamp must be within the last 5 minutes + if (args.timestamp < Math.floor(Date.now() / 1000) - 60 * 5) { + return false; + } + return this.createHash(args.timestamp) === args.hash; } - return this.createHash(args.timestamp) === args.hash; - } - private createHash(timestamp: number): string { - if (!timestamp) throw new Error("Timestamp is required"); - return createHmac("sha256", sitePreviewSecret) - .update(timestamp + sitePreviewSecret) - .digest("hex"); + private createHash(timestamp: number): string { + if (!timestamp) throw new Error("Timestamp is required"); + return createHmac("sha256", secret) + .update(timestamp + secret) + .digest("hex"); + } } + return SitePreviewResolver; } From 02d61e2c503be2bcd76e88923a54fbf1325aac47 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Tue, 10 Oct 2023 11:21:06 +0200 Subject: [PATCH 12/19] Make type definition more logical --- demo/site/src/pages/api/preview.ts | 8 ++------ demo/site/src/util/createGraphQLClient.ts | 7 +++++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/demo/site/src/pages/api/preview.ts b/demo/site/src/pages/api/preview.ts index 534701539f..b0d18cf0b7 100644 --- a/demo/site/src/pages/api/preview.ts +++ b/demo/site/src/pages/api/preview.ts @@ -1,4 +1,4 @@ -import createGraphQLClient from "@src/util/createGraphQLClient"; +import createGraphQLClient, { GraphQLClientOptions } from "@src/util/createGraphQLClient"; import { gql } from "graphql-request"; import { GQLValidateSitePreviewHashQuery, GQLValidateSitePreviewHashQueryVariables } from "./preview.generated"; @@ -28,8 +28,4 @@ export default async function handler(req, res) { res.redirect(req.query.path ?? "/"); } -export interface PreviewData { - includeInvisiblePages: boolean; - includeInvisibleBlocks: boolean; - previewDamUrls: boolean; -} +export type PreviewData = GraphQLClientOptions; diff --git a/demo/site/src/util/createGraphQLClient.ts b/demo/site/src/util/createGraphQLClient.ts index d71392adf5..87ffb051f2 100644 --- a/demo/site/src/util/createGraphQLClient.ts +++ b/demo/site/src/util/createGraphQLClient.ts @@ -1,7 +1,10 @@ -import { PreviewData } from "@src/pages/api/preview"; import { GraphQLClient } from "graphql-request"; -type GraphQLClientOptions = PreviewData; +export type GraphQLClientOptions = { + includeInvisiblePages: boolean; + includeInvisibleBlocks: boolean; + previewDamUrls: boolean; +}; const defaultOptions: GraphQLClientOptions = { includeInvisiblePages: false, From b0c92c2540f69b7e23e9fbf3274d62333ab69c06 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Tue, 10 Oct 2023 13:13:58 +0200 Subject: [PATCH 13/19] Use provider instead factory --- packages/api/cms-api/generate-schema.ts | 2 + packages/api/cms-api/schema.gql | 7 +++ .../src/page-tree/page-tree.constants.ts | 2 + .../cms-api/src/page-tree/page-tree.module.ts | 16 ++++-- .../src/page-tree/site-preview.resolver.ts | 57 ++++++++++--------- 5 files changed, 54 insertions(+), 30 deletions(-) diff --git a/packages/api/cms-api/generate-schema.ts b/packages/api/cms-api/generate-schema.ts index bc171fdb58..cd4747b826 100644 --- a/packages/api/cms-api/generate-schema.ts +++ b/packages/api/cms-api/generate-schema.ts @@ -29,6 +29,7 @@ import { createFolderEntity } from "./src/dam/files/entities/folder.entity"; import { FileLicensesResolver } from "./src/dam/files/file-licenses.resolver"; import { createFilesResolver } from "./src/dam/files/files.resolver"; import { createFoldersResolver } from "./src/dam/files/folders.resolver"; +import { SitePreviewResolver } from "./src/page-tree/site-preview.resolver"; import { RedirectInputFactory } from "./src/redirects/dto/redirect-input.factory"; import { RedirectEntityFactory } from "./src/redirects/entities/redirect-entity.factory"; import { CurrentUserPermission } from "./src/user-permissions/dto/current-user"; @@ -125,6 +126,7 @@ async function generateSchema(): Promise { UserResolver, UserPermissionResolver, UserContentScopesResolver, + SitePreviewResolver, ]); await writeFile("schema.gql", printSchema(schema)); diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql index 4fd8eb59ba..f6a7bd76d0 100644 --- a/packages/api/cms-api/schema.gql +++ b/packages/api/cms-api/schema.gql @@ -155,6 +155,11 @@ type PaginatedUserList { totalCount: Int! } +type SitePreviewHash { + timestamp: Float! + hash: String! +} + type PageTreeNode { id: ID! parentId: String @@ -345,6 +350,8 @@ type Query { userPermissionsAvailablePermissions: [String!]! userPermissionsContentScopes(userId: String!, skipManual: Boolean): [JSONObject!]! userPermissionsAvailableContentScopes: [JSONObject!]! + getSitePreviewHash: SitePreviewHash! + validateSitePreviewHash(timestamp: Float!, hash: String!): Boolean! } input RedirectScopeInput { diff --git a/packages/api/cms-api/src/page-tree/page-tree.constants.ts b/packages/api/cms-api/src/page-tree/page-tree.constants.ts index 2cc32955fd..55684eeb94 100644 --- a/packages/api/cms-api/src/page-tree/page-tree.constants.ts +++ b/packages/api/cms-api/src/page-tree/page-tree.constants.ts @@ -5,3 +5,5 @@ export const PAGE_TREE_ENTITY = "PageTreeNode"; export const PAGE_TREE_CONFIG = "PageTreeConfig"; export const defaultReservedPaths = ["/admin", "/preview"]; + +export const SITE_PREVIEW_CONFIG = "SitePreviewConfig"; diff --git a/packages/api/cms-api/src/page-tree/page-tree.module.ts b/packages/api/cms-api/src/page-tree/page-tree.module.ts index b3d3f85401..ecb217a1e5 100644 --- a/packages/api/cms-api/src/page-tree/page-tree.module.ts +++ b/packages/api/cms-api/src/page-tree/page-tree.module.ts @@ -1,6 +1,6 @@ import { MikroOrmModule } from "@mikro-orm/nestjs"; import { EntityManager, EntityRepository } from "@mikro-orm/postgresql"; -import { DynamicModule, Global, Module, Type, ValueProvider } from "@nestjs/common"; +import { DynamicModule, Global, Module, Provider, Type, ValueProvider } from "@nestjs/common"; import { DependentsResolverFactory } from "../dependencies/dependents.resolver.factory"; import { DocumentInterface } from "../document/dto/document-interface"; @@ -10,10 +10,10 @@ import { DocumentSubscriberFactory } from "./document-subscriber"; import { PageTreeNodeBaseCreateInput, PageTreeNodeBaseUpdateInput } from "./dto/page-tree-node.input"; import { AttachedDocument } from "./entities/attached-document.entity"; import { PageTreeNodeBase } from "./entities/page-tree-node-base.entity"; -import { defaultReservedPaths, PAGE_TREE_CONFIG, PAGE_TREE_ENTITY, PAGE_TREE_REPOSITORY } from "./page-tree.constants"; +import { defaultReservedPaths, PAGE_TREE_CONFIG, PAGE_TREE_ENTITY, PAGE_TREE_REPOSITORY, SITE_PREVIEW_CONFIG } from "./page-tree.constants"; import { PageTreeService } from "./page-tree.service"; import { PageTreeReadApiService } from "./page-tree-read-api.service"; -import { createSitePreviewResolver } from "./site-preview.resolver"; +import { SitePreviewConfig, SitePreviewResolver } from "./site-preview.resolver"; import type { PageTreeNodeInterface, ScopeInterface } from "./types"; import { PageExistsConstraint } from "./validators/page-exists.validator"; @@ -67,6 +67,13 @@ export class PageTreeModule { const documentSubscriber = DocumentSubscriberFactory.create({ Documents }); + const sitePreviewProvider: Provider = { + provide: SITE_PREVIEW_CONFIG, + useValue: { + secret: options.sitePreviewSecret, + }, + }; + return { module: PageTreeModule, imports: [MikroOrmModule.forFeature([AttachedDocument, PageTreeNode, ...(Scope ? [Scope] : [])])], @@ -86,7 +93,8 @@ export class PageTreeModule { inject: [PageTreeService], }, documentSubscriber, - createSitePreviewResolver({ secret: options.sitePreviewSecret }), + sitePreviewProvider, + SitePreviewResolver, ], exports: [PageTreeService, PageTreeReadApiService, AttachedDocumentLoaderService], }; diff --git a/packages/api/cms-api/src/page-tree/site-preview.resolver.ts b/packages/api/cms-api/src/page-tree/site-preview.resolver.ts index 037547234c..230e28ca73 100644 --- a/packages/api/cms-api/src/page-tree/site-preview.resolver.ts +++ b/packages/api/cms-api/src/page-tree/site-preview.resolver.ts @@ -1,8 +1,10 @@ -import { Type } from "@nestjs/common"; +import { Inject } from "@nestjs/common"; import { Args, ArgsType, Field, ObjectType, Query, Resolver } from "@nestjs/graphql"; import { IsNumber, IsString } from "class-validator"; import { createHmac } from "crypto"; +import { SITE_PREVIEW_CONFIG } from "./page-tree.constants"; + @ObjectType() @ArgsType() class SitePreviewHash { @@ -15,33 +17,36 @@ class SitePreviewHash { hash: string; } -export function createSitePreviewResolver({ secret }: { secret: string }): Type { - @Resolver(() => SitePreviewHash) - class SitePreviewResolver { - @Query(() => SitePreviewHash) - getSitePreviewHash(): SitePreviewHash { - const timestamp = Math.floor(Date.now() / 1000); - return { - timestamp, - hash: this.createHash(timestamp), - }; - } +export type SitePreviewConfig = { + secret: string; +}; - @Query(() => Boolean) - validateSitePreviewHash(@Args() args: SitePreviewHash): boolean { - // Timestamp must be within the last 5 minutes - if (args.timestamp < Math.floor(Date.now() / 1000) - 60 * 5) { - return false; - } - return this.createHash(args.timestamp) === args.hash; - } +@Resolver(() => SitePreviewHash) +export class SitePreviewResolver { + constructor(@Inject(SITE_PREVIEW_CONFIG) private readonly config: SitePreviewConfig) {} - private createHash(timestamp: number): string { - if (!timestamp) throw new Error("Timestamp is required"); - return createHmac("sha256", secret) - .update(timestamp + secret) - .digest("hex"); + @Query(() => SitePreviewHash) + getSitePreviewHash(): SitePreviewHash { + const timestamp = Math.floor(Date.now() / 1000); + return { + timestamp, + hash: this.createHash(timestamp), + }; + } + + @Query(() => Boolean) + validateSitePreviewHash(@Args() args: SitePreviewHash): boolean { + // Timestamp must be within the last 5 minutes + if (args.timestamp < Math.floor(Date.now() / 1000) - 60 * 5) { + return false; } + return this.createHash(args.timestamp) === args.hash; + } + + private createHash(timestamp: number): string { + if (!timestamp) throw new Error("Timestamp is required"); + return createHmac("sha256", this.config.secret) + .update(timestamp + this.config.secret) + .digest("hex"); } - return SitePreviewResolver; } From ffc2f3e332596df026aab79913638b684101095d Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Tue, 10 Oct 2023 13:20:18 +0200 Subject: [PATCH 14/19] Use date-fns for better readability --- .../cms-api/src/page-tree/site-preview.resolver.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/api/cms-api/src/page-tree/site-preview.resolver.ts b/packages/api/cms-api/src/page-tree/site-preview.resolver.ts index 230e28ca73..87830b19d9 100644 --- a/packages/api/cms-api/src/page-tree/site-preview.resolver.ts +++ b/packages/api/cms-api/src/page-tree/site-preview.resolver.ts @@ -2,6 +2,7 @@ import { Inject } from "@nestjs/common"; import { Args, ArgsType, Field, ObjectType, Query, Resolver } from "@nestjs/graphql"; import { IsNumber, IsString } from "class-validator"; import { createHmac } from "crypto"; +import { differenceInMinutes, getTime } from "date-fns"; import { SITE_PREVIEW_CONFIG } from "./page-tree.constants"; @@ -27,22 +28,25 @@ export class SitePreviewResolver { @Query(() => SitePreviewHash) getSitePreviewHash(): SitePreviewHash { - const timestamp = Math.floor(Date.now() / 1000); + const timestamp = this.getTimestamp(); return { - timestamp, + timestamp: timestamp, hash: this.createHash(timestamp), }; } @Query(() => Boolean) validateSitePreviewHash(@Args() args: SitePreviewHash): boolean { - // Timestamp must be within the last 5 minutes - if (args.timestamp < Math.floor(Date.now() / 1000) - 60 * 5) { + if (differenceInMinutes(this.getTimestamp(), args.timestamp) > 5) { return false; } return this.createHash(args.timestamp) === args.hash; } + private getTimestamp() { + return getTime(Date.now()); + } + private createHash(timestamp: number): string { if (!timestamp) throw new Error("Timestamp is required"); return createHmac("sha256", this.config.secret) From acaabea01d5e82d7cd377e8148b3a546eb12a4ff Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Tue, 10 Oct 2023 13:39:07 +0200 Subject: [PATCH 15/19] Handle activating preview internally in SitePreviewProvider --- .changeset/yellow-seahorses-lick.md | 2 +- demo/site/src/pages/_app.tsx | 10 ++-------- packages/admin/cms-admin/src/preview/README.md | 2 +- .../cms-site/src/sitePreview/SitePreviewProvider.tsx | 11 ++++++----- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/.changeset/yellow-seahorses-lick.md b/.changeset/yellow-seahorses-lick.md index b5f4715d41..1e51b37a63 100644 --- a/.changeset/yellow-seahorses-lick.md +++ b/.changeset/yellow-seahorses-lick.md @@ -15,7 +15,7 @@ Requires following changes to site: - Remove createGetUniversalProps - Just implement getStaticProps/getServerSideProps (Preview Mode will SSR automatically) - Get previewData from context and use it to configure the GraphQL-Client -- Add SitePreviewProvider to App when Preview Mode is active +- Add SitePreviewProvider to App - Add /api/preview Requires following changes to api: diff --git a/demo/site/src/pages/_app.tsx b/demo/site/src/pages/_app.tsx index ac569af3a7..3eb9208691 100644 --- a/demo/site/src/pages/_app.tsx +++ b/demo/site/src/pages/_app.tsx @@ -2,7 +2,6 @@ import { SitePreviewProvider } from "@comet/cms-site"; import theme, { Theme } from "@src/theme"; import { AppProps, NextWebVitalsMetric } from "next/app"; import Head from "next/head"; -import { useRouter } from "next/router"; import Script from "next/script"; import * as React from "react"; import { IntlProvider } from "react-intl"; @@ -40,7 +39,6 @@ export function reportWebVitals({ id, name, label, value }: NextWebVitalsMetric) } export default function App({ Component, pageProps }: AppProps): JSX.Element { - const router = useRouter(); return ( // see https://github.com/vercel/next.js/tree/master/examples/with-react-intl // for a complete strategy to couple next with react-intl @@ -68,13 +66,9 @@ export default function App({ Component, pageProps }: AppProps): JSX.Element { - {router.isPreview ? ( - - - - ) : ( + - )} + ); diff --git a/packages/admin/cms-admin/src/preview/README.md b/packages/admin/cms-admin/src/preview/README.md index 855f3bf3e2..a6aab80192 100644 --- a/packages/admin/cms-admin/src/preview/README.md +++ b/packages/admin/cms-admin/src/preview/README.md @@ -108,7 +108,7 @@ SitePreview: state from Url (get params): path, device, showOnlyVisible ### Site: States, Contexts and Components ``` -SitePreviewProvider +SitePreviewProvider (only active in Preview Mode) - messages SitePreviewLocation on location change (sends message directly using sendSitePreviewIFrameMessage helper) - creates PreviewContext containing - previewType: "SitePreview", diff --git a/packages/site/cms-site/src/sitePreview/SitePreviewProvider.tsx b/packages/site/cms-site/src/sitePreview/SitePreviewProvider.tsx index e5327a8aed..8956d0e739 100644 --- a/packages/site/cms-site/src/sitePreview/SitePreviewProvider.tsx +++ b/packages/site/cms-site/src/sitePreview/SitePreviewProvider.tsx @@ -5,11 +5,7 @@ import { PreviewContext } from "../preview/PreviewContext"; import { sendSitePreviewIFrameMessage } from "./iframebridge/sendSitePreviewIFrameMessage"; import { SitePreviewIFrameLocationMessage, SitePreviewIFrameMessageType } from "./iframebridge/SitePreviewIFrameMessage"; -interface Props { - previewPath?: string; -} - -export const SitePreviewProvider: React.FunctionComponent = ({ children }) => { +const SitePreview: React.FunctionComponent = ({ children }) => { const router = useRouter(); React.useEffect(() => { @@ -41,3 +37,8 @@ export const SitePreviewProvider: React.FunctionComponent = ({ children } ); }; + +export const SitePreviewProvider: React.FunctionComponent = ({ children }) => { + const router = useRouter(); + return <>{router.isPreview ? {children} : children}; +}; From 00d638bc365bd25092335e3ebdd6544841907cb9 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Tue, 10 Oct 2023 16:07:08 +0200 Subject: [PATCH 16/19] Fix typo in import --- packages/site/cms-site/src/blocks/InternalLinkBlock.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/site/cms-site/src/blocks/InternalLinkBlock.tsx b/packages/site/cms-site/src/blocks/InternalLinkBlock.tsx index 9b88285f47..abee2bc39c 100644 --- a/packages/site/cms-site/src/blocks/InternalLinkBlock.tsx +++ b/packages/site/cms-site/src/blocks/InternalLinkBlock.tsx @@ -1,4 +1,4 @@ -import Link from "next/Link"; +import Link from "next/link"; import * as React from "react"; import { InternalLinkBlockData } from "../blocks.generated"; From 18d7b46b11dea4d86faef4b65f6a3b3eae561148 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Wed, 11 Oct 2023 10:22:38 +0200 Subject: [PATCH 17/19] Renew schema --- demo/api/schema.gql | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 2f9cd5dcf3..57ab3df7ab 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -127,6 +127,11 @@ type FilenameResponse { isOccupied: Boolean! } +type SitePreviewHash { + timestamp: Float! + hash: String! +} + type CurrentUserPermission { permission: String! } @@ -159,11 +164,6 @@ type PaginatedUserList { totalCount: Int! } -type SitePreviewHash { - timestamp: Float! - hash: String! -} - type Link implements DocumentInterface { id: ID! updatedAt: DateTime! From 4d950a854ce7ea06170e416bab6a930fb776b157 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Wed, 22 Nov 2023 12:05:19 +0100 Subject: [PATCH 18/19] Update changeset --- .changeset/yellow-seahorses-lick.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.changeset/yellow-seahorses-lick.md b/.changeset/yellow-seahorses-lick.md index 1e51b37a63..71ca251373 100644 --- a/.changeset/yellow-seahorses-lick.md +++ b/.changeset/yellow-seahorses-lick.md @@ -5,19 +5,19 @@ "@comet/cms-site": minor --- -Migrate Site-Preview to Next.js Preview Mode +Migrate site preview to Next.js Preview Mode Requires following changes to site: -- Import useRouter from next/router (not exported from @comet/cms-site anymore) -- Import Link from next/link (there is no export from @comet/cms-site anymore) -- Remove Preview Pages (Pages under preview/ subdirectory which call createGetUniversalProps with preview parameters) -- Remove createGetUniversalProps - - Just implement getStaticProps/getServerSideProps (Preview Mode will SSR automatically) - - Get previewData from context and use it to configure the GraphQL-Client -- Add SitePreviewProvider to App -- Add /api/preview +- Import `useRouter` from `next/router` (not exported from `@comet/cms-site` anymore) +- Import `Link` from `next/link` (not exported from `@comet/cms-site` anymore) +- Remove preview pages (pages in `src/pages/preview/` directory which call `createGetUniversalProps` with preview parameters) +- Remove `createGetUniversalProps` + - Just implement `getStaticProps`/`getServerSideProps` (Preview Mode will SSR automatically) + - Get `previewData` from `context` and use it to configure the GraphQL Client +- Add `SitePreviewProvider` to `App` (typically in `src/pages/_app.tsx`) +- Add `/api/preview` Next API route (see demo) -Requires following changes to api: +Requires following changes to API: -- Set sitePreviewSecret in PageTreeModule-options (make sure it's the same for the across multiple api-instances) +- Set `sitePreviewSecret` in `PageTreeModule`-options (make sure it's the same across multiple API-instances) From 82325bcec467fb079797df716322fc5cb71f8c2a Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Wed, 22 Nov 2023 12:09:09 +0100 Subject: [PATCH 19/19] Remove unnecessary variable --- .../cms-api/src/page-tree/page-tree.module.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/api/cms-api/src/page-tree/page-tree.module.ts b/packages/api/cms-api/src/page-tree/page-tree.module.ts index ecb217a1e5..5fdf183530 100644 --- a/packages/api/cms-api/src/page-tree/page-tree.module.ts +++ b/packages/api/cms-api/src/page-tree/page-tree.module.ts @@ -1,6 +1,6 @@ import { MikroOrmModule } from "@mikro-orm/nestjs"; import { EntityManager, EntityRepository } from "@mikro-orm/postgresql"; -import { DynamicModule, Global, Module, Provider, Type, ValueProvider } from "@nestjs/common"; +import { DynamicModule, Global, Module, Type, ValueProvider } from "@nestjs/common"; import { DependentsResolverFactory } from "../dependencies/dependents.resolver.factory"; import { DocumentInterface } from "../document/dto/document-interface"; @@ -13,7 +13,7 @@ import { PageTreeNodeBase } from "./entities/page-tree-node-base.entity"; import { defaultReservedPaths, PAGE_TREE_CONFIG, PAGE_TREE_ENTITY, PAGE_TREE_REPOSITORY, SITE_PREVIEW_CONFIG } from "./page-tree.constants"; import { PageTreeService } from "./page-tree.service"; import { PageTreeReadApiService } from "./page-tree-read-api.service"; -import { SitePreviewConfig, SitePreviewResolver } from "./site-preview.resolver"; +import { SitePreviewResolver } from "./site-preview.resolver"; import type { PageTreeNodeInterface, ScopeInterface } from "./types"; import { PageExistsConstraint } from "./validators/page-exists.validator"; @@ -67,13 +67,6 @@ export class PageTreeModule { const documentSubscriber = DocumentSubscriberFactory.create({ Documents }); - const sitePreviewProvider: Provider = { - provide: SITE_PREVIEW_CONFIG, - useValue: { - secret: options.sitePreviewSecret, - }, - }; - return { module: PageTreeModule, imports: [MikroOrmModule.forFeature([AttachedDocument, PageTreeNode, ...(Scope ? [Scope] : [])])], @@ -93,7 +86,12 @@ export class PageTreeModule { inject: [PageTreeService], }, documentSubscriber, - sitePreviewProvider, + { + provide: SITE_PREVIEW_CONFIG, + useValue: { + secret: options.sitePreviewSecret, + }, + }, SitePreviewResolver, ], exports: [PageTreeService, PageTreeReadApiService, AttachedDocumentLoaderService],