import type { ParsedUrlQuery } from "node:querystring"; import { stringify } from "node:querystring"; import { SINGLE_ORG_SLUG } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import type { RedirectType } from "@calcom/prisma/client"; const log = logger.getSubLogger({ prefix: ["lib", "handleOrgRedirect"] }); type NextJsRedirect = { redirect: { permanent: false; /** * It could be a full URL or a relative path */ destination: string; }; }; function getSearchString(relativeOrAbsoluteUrl: string) { const url = new URL(relativeOrAbsoluteUrl, "http://localhost"); return url.search; } const getTemporaryOrgRedirect = async ({ slugs, redirectType, eventTypeSlug, currentQuery, useRelativePath = false, }: { slugs: string[]; redirectType: RedirectType; eventTypeSlug: string | null; currentQuery: ParsedUrlQuery; useRelativePath?: boolean; }): Promise => { const prisma = (await import("@calcom/prisma")).default; log.debug( `Looking for redirect for`, safeStringify({ slugs, redirectType, eventTypeSlug, }) ); const redirects = await prisma.tempOrgRedirect.findMany({ where: { type: redirectType, from: { in: slugs, }, fromOrgId: 0, }, }); if (!redirects.length) { return null; } // Use the first redirect origin as the new origin as we aren't supposed to handle different org usernames in a group const newOrigin = new URL(redirects[0].toUrl).origin; // Filter out any existing orgRedirection parameter to avoid duplicates // querystring.stringify transforms undefined values to empty strings, so we need to filter those out as well initially const filteredQuery = Object.entries(currentQuery).reduce((acc, [key, value]) => { if (key !== "orgRedirection" && value !== undefined) { acc[key] = value; } return acc; }, {} as ParsedUrlQuery); const currentQueryString = stringify(filteredQuery); const query = currentQueryString ? `?${currentQueryString}&orgRedirection=true` : "?orgRedirection=true"; // Use the same order as in input slugs - It is important from Dynamic Group perspective as the first user's settings are used for various things const newSlugs = slugs.map((slug) => { const redirect = redirects.find((redirect) => redirect.from === slug); if (!redirect) { return slug; } const newSlug = new URL(redirect.toUrl).pathname.slice(1); return newSlug; }); const newSlug = newSlugs.join("+"); const newPath = newSlug ? `/${newSlug}` : ""; // When in single org mode and not on actual org subdomain, use relative path to stay on same domain const newDestination = useRelativePath ? `${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}` : `${newOrigin}${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`; log.debug(`Suggesting redirect from ${slugs} to ${newDestination}`); return { redirect: { permanent: false, destination: newDestination, }, } as const; }; interface HandleOrgRedirectParams { slugs: string[]; redirectType: RedirectType; eventTypeSlug: string | null; context: { query: ParsedUrlQuery; }; currentOrgDomain: string | null; } /** * Handles organization redirects for both regular org context and SINGLE_ORG_SLUG mode * The redirect is required for all existing user links and team links to keep working when a user/team is moved to an organization * Example: * - User "john87" is added to organization "acme" and his username in the organization is "john". So, cal.com/john87 is redirected to cal.com/john * - Team "acme-sales" is added to organization "acme" and its slug in the organization is "sales". So, cal.com/acme-sales is redirected to cal.com/sales * * Returns a redirect object if a redirect is needed, null otherwise */ export async function handleOrgRedirect({ slugs, redirectType, eventTypeSlug, context, currentOrgDomain, }: HandleOrgRedirectParams) { const isOrgContext = !!currentOrgDomain; const isARedirectFromNonOrgLink = context.query.orgRedirection === "true"; // If we're not in an org context, the request could clearly be eligible for a redirect if (!isOrgContext) { const nextJsRedirect = await getTemporaryOrgRedirect({ slugs, redirectType, eventTypeSlug, currentQuery: context.query, }); if (nextJsRedirect) { return nextJsRedirect; } } const isSingleOrgMode = SINGLE_ORG_SLUG && isOrgContext; // When SINGLE_ORG_SLUG is set(which is possible in Self-Hosted instances), isOrgContext could be true even when the current domain is not an org subdomain // Example: my-instance.com could technically mean acme.my-instance.com where acme is the org slug // In such a case, the existing links which were on my-instance.com e.g. my-instance.com/john87 should be redirected to my-instance.com/john where john is the new username in Organization // This is why we need to follow redirect even when in Org Context if (!isSingleOrgMode) { return null; } // If already redirected from a non-org link, we shouldn't redirect again. Protects against infinite redirects if (isARedirectFromNonOrgLink) { return null; } // We're in single org mode but not on an actual org subdomain // Check if there's a redirect for this username // Use relative path since we want to stay on the same domain (my-instance.com) const nextJsRedirect = await getTemporaryOrgRedirect({ slugs, redirectType, eventTypeSlug, currentQuery: context.query, useRelativePath: true, }); return nextJsRedirect; } export async function getRedirectWithOriginAndSearchString({ slugs, redirectType, context, currentOrgDomain, }: Omit) { const nextJsRedirect = await handleOrgRedirect({ slugs, redirectType, eventTypeSlug: null, context, currentOrgDomain, }); if (!nextJsRedirect) { return null; } const newDestination = nextJsRedirect.redirect.destination; // If there is an origin use it, otherwise mark no new Origin and it remains the same domain const newOrigin = newDestination.startsWith("http://") || newDestination.startsWith("https://") ? new URL(newDestination).origin : null; return { origin: newOrigin, searchString: getSearchString(newDestination), }; }