diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 0406a6a8e0..1da8ac51e3 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,10 +1,10 @@ require("dotenv").config({ path: "../../.env" }); const CopyWebpackPlugin = require("copy-webpack-plugin"); const os = require("os"); -const glob = require("glob"); const englishTranslation = require("./public/static/locales/en/common.json"); const { withAxiom } = require("next-axiom"); const { i18n } = require("./next-i18next.config"); +const { pages } = require("./pages"); if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET"); if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY"); @@ -82,16 +82,19 @@ if (process.env.ANALYZE === "true") { plugins.push(withAxiom); -/** Needed to rewrite public booking page, gets all static pages but [user] */ -const pages = glob - .sync("pages/**/[^_]*.{tsx,js,ts}", { cwd: __dirname }) - .map((filename) => - filename - .substr(6) - .replace(/(\.tsx|\.js|\.ts)/, "") - .replace(/\/.*/, "") - ) - .filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]")); +// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does) +// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages +// It would also not match /free/30min/embed because we are ensuring just two slashes +// ?!book ensures it doesn't match /free/book page which doesn't have a corresponding new-booker page. +// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work. +// book$ ensures that only /book is excluded from rewrite(which is at the end always) and not /booked + +// Important Note: When modifying these RegExps update apps/web/test/lib/next-config.test.ts as well +const userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book$)[^/]+)`; +const teamTypeRouteRegExp = "/team/:slug/:type((?!book$)[^/]+)"; +const privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)"; +const embedUserTypeRouteRegExp = `/:user((?!${pages.join("|")}).*)/:type/embed`; +const embedTeamTypeRouteRegExp = "/team/:slug/:type/embed"; /** @type {import("next").NextConfig} */ const nextConfig = { @@ -183,14 +186,6 @@ const nextConfig = { return config; }, async rewrites() { - // .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does) - // It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages - // It would also not match /free/30min/embed because we are ensuring just two slashes - // ?!book ensures it doesn't match /free/book page which doesn't have a corresponding new-booker page. - // [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work. - const userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book)[^/]+)`; - const teamTypeRouteRegExp = "/team/:slug/:type((?!book)[^/]+)"; - const privateLinkRouteRegExp = "/d/:link/:slug((?!book)[^/]+)"; let rewrites = [ { source: "/org/:slug", @@ -248,12 +243,12 @@ const nextConfig = { // Keep cookie based booker enabled to test new-booker embed in production ...[ { - source: `/:user((?!${pages.join("|")}).*)/:type/embed`, + source: embedUserTypeRouteRegExp, destination: "/new-booker/:user/:type/embed", has: [{ type: "cookie", key: "new-booker-enabled" }], }, { - source: "/team/:slug/:type/embed", + source: embedTeamTypeRouteRegExp, destination: "/new-booker/team/:slug/:type/embed", has: [{ type: "cookie", key: "new-booker-enabled" }], }, diff --git a/apps/web/pages.js b/apps/web/pages.js new file mode 100644 index 0000000000..3d886afdd5 --- /dev/null +++ b/apps/web/pages.js @@ -0,0 +1,14 @@ +const glob = require("glob"); + +/** Needed to rewrite public booking page, gets all static pages but [user] */ +const pages = glob + .sync("pages/**/[^_]*.{tsx,js,ts}", { cwd: __dirname }) + .map((filename) => + filename + .substr(6) + .replace(/(\.tsx|\.js|\.ts)/, "") + .replace(/\/.*/, "") + ) + .filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]")); + +exports.pages = pages; diff --git a/apps/web/test/lib/next-config.test.ts b/apps/web/test/lib/next-config.test.ts new file mode 100644 index 0000000000..16190584dd --- /dev/null +++ b/apps/web/test/lib/next-config.test.ts @@ -0,0 +1,157 @@ + +import { it, expect, describe, beforeAll, afterAll } from "vitest"; +let userTypeRouteRegExp: RegExp; +let teamTypeRouteRegExp:RegExp; +let privateLinkRouteRegExp:RegExp +let embedUserTypeRouteRegExp:RegExp +let embedTeamTypeRouteRegExp:RegExp + + +const getRegExpFromNextJsRewriteRegExp = (nextJsRegExp:string) => { + // const parts = nextJsRegExp.split(':'); + + // const validNamedGroupRegExp = parts.map((part, index)=>{ + // if (index === 0) { + // return part; + // } + // if (part.match(/^[a-zA-Z0-9]+$/)) { + // return `(?<${part}>[^/]+)` + // } + // part = part.replace(new RegExp('([^(]+)(.*)'), '(?<$1>$2)'); + // return part + // }).join(''); + + // TODO: If we can easily convert the exported rewrite regexes from next.config.js to a valid named capturing group regex, it would be best + // Next.js does an exact match as per my testing. + return new RegExp(`^${nextJsRegExp}$`) +} + +describe('next.config.js - RegExp', ()=>{ + beforeAll(async()=>{ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_URL = process.env.CALENDSO_ENCRYPTION_KEY = 1 + // eslint-disable-next-line @typescript-eslint/no-var-requires + const pages = require("../../pages").pages + + // How to convert a Next.js rewrite RegExp/wildcard to a valid JS named capturing Group RegExp? + // - /:user/ -> (?[^/]+) + // - /:user(?!404)[^/]+/ -> (?((?!404)[^/]+)) + + // userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book$)[^/]+)`; + userTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?((?!${pages.join("/|")})[^/]*))/(?((?!book$)[^/]+))`); + + // teamTypeRouteRegExp = "/team/:slug/:type((?!book$)[^/]+)"; + teamTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp("/team/(?[^/]+)/(?((?!book$)[^/]+))"); + + // privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)"; + privateLinkRouteRegExp = getRegExpFromNextJsRewriteRegExp("/d/(?[^/]+)/(?((?!book$)[^/]+))"); + + // embedUserTypeRouteRegExp = `/:user((?!${pages.join("|")}).*)/:type/embed`; + embedUserTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?((?!${pages.join("|")}).*))/(?[^/]+)/embed`); + + // embedTeamTypeRouteRegExp = "/team/:slug/:type/embed"; + embedTeamTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp("/team/(?[^/]+)/(?[^/]+)/embed"); + }); + + afterAll(()=>{ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_URL = process.env.CALENDSO_ENCRYPTION_KEY = undefined + }) + + it("Booking Urls", async () => { + expect(userTypeRouteRegExp.exec('/free/30')?.groups).toContain({ + user: 'free', + type: '30' + }) + + // Edgecase of username starting with team also works + expect(userTypeRouteRegExp.exec('/teampro/30')?.groups).toContain({ + user: 'teampro', + type: '30' + }) + + expect(userTypeRouteRegExp.exec('/teampro+pro/30')?.groups).toContain({ + user: 'teampro+pro', + type: '30' + }) + + expect(userTypeRouteRegExp.exec('/teampro+pro/book')).toEqual(null) + + // Because /book doesn't have a corresponding new-booker route. + expect(userTypeRouteRegExp.exec('/free/book')).toEqual(null) + + // Because /booked is a normal event name + expect(userTypeRouteRegExp.exec('/free/booked')?.groups).toEqual({ + user: 'free', + type: 'booked' + }) + + + expect(embedUserTypeRouteRegExp.exec('/free/30/embed')?.groups).toContain({ + user: 'free', + type:'30' + }) + + expect(teamTypeRouteRegExp.exec('/team/seeded/30')?.groups).toContain({ + slug: 'seeded', + type: '30' + }) + + // Because /book doesn't have a corresponding new-booker route. + expect(teamTypeRouteRegExp.exec('/team/seeded/book')).toEqual(null) + + expect(teamTypeRouteRegExp.exec('/team/seeded/30/embed')).toEqual(null) + + expect(embedTeamTypeRouteRegExp.exec('/team/seeded/30/embed')?.groups).toContain({ + slug: 'seeded', + type:'30' + }) + + expect(privateLinkRouteRegExp.exec('/d/3v4s321CXRJZx5TFxkpPvd/30min')?.groups).toContain({ + link: '3v4s321CXRJZx5TFxkpPvd', + slug: '30min' + }) + + expect(privateLinkRouteRegExp.exec('/d/3v4s321CXRJZx5TFxkpPvd/30min')?.groups).toContain({ + link: '3v4s321CXRJZx5TFxkpPvd', + slug: '30min' + }) + + // Because /book doesn't have a corresponding new-booker route. + expect(privateLinkRouteRegExp.exec('/d/3v4s321CXRJZx5TFxkpPvd/book')).toEqual(null) + }); + + it('Non booking Urls', ()=>{ + + expect(userTypeRouteRegExp.exec('/404')).toEqual(null) + expect(teamTypeRouteRegExp.exec('/404')).toEqual(null) + + expect(userTypeRouteRegExp.exec('/404/30')).toEqual(null) + expect(teamTypeRouteRegExp.exec('/404/30')).toEqual(null) + + expect(userTypeRouteRegExp.exec('/api')).toEqual(null) + expect(teamTypeRouteRegExp.exec('/api')).toEqual(null) + + expect(userTypeRouteRegExp.exec('/api/30')).toEqual(null) + expect(teamTypeRouteRegExp.exec('/api/30')).toEqual(null) + + expect(userTypeRouteRegExp.exec('/workflows/30')).toEqual(null) + expect(teamTypeRouteRegExp.exec('/workflows/30')).toEqual(null) + + expect(userTypeRouteRegExp.exec('/event-types/30')).toEqual(null) + expect(teamTypeRouteRegExp.exec('/event-types/30')).toEqual(null) + + expect(userTypeRouteRegExp.exec('/teams/1')).toEqual(null) + expect(teamTypeRouteRegExp.exec('/teams/1')).toEqual(null) + + expect(userTypeRouteRegExp.exec('/teams')).toEqual(null) + expect(teamTypeRouteRegExp.exec('/teams')).toEqual(null) + + // Note that even though it matches /embed/embed.js, but it's served from /public and the regexes are in afterEach, it won't hit the flow. + // expect(userTypeRouteRegExp.exec('/embed/embed.js')).toEqual(null) + // expect(teamTypeRouteRegExp.exec('/embed/embed.js')).toEqual(null) + }) +}) +