diff --git a/.changeset/cold-guests-train.md b/.changeset/cold-guests-train.md
new file mode 100644
index 0000000000..315f9c7787
--- /dev/null
+++ b/.changeset/cold-guests-train.md
@@ -0,0 +1,11 @@
+---
+"@comet/cms-admin": minor
+"@comet/cms-site": minor
+"@comet/cms-api": minor
+---
+
+Create site preview JWT in the API
+
+With this change the site preview can be deployed unprotected. Authentication is made via a JWT created in the API and validated in the site. A separate domain for the site preview is still necessary.
+
+BREAKING: this update of Comet v7 requires to have set sitePreviewSecret (which has to be the same value like possibly already set for site). Please refer to https://github.com/vivid-planet/comet-starter/pull/371 for more information on how to upgrade.
diff --git a/.env b/.env
index 4ab8e2fcbc..4462d4f917 100644
--- a/.env
+++ b/.env
@@ -66,6 +66,7 @@ NEXT_PUBLIC_API_URL=$API_URL
# site-pages
SITE_PAGES_PORT=3001
SITE_PAGES_URL=http://localhost:$SITE_PAGES_PORT
+SITE_PREVIEW_SECRET=zPoURKLHPcMnV7E9
NEXT_PUBLIC_SITE_LANGUAGES=en,de
NEXT_PUBLIC_SITE_DEFAULT_LANGUAGE=en
NEXT_PUBLIC_SITE_PAGES_DOMAIN=secondary
diff --git a/demo/api/schema.gql b/demo/api/schema.gql
index 780ceb3b10..d792ba463b 100644
--- a/demo/api/schema.gql
+++ b/demo/api/schema.gql
@@ -750,6 +750,7 @@ type Query {
pageTreeNodeList(scope: PageTreeNodeScopeInput!, category: String): [PageTreeNode!]!
paginatedPageTreeNodes(scope: PageTreeNodeScopeInput!, category: String, sort: [PageTreeNodeSort!], documentType: String, offset: Int! = 0, limit: Int! = 25): PaginatedPageTreeNodes!
pageTreeNodeSlugAvailable(scope: PageTreeNodeScopeInput!, parentId: ID, slug: String!): SlugAvailability!
+ sitePreviewJwt(scope: JSONObject!, path: String!, includeInvisible: Boolean!): String!
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/app.module.ts b/demo/api/src/app.module.ts
index 2f950521bf..2b59960fe2 100644
--- a/demo/api/src/app.module.ts
+++ b/demo/api/src/app.module.ts
@@ -117,7 +117,9 @@ export class AppModule {
Documents: [Page, Link, PredefinedPage],
Scope: PageTreeNodeScope,
reservedPaths: ["/events"],
+ sitePreviewSecret: config.sitePreviewSecret,
}),
+
RedirectsModule.register({ customTargets: { news: NewsLinkBlock }, Scope: RedirectScope }),
BlobStorageModule.register({
backend: config.blob.storage,
diff --git a/demo/api/src/config/config.ts b/demo/api/src/config/config.ts
index 6aef9da589..6499d573ce 100644
--- a/demo/api/src/config/config.ts
+++ b/demo/api/src/config/config.ts
@@ -88,6 +88,7 @@ export function createConfig(processEnv: NodeJS.ProcessEnv) {
secret: envVars.FILE_UPLOADS_DOWNLOAD_SECRET,
},
},
+ sitePreviewSecret: envVars.SITE_PREVIEW_SECRET,
};
}
diff --git a/demo/api/src/config/environment-variables.ts b/demo/api/src/config/environment-variables.ts
index 173cc2d993..5842b401e9 100644
--- a/demo/api/src/config/environment-variables.ts
+++ b/demo/api/src/config/environment-variables.ts
@@ -135,4 +135,9 @@ export class EnvironmentVariables {
@IsString()
@MinLength(16)
FILE_UPLOADS_DOWNLOAD_SECRET: string;
+
+ @IsString()
+ @MinLength(16)
+ @ValidateIf(() => process.env.NODE_ENV === "production")
+ SITE_PREVIEW_SECRET: string;
}
diff --git a/packages/admin/cms-admin/src/preview/site/SitePreview.tsx b/packages/admin/cms-admin/src/preview/site/SitePreview.tsx
index 4c93a29566..18782ef62f 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, Domain, DomainLocked } from "@comet/admin-icons";
import { Grid, Tooltip, Typography } from "@mui/material";
import { ReactNode, useCallback, useState } from "react";
@@ -14,6 +15,7 @@ import { VisibilityToggle } from "../common/VisibilityToggle";
import { SitePrevewIFrameLocationMessage, SitePreviewIFrameMessageType } from "./iframebridge/SitePreviewIFrameMessage";
import { useSitePreviewIFrameBridge } from "./iframebridge/useSitePreviewIFrameBridge";
import { OpenLinkDialog } from "./OpenLinkDialog";
+import { GQLSitePreviewJwtQuery } from "./SitePreview.generated";
import { ActionsContainer, LogoWrapper, Root, SiteInformation, SiteLink, SiteLinkWrapper } from "./SitePreview.sc";
//TODO v4 remove RouteComponentProps
@@ -98,6 +100,7 @@ function SitePreview({ resolvePath, logo =
const newShowOnlyVisible = !showOnlyVisible;
setShowOnlyVisible(String(newShowOnlyVisible));
setIframePath(sitePath); //reload iframe with new settings
+ refetch();
};
const siteLink = `${siteConfig.url}${sitePath}`;
@@ -113,13 +116,26 @@ function SitePreview({ resolvePath, logo =
}
});
- const initialPageUrl = `${siteConfig.sitePreviewApiUrl}?${new URLSearchParams({
- scope: JSON.stringify(scope),
- path: iframePath,
- settings: JSON.stringify({
- includeInvisible: showOnlyVisible ? false : true,
- }),
- }).toString()}`;
+ const { data, error, refetch } = useQuery(
+ gql`
+ query SitePreviewJwt($scope: JSONObject!, $path: String!, $includeInvisible: Boolean!) {
+ sitePreviewJwt(scope: $scope, path: $path, includeInvisible: $includeInvisible)
+ }
+ `,
+ {
+ fetchPolicy: "network-only",
+ variables: {
+ scope,
+ path: iframePath,
+ includeInvisible: showOnlyVisible ? false : true,
+ },
+ pollInterval: 1000 * 60 * 60 * 24, // due to expiration time of jwt
+ },
+ );
+ if (error) throw new Error(error.message);
+ if (!data) return ;
+
+ const initialPageUrl = `${siteConfig.sitePreviewApiUrl}?${new URLSearchParams({ jwt: data.sitePreviewJwt }).toString()}`;
return (
diff --git a/packages/api/cms-api/generate-schema.ts b/packages/api/cms-api/generate-schema.ts
index 226f4830d0..6485126516 100644
--- a/packages/api/cms-api/generate-schema.ts
+++ b/packages/api/cms-api/generate-schema.ts
@@ -30,6 +30,7 @@ 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 { FileUploadsResolver } from "./src/file-uploads/file-uploads.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 { AzureAiTranslatorResolver } from "./src/translation/azure-ai-translator.resolver";
@@ -113,6 +114,7 @@ async function generateSchema(): Promise {
GenerateAltTextResolver,
GenerateImageTitleResolver,
FileUploadsResolver,
+ SitePreviewResolver,
]);
await writeFile("schema.gql", printSchema(schema));
diff --git a/packages/api/cms-api/package.json b/packages/api/cms-api/package.json
index 2b6d173386..998285a0eb 100644
--- a/packages/api/cms-api/package.json
+++ b/packages/api/cms-api/package.json
@@ -60,6 +60,7 @@
"graphql-parse-resolve-info": "^4.13.0",
"graphql-scalars": "^1.23.0",
"hasha": "^5.2.2",
+ "jose": "^5.2.4",
"jsonwebtoken": "^8.5.1",
"jszip": "^3.10.1",
"jwks-rsa": "^3.0.0",
diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql
index 3f2b294870..0696e73317 100644
--- a/packages/api/cms-api/schema.gql
+++ b/packages/api/cms-api/schema.gql
@@ -379,6 +379,7 @@ type Query {
userPermissionsAvailableContentScopes: [JSONObject!]!
fileUploadForTypesGenerationDoNotUse: FileUpload!
azureAiTranslate(input: AzureAiTranslationInput!): String!
+ sitePreviewJwt(scope: JSONObject!, path: String!, includeInvisible: Boolean!): String!
}
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 36a1db6737..23c752b3bc 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
@@ -11,11 +11,12 @@ 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 { PageTreeNodeDocumentEntityInfoService } from "./page-tree-node-document-entity-info.service";
import { PageTreeNodeDocumentEntityScopeService } from "./page-tree-node-document-entity-scope.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";
@@ -30,6 +31,7 @@ interface PageTreeModuleOptions {
Documents: Type[];
Scope?: Type;
reservedPaths?: string[];
+ sitePreviewSecret?: string;
}
@Global()
@@ -42,6 +44,13 @@ export class PageTreeModule {
throw new Error(`PageTreeModule: Your PageTreeNode entity must be named ${PAGE_TREE_ENTITY}`);
}
+ // TODO v8: Make sitePreviewSecret mandatory and remove this error
+ if (!options.sitePreviewSecret) {
+ throw new Error(
+ "BREAKING: this update of Comet v7 requires to have set sitePreviewSecret (which has to be the same value like possibly already set for site). Please refer to https://github.com/vivid-planet/comet-starter/pull/371 for more information on how to upgrade.",
+ );
+ }
+
const PageTreeResolver = createPageTreeResolver({
PageTreeNode,
Documents,
@@ -90,6 +99,13 @@ export class PageTreeModule {
PageTreeNodeDocumentEntityInfoService,
PageTreeNodeDocumentEntityScopeService,
InternalLinkBlockTransformerService,
+ {
+ provide: SITE_PREVIEW_CONFIG,
+ useValue: {
+ secret: options.sitePreviewSecret,
+ },
+ },
+ SitePreviewResolver,
],
exports: [
PageTreeService,
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..16680500e8
--- /dev/null
+++ b/packages/api/cms-api/src/page-tree/site-preview.resolver.ts
@@ -0,0 +1,36 @@
+import { Inject } from "@nestjs/common";
+import { Args, Query, Resolver } from "@nestjs/graphql";
+import { GraphQLJSONObject } from "graphql-scalars";
+import { SignJWT } from "jose";
+
+import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator";
+import { ContentScope } from "../user-permissions/interfaces/content-scope.interface";
+import { SITE_PREVIEW_CONFIG } from "./page-tree.constants";
+
+export type SitePreviewConfig = {
+ secret: string;
+};
+
+@Resolver()
+export class SitePreviewResolver {
+ constructor(@Inject(SITE_PREVIEW_CONFIG) private readonly config: SitePreviewConfig) {}
+
+ @Query(() => String)
+ @RequiredPermission("pageTree")
+ async sitePreviewJwt(
+ @Args("scope", { type: () => GraphQLJSONObject }) scope: ContentScope,
+ @Args("path") path: string,
+ @Args("includeInvisible") includeInvisible: boolean,
+ ): Promise {
+ return new SignJWT({
+ scope,
+ path,
+ previewData: {
+ includeInvisible,
+ },
+ })
+ .setProtectedHeader({ alg: "HS256" })
+ .setExpirationTime("1 day")
+ .sign(new TextEncoder().encode(this.config.secret));
+ }
+}
diff --git a/packages/site/cms-site/src/sitePreview/SitePreviewUtils.ts b/packages/site/cms-site/src/sitePreview/SitePreviewUtils.ts
index be6813ba19..5d1c8646b9 100644
--- a/packages/site/cms-site/src/sitePreview/SitePreviewUtils.ts
+++ b/packages/site/cms-site/src/sitePreview/SitePreviewUtils.ts
@@ -1,12 +1,10 @@
import "server-only";
-import { jwtVerify, SignJWT } from "jose";
+import { jwtVerify } from "jose";
import { cookies, draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { type NextRequest } from "next/server";
-import { GraphQLFetch } from "../graphQLFetch/graphQLFetch";
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Scope = Record;
@@ -15,56 +13,24 @@ export type SitePreviewData = {
};
export type SitePreviewParams = {
scope: Scope;
+ path: string;
previewData?: SitePreviewData;
};
-function getPreviewScopeSigningKey() {
- if (!process.env.SITE_PREVIEW_SECRET && process.env.NODE_ENV === "production") {
- throw new Error("SITE_PREVIEW_SECRET environment variable is required in production mode");
- }
- return process.env.SITE_PREVIEW_SECRET || "secret";
-}
-
-export async function sitePreviewRoute(request: NextRequest, graphQLFetch: GraphQLFetch) {
- const previewScopeSigningKey = getPreviewScopeSigningKey();
+export async function sitePreviewRoute(request: NextRequest, _graphQLFetch: unknown /* deprecated: remove argument in v8 */) {
const params = request.nextUrl.searchParams;
- const settingsParam = params.get("settings");
- const scopeParam = params.get("scope");
- if (!settingsParam || !scopeParam) {
- throw new Error("Missing settings or scope parameter");
+ const jwt = params.get("jwt");
+ if (!jwt) {
+ throw new Error("Missing jwt parameter");
}
- const previewData = JSON.parse(settingsParam);
- const scope = JSON.parse(scopeParam);
-
- const { currentUser } = await graphQLFetch<{ currentUser: { permissionsForScope: string[] } }, { scope: Scope }>(
- `
- query CurrentUserPermissionsForScope($scope: JSONObject!) {
- currentUser {
- permissionsForScope(scope: $scope)
- }
- }
- `,
- { scope },
- {
- headers: {
- authorization: request.headers.get("authorization") || "",
- },
- },
- );
- if (!currentUser.permissionsForScope.includes("pageTree")) {
- return new Response("Preview is not allowed", {
- status: 403,
- });
- }
+ const data = await verifySitePreviewJwt(jwt);
- const data: SitePreviewParams = { scope, previewData };
- const token = await new SignJWT(data).setProtectedHeader({ alg: "HS256" }).sign(new TextEncoder().encode(previewScopeSigningKey));
- cookies().set("__comet_preview", token);
+ cookies().set("__comet_preview", jwt);
draftMode().enable();
- return redirect(params.get("path") || "/");
+ return redirect(data.path);
}
/**
@@ -73,16 +39,21 @@ export async function sitePreviewRoute(request: NextRequest, graphQLFetch: Graph
* @return If SitePreview is active the current preview settings
*/
export async function previewParams(options: { skipDraftModeCheck: boolean } = { skipDraftModeCheck: false }): Promise {
- const previewScopeSigningKey = getPreviewScopeSigningKey();
-
if (!options.skipDraftModeCheck) {
if (!draftMode().isEnabled) return null;
}
const cookie = cookies().get("__comet_preview");
if (cookie) {
- const { payload } = await jwtVerify(cookie.value, new TextEncoder().encode(previewScopeSigningKey));
- return payload;
+ return verifySitePreviewJwt(cookie.value);
}
return null;
}
+
+export async function verifySitePreviewJwt(jwt: string): Promise {
+ if (!process.env.SITE_PREVIEW_SECRET) {
+ throw new Error("SITE_PREVIEW_SECRET environment variable is required.");
+ }
+ const data = await jwtVerify(jwt, new TextEncoder().encode(process.env.SITE_PREVIEW_SECRET));
+ return data.payload;
+}
diff --git a/packages/site/cms-site/src/sitePreview/pagesRouter/legacyPagesRouterSitePreviewApiHandler.ts b/packages/site/cms-site/src/sitePreview/pagesRouter/legacyPagesRouterSitePreviewApiHandler.ts
index aa8e268c63..f6447e3739 100644
--- a/packages/site/cms-site/src/sitePreview/pagesRouter/legacyPagesRouterSitePreviewApiHandler.ts
+++ b/packages/site/cms-site/src/sitePreview/pagesRouter/legacyPagesRouterSitePreviewApiHandler.ts
@@ -1,50 +1,23 @@
import { NextApiRequest, NextApiResponse } from "next";
-import { Scope, SitePreviewParams } from "../SitePreviewUtils";
+import { verifySitePreviewJwt } from "../SitePreviewUtils";
-type GraphQLClient = {
- setHeader(key: string, value: string): unknown;
- request(document: string, variables?: Variables): Promise;
-};
-
-async function legacyPagesRouterSitePreviewApiHandler(req: NextApiRequest, res: NextApiResponse, graphQLClient: GraphQLClient) {
+async function legacyPagesRouterSitePreviewApiHandler(
+ req: NextApiRequest,
+ res: NextApiResponse,
+ _graphQLClient: unknown /* deprecated: remove argument in v8 */,
+) {
const params = req.query;
- const settingsParam = params.settings;
- const scopeParam = params.scope;
-
- if (typeof settingsParam !== "string" || typeof scopeParam !== "string") {
- throw new Error("Missing settings or scope parameter");
- }
-
- if (Array.isArray(params.path)) {
- res.status(400).json({ error: "Parameter 'path' can't be an array" });
- return;
- }
-
- const previewData = JSON.parse(settingsParam);
- const scope = JSON.parse(scopeParam);
-
- graphQLClient.setHeader("authorization", req.headers?.authorization ?? "");
+ const jwt = params.jwt;
- const { currentUser } = await graphQLClient.request<{ currentUser: { permissionsForScope: string[] } }, { scope: Scope }>(
- `
- query CurrentUserPermissionsForScope($scope: JSONObject!) {
- currentUser {
- permissionsForScope(scope: $scope)
- }
- }
- `,
- { scope },
- );
- if (!currentUser.permissionsForScope.includes("pageTree")) {
- res.status(403).json({ error: "Preview is not allowed" });
- return;
+ if (typeof jwt !== "string") {
+ throw new Error("Missing jwt parameter");
}
- const data: SitePreviewParams = { scope, previewData };
+ const data = await verifySitePreviewJwt(jwt);
res.setPreviewData(data);
- res.redirect(params.path ?? "/");
+ res.redirect(data.path);
}
export { legacyPagesRouterSitePreviewApiHandler };
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6ecfb2597f..727b885d72 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2351,6 +2351,9 @@ importers:
hasha:
specifier: ^5.2.2
version: 5.2.2
+ jose:
+ specifier: ^5.2.4
+ version: 5.6.3
jsonwebtoken:
specifier: ^8.5.1
version: 8.5.1