diff --git a/.changeset/swift-monkeys-turn.md b/.changeset/swift-monkeys-turn.md new file mode 100644 index 00000000000..db641fce445 --- /dev/null +++ b/.changeset/swift-monkeys-turn.md @@ -0,0 +1,8 @@ +--- +"@comet/cms-admin": major +"@comet/cms-api": major +--- + +Redirects: add `domain` source type + +To fully support domain redirects, additional handling is required in the site middleware. diff --git a/demo/api/schema.gql b/demo/api/schema.gql index d7ad43df410..6c269ef96e7 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -1996,6 +1996,7 @@ input RedirectFilter { generationType: StringFilter or: [RedirectFilter!] source: StringFilter + sourceType: RedirectSourceTypeEnumFilter target: StringFilter updatedAt: DateTimeFilter } @@ -2033,7 +2034,14 @@ enum RedirectSortField { updatedAt } +input RedirectSourceTypeEnumFilter { + equal: RedirectSourceTypeValues + isAnyOf: [RedirectSourceTypeValues!] + notEqual: RedirectSourceTypeValues +} + enum RedirectSourceTypeValues { + domain path } diff --git a/demo/site-configs/main.ts b/demo/site-configs/main.ts index a55d93d9878..d874345c4d8 100644 --- a/demo/site-configs/main.ts +++ b/demo/site-configs/main.ts @@ -9,6 +9,7 @@ export default ((env) => { name: "Comet Site Main", domains: { main: envToDomainMap[env], + additional: ["test.localhost:3000"], }, public: { scope: { diff --git a/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx b/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx index 9178e9be3ff..04ca6cbb867 100644 --- a/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx +++ b/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx @@ -91,10 +91,9 @@ export default async function Page({ params }: PageProps<"/[visibility]/[domain] break; } } - } - - if (destination) { - redirect(destination); + if (destination) { + redirect(destination); + } } } notFound(); diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index 20845be12a0..89c6b00fd64 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -1,25 +1,119 @@ +import { gql } from "@comet/site-nextjs"; +import { type ExternalLinkBlockData, type InternalLinkBlockData, type NewsLinkBlockData, type RedirectsLinkBlockData } from "@src/blocks.generated"; +import { type GQLPageTreeNodeScope, type GQLRedirectScopeInput } from "@src/graphql.generated"; import type { PublicSiteConfig } from "@src/site-configs"; +import { createSitePath } from "@src/util/createSitePath"; +import { createGraphQLFetchMiddleware } from "@src/util/graphQLClientMiddleware"; import { getHostByHeaders, getSiteConfigForHost, getSiteConfigs } from "@src/util/siteConfig"; import { type NextRequest, NextResponse } from "next/server"; +import { memoryCache } from "./cache"; import { type CustomMiddleware } from "./chain"; +import { type GQLDomainRedirectsQuery, type GQLDomainRedirectsQueryVariables } from "./redirectToMainHost.generated"; + +const domainRedirectsQuery = gql` + query DomainRedirects($scope: RedirectScopeInput!, $filter: RedirectFilter, $offset: Int, $limit: Int) { + paginatedRedirects(scope: $scope, filter: $filter, offset: $offset, limit: $limit) { + nodes { + id + source + target + sourceType + scope { + domain + } + } + totalCount + } + } +`; + +const graphQLFetch = createGraphQLFetchMiddleware(); + +type Redirect = GQLDomainRedirectsQuery["paginatedRedirects"]["nodes"][number]; + +async function fetchDomainRedirects(scope: GQLRedirectScopeInput) { + const key = `domainRedirects-${scope.domain}`; + return memoryCache.wrap(key, async () => { + const limit = 50; + + let allNodes: Redirect[] = []; + let totalCount = 0; + let currentCount = 0; + do { + const data = await graphQLFetch(domainRedirectsQuery, { + scope: { domain: scope.domain }, + filter: { sourceType: { equal: "domain" } }, + offset: currentCount, + limit, + }); + const nodes = data?.paginatedRedirects?.nodes || []; + totalCount = data?.paginatedRedirects?.totalCount || 0; + currentCount += nodes.length; + allNodes = allNodes.concat(nodes); + } while (currentCount < totalCount); + return allNodes; + }); +} + +async function fetchDomainRedirectsForAllScopes() { + return (await Promise.all(getSiteConfigs().map((config) => fetchDomainRedirects(config.scope)))).flat(); +} + +function normalizeHost(value: string): string { + return value.replace(/^https?:\/\//, ""); +} const normalizeDomain = (host: string) => (host.startsWith("www.") ? host.substring(4) : host); const matchesHostWithAdditionalDomain = (siteConfig: PublicSiteConfig, host: string) => { - if (normalizeDomain(siteConfig.domains.main) === normalizeDomain(host)) return true; // non-www redirect - if (siteConfig.domains.additional?.map(normalizeDomain).includes(normalizeDomain(host))) return true; + if (normalizeDomain(siteConfig.domains.main) === normalizeDomain(host)) { + return true; // non-www redirect + } + if (siteConfig.domains.additional?.map(normalizeDomain).includes(normalizeDomain(host))) { + return true; + } return false; }; const matchesHostWithPattern = (siteConfig: PublicSiteConfig, host: string) => { - if (!siteConfig.domains.pattern) return false; + if (!siteConfig.domains.pattern) { + return false; + } return new RegExp(siteConfig.domains.pattern).test(host); }; -/** - * When http host isn't siteConfig.domains.main (instead .pattern or .additional match), redirect to main host. - */ +function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], targetBaseUrl: string): string | undefined { + if (!block) { + return undefined; + } + switch (block.type) { + case "internal": { + const internalLink = block.props as InternalLinkBlockData; + if (internalLink.targetPage) { + return `${targetBaseUrl}${createSitePath({ + path: internalLink.targetPage.path, + scope: internalLink.targetPage.scope as GQLPageTreeNodeScope, + })}`; + } + break; + } + case "external": + return (block.props as ExternalLinkBlockData).targetUrl; + case "news": { + const newsLink = block.props as NewsLinkBlockData; + if (newsLink.news) { + return createSitePath({ + path: `/news/${newsLink.news.slug}`, + scope: newsLink.news.scope, + }); + } + break; + } + } + return undefined; +} + export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { return async (request: NextRequest) => { const headers = request.headers; @@ -27,14 +121,55 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { const siteConfig = await getSiteConfigForHost(host); if (!siteConfig) { - // Redirect to Main Host const redirectSiteConfig = getSiteConfigs().find((siteConfig) => matchesHostWithAdditionalDomain(siteConfig, host)) || getSiteConfigs().find((siteConfig) => matchesHostWithPattern(siteConfig, host)); + if (redirectSiteConfig) { - return NextResponse.redirect(`https://${redirectSiteConfig.domains.main}${request.nextUrl.pathname}${request.nextUrl.search}`, { - status: 301, - }); + const { scope } = redirectSiteConfig; + + const domainRedirects = await fetchDomainRedirects(scope); + + const redirect = domainRedirects.find((redirect) => normalizeHost(redirect.source) === normalizeHost(host)); + + if (redirect) { + const destination = getRedirectTargetUrl(redirect.target.block, `https://${redirectSiteConfig.domains.main}`); + if (destination) { + if (normalizeHost(new URL(destination).host) === normalizeHost(host)) { + throw new Error(`Redirect loop detected: ${host} -> ${destination}`); + } + return NextResponse.redirect(destination, { status: 301 }); + } + } + // Redirect to Main Host + const mainHost = normalizeHost(redirectSiteConfig.domains.main); + if (mainHost === normalizeHost(host)) { + throw new Error(`Redirect loop detected: main host ${mainHost} equals current host`); + } else { + return NextResponse.redirect(`https://${redirectSiteConfig.domains.main}${request.nextUrl.pathname}${request.nextUrl.search}`, { + status: 301, + }); + } + } + + const domainRedirects = await fetchDomainRedirectsForAllScopes(); + + const redirect = domainRedirects.find((redirect) => normalizeHost(redirect.source) === normalizeHost(host)); + if (redirect) { + const scopedSiteConfig = getSiteConfigs().find((config) => config.scope.domain === redirect.scope.domain); + + if (!scopedSiteConfig) { + throw new Error(`Got redirect to domain ${redirect.scope.domain}, but couldn't find corresponding site-config.`); + } + + const destination = getRedirectTargetUrl(redirect.target.block, `https://${scopedSiteConfig.domains.main}`); + + if (destination) { + if (normalizeHost(new URL(destination).host) === normalizeHost(host)) { + throw new Error(`Redirect loop detected: ${host} -> ${destination}`); + } + return NextResponse.redirect(destination, { status: 301 }); + } } return NextResponse.json({ error: `Cannot resolve domain: ${host}` }, { status: 404 }); diff --git a/docs/docs/7-migration-guide/migration-from-v8-to-v9.md b/docs/docs/7-migration-guide/migration-from-v8-to-v9.md index b2a36facf55..acf66ef2391 100644 --- a/docs/docs/7-migration-guide/migration-from-v8-to-v9.md +++ b/docs/docs/7-migration-guide/migration-from-v8-to-v9.md @@ -672,6 +672,29 @@ Review the [migration guide](https://nextjs.org/docs/app/guides/upgrading/versio mv site/src/middleware.ts site/src/proxy.ts ``` +:::note + +If you're using Knip, you may need to add `proxy.ts` as entry point: + +```diff title="site/knip.json" +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "entry": [ + "./src/app/**", + "./cache-handler.ts", + "./tracing.ts", ++ "./src/proxy.ts" + ], + "project": ["./src/**/*.{ts,tsx}"] +} +``` + +::: + +### Domain Redirects + +Domain redirects can now be set in the admin. It is necessary to update your middleware — most likely the `redirectToMainHost` middleware — to handle domain redirects. See example in the demo here: https://github.com/vivid-planet/comet/blob/main/demo/site/src/middleware/redirectToMainHost.ts + ### Add `cache: "force-cache"` to GraphQL fetch Next.js no longer caches `fetch` requests by default. diff --git a/packages/admin/cms-admin/src/redirects/RedirectForm.tsx b/packages/admin/cms-admin/src/redirects/RedirectForm.tsx index cec275f8529..6706509b3ff 100644 --- a/packages/admin/cms-admin/src/redirects/RedirectForm.tsx +++ b/packages/admin/cms-admin/src/redirects/RedirectForm.tsx @@ -1,5 +1,6 @@ import { gql, useApolloClient, useQuery } from "@apollo/client"; import { + Alert, Field, FillSpace, FinalForm, @@ -14,14 +15,14 @@ import { ToolbarTitleItem, useStackSwitchApi, } from "@comet/admin"; -import { MenuItem } from "@mui/material"; +import { Box, MenuItem } from "@mui/material"; +import { isFQDN } from "class-validator"; import isEqual from "lodash.isequal"; import { type JSX, useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { createFinalFormBlock } from "../blocks/form/createFinalFormBlock"; import { type BlockInterface, type BlockState } from "../blocks/types"; -import { isValidUrl } from "../blocks/validators/isValidUrl"; import { ContentScopeIndicator } from "../contentScope/ContentScopeIndicator"; import { type GQLRedirectSourceTypeValues } from "../graphql.generated"; import { type GQLRedirectSourceAvailableQuery, type GQLRedirectSourceAvailableQueryVariables } from "./RedirectForm.generated"; @@ -97,6 +98,13 @@ export const RedirectForm = ({ mode, id, linkBlock, scope }: Props): JSX.Element defaultMessage: "Path", }), }, + { + value: "domain", + label: intl.formatMessage({ + id: "comet.pages.redirects.redirect.source.type.domain", + defaultMessage: "Domain", + }), + }, ]; const [submit] = useSubmitMutation(mode, id, linkBlock, scope); @@ -149,10 +157,10 @@ export const RedirectForm = ({ mode, id, linkBlock, scope }: Props): JSX.Element }; const validateDomain = (value: string) => { - if (!isValidUrl(value)) { + if (!isFQDN(value)) { return intl.formatMessage({ id: "comet.pages.redirects.validate.domain.error", - defaultMessage: "Needs to start with http:// or https://", + defaultMessage: "Needs to be a valid domain (e.g. example.com)", }); } }; @@ -209,6 +217,16 @@ export const RedirectForm = ({ mode, id, linkBlock, scope }: Props): JSX.Element )} + {values.sourceType === "domain" && ( + + + + + + )} { + this.addSql(`ALTER TABLE "Redirect" DROP CONSTRAINT IF EXISTS "Redirect_sourceType_check";`); + this.addSql(`ALTER TABLE "Redirect" ADD CONSTRAINT "Redirect_sourceType_check" CHECK ("sourceType" IN ('path', 'domain'));`); + } + + override async down(): Promise {} +} diff --git a/packages/api/cms-api/src/mikro-orm/mikro-orm.module.ts b/packages/api/cms-api/src/mikro-orm/mikro-orm.module.ts index 53d75bf6762..42bf869ba25 100644 --- a/packages/api/cms-api/src/mikro-orm/mikro-orm.module.ts +++ b/packages/api/cms-api/src/mikro-orm/mikro-orm.module.ts @@ -36,6 +36,7 @@ import { Migration20250623085054 } from "./migrations/Migration20250623085054"; import { Migration20250623113026 } from "./migrations/Migration20250623113026"; import { Migration20251013081751 } from "./migrations/Migration20251013081751"; import { Migration20251118143418 } from "./migrations/Migration20251118143418"; +import { Migration20251126093305 } from "./migrations/Migration20251126093305"; export interface MikroOrmModuleOptions { ormConfig: MikroOrmNestjsOptions; @@ -105,6 +106,7 @@ export function createOrmConfig({ migrations, ...defaults }: MikroOrmNestjsOptio { name: "Migration20250623113026", class: Migration20250623113026 }, { name: "Migration20251118143418", class: Migration20251118143418 }, { name: "Migration20251013081751", class: Migration20251013081751 }, + { name: "Migration20251126093305", class: Migration20251126093305 }, { name: "Migration20250531565156", class: Migration20250531565156 }, { name: "Migration20250531565157", class: Migration20250531565157 }, ...(migrations?.migrationsList || []), diff --git a/packages/api/cms-api/src/redirects/dto/redirects.filter.ts b/packages/api/cms-api/src/redirects/dto/redirects.filter.ts index 715d8624e2e..63e0bf2bfa3 100644 --- a/packages/api/cms-api/src/redirects/dto/redirects.filter.ts +++ b/packages/api/cms-api/src/redirects/dto/redirects.filter.ts @@ -4,7 +4,12 @@ import { IsOptional, ValidateNested } from "class-validator"; import { BooleanFilter } from "../../common/filter/boolean.filter"; import { DateTimeFilter } from "../../common/filter/date-time.filter"; +import { createEnumFilter } from "../../common/filter/enum.filter.factory"; import { StringFilter } from "../../common/filter/string.filter"; +import { RedirectSourceTypeValues } from "../redirects.enum"; + +@InputType() +class RedirectSourceTypeEnumFilter extends createEnumFilter(RedirectSourceTypeValues) {} @InputType() export class RedirectFilter { @@ -28,6 +33,12 @@ export class RedirectFilter { @Type(() => BooleanFilter) active?: BooleanFilter; + @Field(() => RedirectSourceTypeEnumFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => RedirectSourceTypeEnumFilter) + sourceType?: RedirectSourceTypeEnumFilter; + @Field(() => DateTimeFilter, { nullable: true }) @ValidateNested() @Type(() => DateTimeFilter) diff --git a/packages/api/cms-api/src/redirects/redirects.enum.ts b/packages/api/cms-api/src/redirects/redirects.enum.ts index 5bc244f62b3..7de3bf7102b 100644 --- a/packages/api/cms-api/src/redirects/redirects.enum.ts +++ b/packages/api/cms-api/src/redirects/redirects.enum.ts @@ -2,6 +2,7 @@ import { registerEnumType } from "@nestjs/graphql"; export enum RedirectSourceTypeValues { "path" = "path", + "domain" = "domain", } registerEnumType(RedirectSourceTypeValues, { name: "RedirectSourceTypeValues" }); diff --git a/packages/api/cms-api/src/redirects/redirects.util.test.ts b/packages/api/cms-api/src/redirects/redirects.util.test.ts index 223ff996f86..514a9cdd39e 100644 --- a/packages/api/cms-api/src/redirects/redirects.util.test.ts +++ b/packages/api/cms-api/src/redirects/redirects.util.test.ts @@ -1,6 +1,6 @@ import { addDays, subDays } from "date-fns"; -import { RedirectGenerationType } from "./redirects.enum"; +import { RedirectGenerationType, RedirectSourceTypeValues } from "./redirects.enum"; import { type FilterableRedirect, isEmptyFilter, redirectMatchesFilter } from "./redirects.util"; describe("redirectMatchesFilter", () => { @@ -8,6 +8,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: new Date(), @@ -21,6 +22,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: new Date(), @@ -56,6 +58,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: new Date(), @@ -67,6 +70,31 @@ describe("redirectMatchesFilter", () => { expect(redirectMatchesFilter(redirect, { active: {} })).toBe(true); }); + it("should match for enum filter", () => { + const redirect: FilterableRedirect = { + generationType: RedirectGenerationType.manual, + source: "/source", + sourceType: RedirectSourceTypeValues.path, + target: "/target", + active: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(redirectMatchesFilter(redirect, { sourceType: { equal: RedirectSourceTypeValues.path } })).toBe(true); + expect(redirectMatchesFilter(redirect, { sourceType: { equal: RedirectSourceTypeValues.domain } })).toBe(false); + + expect(redirectMatchesFilter(redirect, { sourceType: { notEqual: RedirectSourceTypeValues.domain } })).toBe(true); + expect(redirectMatchesFilter(redirect, { sourceType: { notEqual: RedirectSourceTypeValues.path } })).toBe(false); + + expect( + redirectMatchesFilter(redirect, { + sourceType: { isAnyOf: [RedirectSourceTypeValues.path, RedirectSourceTypeValues.domain] }, + }), + ).toBe(true); + expect(redirectMatchesFilter(redirect, { sourceType: { isAnyOf: [RedirectSourceTypeValues.domain] } })).toBe(false); + }); + it("should match for date time filter", () => { const today = new Date(); const yesterday = subDays(today, 1); @@ -75,6 +103,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: yesterday, @@ -107,6 +136,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: new Date(), @@ -121,6 +151,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: new Date(), @@ -139,6 +170,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: new Date(), @@ -161,6 +193,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "https://example.com/target", active: true, createdAt: new Date(), diff --git a/packages/api/cms-api/src/redirects/redirects.util.ts b/packages/api/cms-api/src/redirects/redirects.util.ts index 13c1a1924d4..dacde114d37 100644 --- a/packages/api/cms-api/src/redirects/redirects.util.ts +++ b/packages/api/cms-api/src/redirects/redirects.util.ts @@ -1,10 +1,14 @@ import { type BooleanFilter } from "../common/filter/boolean.filter"; import { type DateTimeFilter } from "../common/filter/date-time.filter"; +import { type EnumFilterInterface } from "../common/filter/enum.filter.factory"; import { type StringFilter } from "../common/filter/string.filter"; import { type RedirectFilter } from "./dto/redirects.filter"; import { type RedirectInterface } from "./entities/redirect-entity.factory"; -export type FilterableRedirect = Pick & { +export type FilterableRedirect = Pick< + RedirectInterface, + "generationType" | "source" | "sourceType" | "active" | "createdAt" | "updatedAt" | "activatedAt" +> & { target?: string; }; @@ -37,6 +41,11 @@ export function redirectMatchesFilter(redirect: FilterableRedirect, filter: Redi if (filter.active) { matches = booleanMatchesFilter(redirect.active, filter.active); } + + if (filter.sourceType) { + matches = enumMatchesFilter(redirect.sourceType, filter.sourceType); + } + if (filter.createdAt) { matches = dateTimeMatchesFilter(redirect.createdAt, filter.createdAt); } @@ -123,6 +132,17 @@ function dateTimeMatchesFilter(date: Date | undefined, filter: DateTimeFilter): return false; } +function enumMatchesFilter(value: TEnum, filter: EnumFilterInterface): boolean { + if (filter.equal !== undefined && value === filter.equal) { + return true; + } else if (filter.notEqual !== undefined && value !== filter.notEqual) { + return true; + } else if (filter.isAnyOf && filter.isAnyOf.includes(value)) { + return true; + } + return false; +} + export function isEmptyFilter(filter: RedirectFilter): boolean { const filters = Object.keys(filter);