diff --git a/apps/web/getSubdomainRegExp.js b/apps/web/getSubdomainRegExp.js new file mode 100644 index 0000000000..33f38e150c --- /dev/null +++ b/apps/web/getSubdomainRegExp.js @@ -0,0 +1,15 @@ +const getDefaultSubdomain = (url) => { + if (!url.startsWith("http:") && !url.startsWith("https:")) { + // Make it a valid URL. Mabe we can simply return null and opt-out from orgs support till the use a URL scheme. + url = `https://${url}`; + } + const _url = new URL(url); + const regex = new RegExp(/^([a-z]+\:\/{2})?((?[\w-.]+)\.[\w-]+\.\w+)$/); + //console.log(_url.hostname, _url.hostname.match(regex)); + return _url.hostname.match(regex)?.groups?.subdomain || null; +}; +exports.getSubdomainRegExp = (url) => { + const defaultSubdomain = getDefaultSubdomain(url); + const subdomain = defaultSubdomain ? `(?!${defaultSubdomain})[^.]+` : "[^.]+"; + return subdomain; +}; diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 87882617fd..d9b5f80a90 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -5,6 +5,7 @@ const englishTranslation = require("./public/static/locales/en/common.json"); const { withAxiom } = require("next-axiom"); const { i18n } = require("./next-i18next.config"); const { pages } = require("./pages"); +const { getSubdomainRegExp } = require("./getSubdomainRegExp"); 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"); @@ -70,14 +71,6 @@ const informAboutDuplicateTranslations = () => { }; informAboutDuplicateTranslations(); - -const getSubdomain = () => { - const _url = new URL(process.env.NEXT_PUBLIC_WEBAPP_URL); - const regex = new RegExp(/^([a-z]+\:\/{2})?((?[\w-]+)\.[\w-]+\.\w+)$/); - //console.log(_url.hostname, _url.hostname.match(regex)); - return _url.hostname.match(regex)?.groups?.subdomain || null; -}; - const plugins = []; if (process.env.ANALYZE === "true") { // only load dependency if env `ANALYZE` was set @@ -102,6 +95,39 @@ 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"; +const subdomainRegExp = getSubdomainRegExp(process.env.NEXT_PUBLIC_WEBAPP_URL); +// Important Note: Do update the RegExp in apps/web/test/lib/next-config.test.ts when changing it. +const orgHostRegExp = `^(?${subdomainRegExp})\\..*`; + +const matcherConfigRootPath = { + has: [ + { + type: "host", + value: orgHostRegExp, + }, + ], + source: "/", +}; + +const matcherConfigOrgMemberPath = { + has: [ + { + type: "host", + value: orgHostRegExp, + }, + ], + source: `/:user((?!${pages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`, +}; + +const matcherConfigUserPath = { + has: [ + { + type: "host", + value: `^(?${subdomainRegExp}[^.]+)\\..*`, + }, + ], + source: `/:user((?!${pages.join("|")}|_next|public))/:path*`, +}; /** @type {import("next").NextConfig} */ const nextConfig = { @@ -193,40 +219,23 @@ const nextConfig = { return config; }, async rewrites() { - const defaultSubdomain = getSubdomain(); - const subdomain = defaultSubdomain ? `(?!${defaultSubdomain})[^.]+` : "[^.]+"; - const beforeFiles = [ - { - has: [ - { - type: "host", - value: `^(?${subdomain})\\..*`, - }, - ], - source: "/", - destination: "/team/:orgSlug", - }, - { - has: [ - { - type: "host", - value: `^(?${subdomain})\\..*`, - }, - ], - source: `/:user((?!${pages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`, - destination: "/org/:orgSlug/:user", - }, - { - has: [ - { - type: "host", - value: `^(?${subdomain}[^.]+)\\..*`, - }, - ], - source: `/:user((?!${pages.join("|")}|_next|public))/:path*`, - destination: "/:user/:path*", - }, + ...(process.env.ORGANIZATIONS_ENABLED + ? [ + { + ...matcherConfigRootPath, + destination: "/team/:orgSlug", + }, + { + ...matcherConfigOrgMemberPath, + destination: "/org/:orgSlug/:user", + }, + { + ...matcherConfigUserPath, + destination: "/:user/:path*", + }, + ] + : []), ]; let afterFiles = [ @@ -381,6 +390,35 @@ const nextConfig = { }, ], }, + ...[ + { + ...matcherConfigRootPath, + headers: [ + { + key: "X-Cal-Org-path", + value: "/team/:orgSlug", + }, + ], + }, + { + ...matcherConfigOrgMemberPath, + headers: [ + { + key: "X-Cal-Org-path", + value: "/org/:orgSlug/:user", + }, + ], + }, + { + ...matcherConfigUserPath, + headers: [ + { + key: "X-Cal-Org-path", + value: "/:user/:path", + }, + ], + }, + ], ]; }, async redirects() { diff --git a/apps/web/test/lib/next-config.test.ts b/apps/web/test/lib/next-config.test.ts index d58790c8eb..0e7acdb7aa 100644 --- a/apps/web/test/lib/next-config.test.ts +++ b/apps/web/test/lib/next-config.test.ts @@ -1,5 +1,7 @@ import { it, expect, describe, beforeAll, afterAll } from "vitest"; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { getSubdomainRegExp } = require("../../getSubdomainRegExp"); let userTypeRouteRegExp: RegExp; let teamTypeRouteRegExp:RegExp; let privateLinkRouteRegExp:RegExp @@ -161,3 +163,36 @@ describe('next.config.js - RegExp', ()=>{ }) }) + +describe('next.config.js - Org Rewrite', ()=> { + // RegExp copied from next.config.js + const orgHostRegExp = (subdomainRegExp:string)=> new RegExp(`^(?${subdomainRegExp})\\..*`) + describe('SubDomain Retrieval from NEXT_PUBLIC_WEBAPP_URL', ()=>{ + it('https://app.cal.com', ()=>{ + const subdomainRegExp = getSubdomainRegExp('https://app.cal.com'); + expect(orgHostRegExp(subdomainRegExp).exec('app.cal.com')).toEqual(null) + expect(orgHostRegExp(subdomainRegExp).exec('company.app.cal.com')?.groups?.orgSlug).toEqual('company') + expect(orgHostRegExp(subdomainRegExp).exec('org.cal.com')?.groups?.orgSlug).toEqual('org') + }) + + it('app.cal.com', ()=>{ + const subdomainRegExp = getSubdomainRegExp('app.cal.com'); + expect(orgHostRegExp(subdomainRegExp).exec('app.cal.com')).toEqual(null) + expect(orgHostRegExp(subdomainRegExp).exec('company.app.cal.com')?.groups?.orgSlug).toEqual('company') + }) + + it('https://calcom.app.company.com', ()=>{ + const subdomainRegExp = getSubdomainRegExp('https://calcom.app.company.com'); + expect(orgHostRegExp(subdomainRegExp).exec('calcom.app.company.com')).toEqual(null) + expect(orgHostRegExp(subdomainRegExp).exec('acme.calcom.app.company.com')?.groups?.orgSlug).toEqual('acme') + }) + + it('https://calcom.example.com', ()=>{ + const subdomainRegExp = getSubdomainRegExp('https://calcom.example.com'); + expect(orgHostRegExp(subdomainRegExp).exec('calcom.example.com')).toEqual(null) + expect(orgHostRegExp(subdomainRegExp).exec('acme.calcom.example.com')?.groups?.orgSlug).toEqual('acme') + // The following also matches which causes anything other than the domain in NEXT_PUBLIC_WEBAPP_URL to give 404 + expect(orgHostRegExp(subdomainRegExp).exec('some-other.company.com')?.groups?.orgSlug).toEqual('some-other') + }) + }) +}) \ No newline at end of file diff --git a/packages/lib/default-cookies.ts b/packages/lib/default-cookies.ts index 2198732dfc..bfd5a9b434 100644 --- a/packages/lib/default-cookies.ts +++ b/packages/lib/default-cookies.ts @@ -22,7 +22,8 @@ export function defaultCookies(useSecureCookies: boolean): CookiesOptions { const defaultOptions: CookieOption["options"] = { domain: isENVDev ? process.env.ORGANIZATIONS_ENABLED - ? ".cal.local" + ? //FIXME: This is causing login to not work if someone uses anything other .cal.local for testing + ".cal.local" : undefined : NEXTAUTH_COOKIE_DOMAIN, // To enable cookies on widgets,