diff --git a/.changeset/happy-pumpkins-agree.md b/.changeset/happy-pumpkins-agree.md new file mode 100644 index 0000000000..8041f70e6a --- /dev/null +++ b/.changeset/happy-pumpkins-agree.md @@ -0,0 +1,5 @@ +--- +"@comet/cms-api": minor +--- + +Redirects: add redirectBySource query that can be used to query for a single redirect by source diff --git a/demo/api/schema.gql b/demo/api/schema.gql index e7f2e064bf..be794b1e07 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -766,7 +766,8 @@ type Query { 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! + redirect(id: ID!): Redirect + redirectBySource(scope: RedirectScopeInput!, source: String!, sourceType: RedirectSourceTypeValues!): Redirect redirectSourceAvailable(scope: RedirectScopeInput!, source: String!): Boolean! damItemsList(offset: Int! = 0, limit: Int! = 25, sortColumnName: String, sortDirection: SortDirection! = ASC, scope: DamScopeInput!, folderId: ID, includeArchived: Boolean, filter: DamItemFilterInput): PaginatedDamItems! damItemListPosition(sortColumnName: String, sortDirection: SortDirection! = ASC, scope: DamScopeInput!, id: ID!, type: DamItemType!, folderId: ID, includeArchived: Boolean, filter: DamItemFilterInput): Int! diff --git a/demo/site/next.config.js b/demo/site/next.config.js index 53723323ca..e89ee9a261 100644 --- a/demo/site/next.config.js +++ b/demo/site/next.config.js @@ -19,6 +19,20 @@ const nextConfig = { }, ]; }, + async redirects() { + const adminUrl = process.env.ADMIN_URL; + + if (!adminUrl) { + throw Error("ADMIN_URL is not defined"); + } + return [ + { + source: "/admin", + destination: adminUrl, + permanent: false, + }, + ]; + }, images: { deviceSizes: cometConfig.dam.allowedImageSizes, }, diff --git a/demo/site/src/app/[lang]/[[...path]]/page.tsx b/demo/site/src/app/[lang]/[[...path]]/page.tsx index 2ec9b77406..f5a4f2ec2d 100644 --- a/demo/site/src/app/[lang]/[[...path]]/page.tsx +++ b/demo/site/src/app/[lang]/[[...path]]/page.tsx @@ -1,30 +1,44 @@ import { gql, previewParams } from "@comet/cms-site"; +import { ExternalLinkBlockData, InternalLinkBlockData, RedirectsLinkBlockData } from "@src/blocks.generated"; import { domain, languages } from "@src/config"; import { documentTypes } from "@src/documentTypes"; -import { GQLPageTreeNodeScopeInput } from "@src/graphql.generated"; +import { GQLPageTreeNodeScope, GQLPageTreeNodeScopeInput } from "@src/graphql.generated"; import { createGraphQLFetch } from "@src/util/graphQLClient"; import type { Metadata, ResolvingMetadata } from "next"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { GQLDocumentTypeQuery, GQLDocumentTypeQueryVariables } from "./page.generated"; const documentTypeQuery = gql` - query DocumentType($path: String!, $scope: PageTreeNodeScopeInput!) { - pageTreeNodeByPath(path: $path, scope: $scope) { + query DocumentType( + $skipPage: Boolean! + $path: String! + $scope: PageTreeNodeScopeInput! + $redirectSource: String! + $redirectScope: RedirectScopeInput! + ) { + pageTreeNodeByPath(path: $path, scope: $scope) @skip(if: $skipPage) { id documentType } + redirectBySource(source: $redirectSource, sourceType: path, scope: $redirectScope) { + target + } } `; async function fetchPageTreeNode(params: { path: string[]; lang: string }) { + const skipPage = !languages.includes(params.lang); const { scope, previewData } = (await previewParams()) || { scope: { domain, language: params.lang }, previewData: undefined }; const graphQLFetch = createGraphQLFetch(previewData); return graphQLFetch( documentTypeQuery, { + skipPage, path: `/${(params.path ?? []).join("/")}`, scope: scope as GQLPageTreeNodeScopeInput, //TODO fix type, the scope from previewParams() is not compatible with GQLPageTreeNodeScopeInput + redirectSource: `/${params.lang}${params.path ? `/${params.path.join("/")}` : ""}`, + redirectScope: { domain: scope.domain }, }, { method: "GET" }, //for request memoization ); @@ -60,13 +74,30 @@ export default async function Page({ params }: Props) { // TODO support multiple domains, get domain by Host header const { scope } = (await previewParams()) || { scope: { domain, language: params.lang } }; - if (!languages.includes(params.lang)) { - notFound(); - } - const data = await fetchPageTreeNode(params); if (!data.pageTreeNodeByPath?.documentType) { + if (data.redirectBySource?.target) { + const target = data.redirectBySource?.target as RedirectsLinkBlockData; + let destination: string | undefined; + if (target.block !== undefined) { + switch (target.block.type) { + case "internal": { + const internalLink = target.block.props as InternalLinkBlockData; + if (internalLink.targetPage) { + destination = `${(internalLink.targetPage.scope as GQLPageTreeNodeScope).language}/${internalLink.targetPage.path}`; + } + break; + } + case "external": + destination = (target.block.props as ExternalLinkBlockData).targetUrl; + break; + } + } + if (destination && destination !== `/${params.lang}${params.path ? `/${params.path.join("/")}` : ""}`) { + redirect(destination); + } + } notFound(); } diff --git a/demo/site/src/app/[lang]/layout.tsx b/demo/site/src/app/[lang]/layout.tsx index c527c1a431..fb25de7b7e 100644 --- a/demo/site/src/app/[lang]/layout.tsx +++ b/demo/site/src/app/[lang]/layout.tsx @@ -1,3 +1,4 @@ +import { languages } from "@src/config"; import { readFile } from "fs/promises"; import { PropsWithChildren } from "react"; @@ -13,9 +14,13 @@ async function loadMessages(lang: string) { } export default async function Page({ children, params }: PropsWithChildren<{ params: { lang: string } }>) { - const messages = await loadMessages(params.lang); + let language = params.lang; + if (!languages.includes(language)) { + language = "en"; + } + const messages = await loadMessages(language); return ( - + {children} ); diff --git a/demo/site/src/middleware.ts b/demo/site/src/middleware.ts index 452047ebdb..2ee59f0d15 100644 --- a/demo/site/src/middleware.ts +++ b/demo/site/src/middleware.ts @@ -3,21 +3,12 @@ import { NextResponse } from "next/server"; import { domain } from "./config"; import { getPredefinedPageRedirect, getPredefinedPageRewrite } from "./middleware/predefinedPages"; -import { createRedirects } from "./middleware/redirects"; export async function middleware(request: NextRequest) { const { pathname } = new URL(request.url); const scope = { domain }; - const redirects = await createRedirects(scope); - - const redirect = redirects.get(pathname); - if (redirect) { - const destination: string = redirect.destination; - return NextResponse.redirect(new URL(destination, request.url), redirect.permanent ? 308 : 307); - } - const predefinedPageRedirect = await getPredefinedPageRedirect(scope, pathname); if (predefinedPageRedirect) { diff --git a/demo/site/src/middleware/redirects.ts b/demo/site/src/middleware/redirects.ts deleted file mode 100644 index ff0ac9869d..0000000000 --- a/demo/site/src/middleware/redirects.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { gql } from "@comet/cms-site"; -import { ExternalLinkBlockData, InternalLinkBlockData, RedirectsLinkBlockData } from "@src/blocks.generated"; -import { GQLRedirectScope } from "@src/graphql.generated"; -import { createGraphQLFetch } from "@src/util/graphQLClient"; -import { RouteHas } from "next/dist/lib/load-custom-routes"; - -import { memoryCache } from "./cache"; -import { GQLRedirectsQuery, GQLRedirectsQueryVariables } from "./redirects.generated"; - -const redirectsQuery = gql` - query Redirects($scope: RedirectScopeInput!, $filter: RedirectFilter, $sort: [RedirectSort!], $offset: Int!, $limit: Int!) { - paginatedRedirects(scope: $scope, filter: $filter, sort: $sort, offset: $offset, limit: $limit) { - nodes { - sourceType - source - target - } - totalCount - } - } -`; - -const graphQLFetch = createGraphQLFetch(); - -const createInternalRedirects = async (): Promise> => { - const redirectsMap = new Map(); - const adminUrl = process.env.ADMIN_URL; - - if (!adminUrl) { - throw Error("ADMIN_URL is not defined"); - } - - redirectsMap.set("/admin", { destination: adminUrl, permanent: false }); - return redirectsMap; -}; - -async function* fetchApiRedirects(scope: GQLRedirectScope) { - let offset = 0; - const limit = 1000; - - while (true) { - const { paginatedRedirects } = await graphQLFetch(redirectsQuery, { - filter: { active: { equal: true } }, - sort: { field: "createdAt", direction: "DESC" }, - offset, - limit, - scope, - }); - - yield* paginatedRedirects.nodes; - - if (offset + limit >= paginatedRedirects.totalCount) { - break; - } - - offset += limit; - } -} - -const createApiRedirects = async (scope: GQLRedirectScope): Promise> => { - const redirects = new Map(); - function replaceRegexCharacters(value: string): string { - // escape ":" and "?", otherwise it is used for next.js regex path matching (https://nextjs.org/docs/pages/api-reference/next-config-js/redirects#regex-path-matching) - return value.replace(/[:?]/g, "\\$&"); - } - - // eslint-disable-next-line no-console - console.time("createApiRedirects"); - - for await (const redirect of fetchApiRedirects(scope)) { - let source: string | undefined; - let destination: string | undefined; - let has: Redirect["has"]; - - if (redirect.sourceType === "path") { - // query parameters have to be defined with has, see: https://nextjs.org/docs/pages/api-reference/next-config-js/redirects#header-cookie-and-query-matching - if (redirect.source?.includes("?")) { - const searchParamsString = redirect.source.split("?").slice(1).join("?"); - const searchParams = new URLSearchParams(searchParamsString); - has = []; - - searchParams.forEach((value, key) => { - if (has) { - has.push({ type: "query", key, value: replaceRegexCharacters(value) }); - } - }); - source = replaceRegexCharacters(redirect.source.replace(searchParamsString, "")); - } else { - source = replaceRegexCharacters(redirect.source); - } - } - - const target = redirect.target as RedirectsLinkBlockData; - - if (target.block !== undefined) { - switch (target.block.type) { - case "internal": - destination = (target.block.props as InternalLinkBlockData).targetPage?.path; - break; - - case "external": - destination = (target.block.props as ExternalLinkBlockData).targetUrl; - break; - } - } - - if (source === destination) { - console.warn(`Skipping redirect loop ${source} -> ${destination}`); - continue; - } - - if (source && destination) { - redirects.set(source, { destination, permanent: true }); - } - } - // eslint-disable-next-line no-console - console.timeEnd("createApiRedirects"); - return redirects; -}; - -type Redirect = { destination: string; permanent: boolean; has?: RouteHas[] | undefined }; - -export const createRedirects = async (scope: GQLRedirectScope) => { - const key = `redirects-${JSON.stringify(scope)}`; - return memoryCache.wrap(key, async () => { - return new Map([ - ...Array.from(await createApiRedirects({ domain: scope.domain })), - ...Array.from(await createInternalRedirects()), - ]); - }); -}; diff --git a/packages/api/cms-api/src/redirects/redirects.resolver.ts b/packages/api/cms-api/src/redirects/redirects.resolver.ts index 0d9000edb0..24456725f5 100644 --- a/packages/api/cms-api/src/redirects/redirects.resolver.ts +++ b/packages/api/cms-api/src/redirects/redirects.resolver.ts @@ -1,4 +1,4 @@ -import { FindOptions, wrap } from "@mikro-orm/core"; +import { FilterQuery, FindOptions, wrap } from "@mikro-orm/core"; import { InjectRepository } from "@mikro-orm/nestjs"; import { EntityRepository } from "@mikro-orm/postgresql"; import { Type } from "@nestjs/common"; @@ -17,6 +17,7 @@ import { RedirectInputInterface } from "./dto/redirect-input.factory"; import { RedirectUpdateActivenessInput } from "./dto/redirect-update-activeness.input"; import { RedirectsArgsFactory } from "./dto/redirects-args.factory"; import { RedirectInterface } from "./entities/redirect-entity.factory"; +import { RedirectSourceTypeValues } from "./redirects.enum"; import { RedirectsService } from "./redirects.service"; import { RedirectScopeInterface } from "./types"; @@ -101,13 +102,27 @@ export function createRedirectsResolver({ return new PaginatedRedirects(entities, totalCount); } - @Query(() => Redirect) + @Query(() => Redirect, { nullable: true }) @AffectedEntity(Redirect) async redirect(@Args("id", { type: () => ID }) id: string): Promise { const redirect = await this.repository.findOne(id); return redirect ?? null; } + @Query(() => Redirect, { nullable: true }) + async redirectBySource( + @Args("scope", { type: () => Scope, defaultValue: hasNonEmptyScope ? undefined : {} }) scope: typeof Scope, + @Args("source", { type: () => String }) source: string, + @Args("sourceType", { type: () => RedirectSourceTypeValues }) sourceType: RedirectSourceTypeValues, + ): Promise { + const where: FilterQuery = { source, sourceType }; + if (hasNonEmptyScope) { + where.scope = scope; + } + const redirect = await this.repository.findOne(where); + return redirect ?? null; + } + @Query(() => Boolean) async redirectSourceAvailable( @Args("scope", { type: () => Scope, defaultValue: hasNonEmptyScope ? undefined : {} }) scope: typeof Scope,