Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Single domain setup with one org #18383

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -415,3 +415,8 @@ REPLEXICA_API_KEY=

# Comma-separated list of DSyncData.directoryId to log SCIM API requests for. It can be enabled temporarily for debugging the requests being sent to SCIM server.
DIRECTORY_IDS_TO_LOG=


# Set this when Cal.com is used to serve only one organization's booking pages
# Read more about it in the README.md
NEXT_PUBLIC_SINGLE_ORG_SLUG=
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,13 @@ Don't code but still want to contribute? Join our [Discussions](https://github.c

- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.

## Single Org Mode
If you want to have booker.yourcompany.com to be the domain used for both dashboard(e.g. https://booker.yourcompany.com/event-types) and booking pages(e.g. https://booker.yourcompany.com/john.joe/15min).
- Set the `NEXT_PUBLIC_SINGLE_ORG_SLUG` environment variable to the slug of the organization you want to use. `NEXT_PUBLIC_SINGLE_ORG_SLUG=booker`
- Set the `NEXT_PUBLIC_WEBAPP_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBAPP_URL=https://booker.yourcompany.com`.
- Set the `NEXT_PUBLIC_WEBSITE_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBSITE_URL=https://booker.yourcompany.com`.
- Set the `NEXTAUTH_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXTAUTH_URL=https://booker.yourcompany.com`.

## Integrations

### Obtaining the Google API Credentials
Expand Down
46 changes: 46 additions & 0 deletions apps/web/getNextjsOrgRewriteConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const isSingleOrgModeEnabled = !!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG;
const orgSlugCaptureGroupName = "orgSlug";
/**
* Returns the leftmost subdomain from a given URL.
* It needs the URL domain to have atleast two dots.
* app.cal.com -> app
* app.company.cal.com -> app
* app.company.com -> app
*/
const getLeftMostSubdomain = (url) => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed getDefaultSubdomain -> getLeftMostSubdomain

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})?((?<subdomain>[\w-.]+)\.[\w-]+\.\w+)$/);
//console.log(_url.hostname, _url.hostname.match(regex));
return _url.hostname.match(regex)?.groups?.subdomain || null;
};

const getRegExpNotMatchingLeftMostSubdomain = (url) => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed getSubdomainRegExp -> getRegExpNotMatchingLeftMostSubdomain

const leftMostSubdomain = getLeftMostSubdomain(url);
const subdomain = leftMostSubdomain ? `(?!${leftMostSubdomain})[^.]+` : "[^.]+";
return subdomain;
};

// For app.cal.com, it will match all domains that are not starting with "app". Technically we would want to match domains like acme.cal.com, dunder.cal.com and not app.cal.com
const getRegExpThatMatchesAllOrgDomains = (exports.getRegExpThatMatchesAllOrgDomains = ({ webAppUrl }) => {
if (isSingleOrgModeEnabled) {
console.log("Single-Org-Mode enabled - Consider all domains to be org domains");
// It works in combination with next.config.js where in this case we use orgSlug=NEXT_PUBLIC_SINGLE_ORG_SLUG
return `.*`;
}
const subdomainRegExp = getRegExpNotMatchingLeftMostSubdomain(webAppUrl);
return `^(?<${orgSlugCaptureGroupName}>${subdomainRegExp})\\.(?!vercel\.app).*`;
});

const nextJsOrgRewriteConfig = {
// :orgSlug is special value which would get matching group from the regex in orgHostPath
orgSlug: process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG || `:${orgSlugCaptureGroupName}`,
orgHostPath: getRegExpThatMatchesAllOrgDomains({
webAppUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || `https://${process.env.VERCEL_URL}`,
}),
};

exports.nextJsOrgRewriteConfig = nextJsOrgRewriteConfig;
15 changes: 0 additions & 15 deletions apps/web/getSubdomainRegExp.js
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to getNextjsOrgRewriteConfig and made it more robust with better tests.

This file was deleted.

35 changes: 18 additions & 17 deletions apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ const { withSentryConfig } = require("@sentry/nextjs");
const { version } = require("./package.json");
const { i18n } = require("./next-i18next.config");
const {
orgHostPath,
nextJsOrgRewriteConfig,
orgUserRoutePath,
orgUserTypeRoutePath,
orgUserTypeEmbedRoutePath,
} = require("./pagesAndRewritePaths");

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");
const isOrganizationsEnabled =
Expand Down Expand Up @@ -124,7 +123,7 @@ const matcherConfigRootPath = {
has: [
{
type: "host",
value: orgHostPath,
value: nextJsOrgRewriteConfig.orgHostPath,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if Single org setup isn't enabled the regex here should remain same

},
],
source: "/",
Expand All @@ -134,7 +133,7 @@ const matcherConfigRootPathEmbed = {
has: [
{
type: "host",
value: orgHostPath,
value: nextJsOrgRewriteConfig.orgHostPath,
},
],
source: "/embed",
Expand All @@ -144,7 +143,7 @@ const matcherConfigUserRoute = {
has: [
{
type: "host",
value: orgHostPath,
value: nextJsOrgRewriteConfig.orgHostPath,
},
],
source: orgUserRoutePath,
Expand All @@ -154,7 +153,7 @@ const matcherConfigUserTypeRoute = {
has: [
{
type: "host",
value: orgHostPath,
value: nextJsOrgRewriteConfig.orgHostPath,
},
],
source: orgUserTypeRoutePath,
Expand All @@ -164,7 +163,7 @@ const matcherConfigUserTypeEmbedRoute = {
has: [
{
type: "host",
value: orgHostPath,
value: nextJsOrgRewriteConfig.orgHostPath,
},
],
source: orgUserTypeEmbedRoutePath,
Expand Down Expand Up @@ -287,6 +286,7 @@ const nextConfig = {
return config;
},
async rewrites() {
const { orgSlug } = nextJsOrgRewriteConfig;
const beforeFiles = [
{
source: "/forms/:formQuery*",
Expand Down Expand Up @@ -328,23 +328,23 @@ const nextConfig = {
? [
{
...matcherConfigRootPath,
destination: "/team/:orgSlug?isOrgProfile=1",
destination: `/team/${orgSlug}?isOrgProfile=1`,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if single org setup isn't enabled, orgSlug = ":orgSlug"

},
{
...matcherConfigRootPathEmbed,
destination: "/team/:orgSlug/embed?isOrgProfile=1",
destination: `/team/${orgSlug}/embed?isOrgProfile=1`,
},
{
...matcherConfigUserRoute,
destination: "/org/:orgSlug/:user",
destination: `/org/${orgSlug}/:user`,
},
{
...matcherConfigUserTypeRoute,
destination: "/org/:orgSlug/:user/:type",
destination: `/org/${orgSlug}/:user/:type`,
},
{
...matcherConfigUserTypeEmbedRoute,
destination: "/org/:orgSlug/:user/:type/embed",
destination: `/org/${orgSlug}/:user/:type/embed`,
},
]
: []),
Expand Down Expand Up @@ -392,6 +392,7 @@ const nextConfig = {
};
},
async headers() {
const { orgSlug } = nextJsOrgRewriteConfig;
// This header can be set safely as it ensures the browser will load the resources even when COEP is set.
// But this header must be set only on those resources that are safe to be loaded in a cross-origin context e.g. all embeddable pages's resources
const CORP_CROSS_ORIGIN_HEADER = {
Expand Down Expand Up @@ -478,7 +479,7 @@ const nextConfig = {
headers: [
{
key: "X-Cal-Org-path",
value: "/team/:orgSlug",
value: `/team/${orgSlug}`,
},
],
},
Expand All @@ -487,7 +488,7 @@ const nextConfig = {
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user",
value: `/org/${orgSlug}/:user`,
},
],
},
Expand All @@ -496,7 +497,7 @@ const nextConfig = {
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type",
value: `/org/${orgSlug}/:user/:type`,
},
],
},
Expand All @@ -505,7 +506,7 @@ const nextConfig = {
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type/embed",
value: `/org/${orgSlug}/:user/:type/embed`,
},
],
},
Expand Down Expand Up @@ -591,7 +592,7 @@ const nextConfig = {
{
type: "header",
key: "host",
value: orgHostPath,
value: nextJsOrgRewriteConfig.orgHostPath,
},
],
destination: "/event-types?openPlain=true",
Expand Down
7 changes: 2 additions & 5 deletions apps/web/pagesAndRewritePaths.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const glob = require("glob");
const { getSubdomainRegExp } = require("./getSubdomainRegExp");
const { nextJsOrgRewriteConfig } = require("./getNextjsOrgRewriteConfig");
/** Needed to rewrite public booking page, gets all static pages but [user] */
// Pages found here are excluded from redirects in beforeFiles in next.config.js
let pages = (exports.pages = glob
Expand Down Expand Up @@ -35,10 +35,7 @@ let pages = (exports.pages = glob
// [^/]+ 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

let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
process.env.NEXT_PUBLIC_WEBAPP_URL || `https://${process.env.VERCEL_URL}`
));
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
exports.nextJsOrgRewriteConfig = nextJsOrgRewriteConfig;

/**
* Returns a regex that matches all existing routes, virtual routes (like /forms, /router, /success etc) and nextjs special paths (_next, public)
Expand Down
89 changes: 51 additions & 38 deletions apps/web/test/lib/next-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { it, expect, describe, beforeAll } from "vitest";

// eslint-disable-next-line @typescript-eslint/no-var-requires
const { getSubdomainRegExp } = require("../../getSubdomainRegExp");
import { getRegExpThatMatchesAllOrgDomains } from "../../getNextjsOrgRewriteConfig";

// eslint-disable-next-line @typescript-eslint/no-var-requires
const { match, pathToRegexp } = require("next/dist/compiled/path-to-regexp");
type MatcherRes = (path: string) => { params: Record<string, string> };
Expand Down Expand Up @@ -30,51 +30,64 @@ beforeAll(async () => {
});

describe("next.config.js - Org Rewrite", () => {
const orgHostRegExp = (subdomainRegExp: string) =>
// RegExp copied from pagesAndRewritePaths.js orgHostPath. Do make the change there as well.
new RegExp(`^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`);

describe("Host matching based on 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");

expect(orgHostRegExp(subdomainRegExp).exec("localhost:3000")).toEqual(null);
describe("getRegExpThatMatchesAllOrgDomains", () => {
it("WEBAPP_URL=app.cal.com", () => {
const regExp = new RegExp(getRegExpThatMatchesAllOrgDomains({ webAppUrl: "app.cal.com" }));
expect(regExp.exec("acme.cal.com")?.groups?.orgSlug).toEqual("acme");
expect(regExp.exec("app.cal.com")).toEqual(null);
// Even though it matches abc. We shouldn't match it as it isn't a subdomain of cal.com(derived from WEBAPP_URL)
// We could fix the RegExp, but that might break some unexpected self-hosted scenarios. So, we can fix it separately.
expect(regExp.exec("abc.sdafasdf.com")?.groups?.orgSlug).toEqual("abc");
});

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("WEBAPP_URL=https://app.cal.com", () => {
const regExp = new RegExp(getRegExpThatMatchesAllOrgDomains({ webAppUrl: "https://app.cal.com" }));
expect(regExp.exec("acme.cal.com")?.groups?.orgSlug).toEqual("acme");
expect(regExp.exec("app.cal.com")).toEqual(null);

// This approach though not used by managed cal.com, but might be in use by self-hosted users.
expect(regExp.exec("acme.app.cal.com")?.groups?.orgSlug).toEqual("acme");

// TODO: Even though it gives abc orgSlug. We shouldn't match it as it isn't a subdomain of cal.com(derived from WEBAPP_URL)
// We could fix the RegExp, but that might break some unexpected self-hosted scenarios. So, we can fix it separately.
expect(regExp.exec("abc.sdafasdf.com")?.groups?.orgSlug).toEqual("abc");
});

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("WEBAPP_URL=https://booker.dashboard.company.com", () => {
const regExp = new RegExp(
getRegExpThatMatchesAllOrgDomains({ webAppUrl: "https://booker.dashboard.company.com" })
);

// This approach though not used by managed cal.com, but might be in use by self-hosted users.
expect(regExp.exec("acme.booker.dashboard.company.com")?.groups?.orgSlug).toEqual("acme");
expect(regExp.exec("booker.dashboard.company.com")).toEqual(null);
});

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"
it("WEBAPP_URL=http://app.cal.local:3000", () => {
const regExp = new RegExp(
getRegExpThatMatchesAllOrgDomains({ webAppUrl: "http://app.cal.local:3000" })
);
expect(regExp.exec("acme.cal.local:3000")?.groups?.orgSlug).toEqual("acme");
expect(regExp.exec("acme.app.cal.local:3000")?.groups?.orgSlug).toEqual("acme");
expect(regExp.exec("app.cal.local:3000")).toEqual(null);
});
it("Should ignore Vercel preview URLs", () => {
const subdomainRegExp = getSubdomainRegExp("https://cal-xxxxxxxx-cal.vercel.app");
expect(
orgHostRegExp(subdomainRegExp).exec("https://cal-xxxxxxxx-cal.vercel.app")
).toMatchInlineSnapshot("null");
expect(orgHostRegExp(subdomainRegExp).exec("cal-xxxxxxxx-cal.vercel.app")).toMatchInlineSnapshot(
"null"
);

it("Vercel Preview special handling - vercel.app. Cal.com deployed on vercel apps have different subdomains, so we can't consider them org domains", () => {
const regExp = new RegExp(getRegExpThatMatchesAllOrgDomains({ webAppUrl: "http://app.vercel.app" }));
// It is not matching on vercel.app but would have matched in any other case
expect(regExp.exec("acme.vercel.app")).toEqual(null);
expect(regExp.exec("app.vercel.app")).toEqual(null);
});

describe("NEXT_PUBLIC_SINGLE_ORG_MODE_ENABLED=1", () => {
process.env.NEXT_PUBLIC_SINGLE_ORG_MODE_ENABLED = "1";
it("WEBAPP_URL=http://app.cal.local:3000", () => {
const regExp = new RegExp(
getRegExpThatMatchesAllOrgDomains({ webAppUrl: "http://app.cal.local:3000" })
);
expect(regExp.exec("acme.cal.local:3000")?.groups?.orgSlug).toEqual("acme");
expect(regExp.exec("app.cal.local:3000")).toEqual(null);
});
});
});

Expand Down
3 changes: 2 additions & 1 deletion turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@
"VAPID_PRIVATE_KEY",
"HUDDLE01_API_TOKEN",
"REPLEXICA_API_KEY",
"DIRECTORY_IDS_TO_LOG"
"DIRECTORY_IDS_TO_LOG",
"NEXT_PUBLIC_SINGLE_ORG_SLUG"
]
}
Loading