Files
calendar/apps/web/lib/handleOrgRedirect.ts
T

204 lines
6.4 KiB
TypeScript

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<NextJsRedirect | null> => {
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<HandleOrgRedirectParams, "eventTypeSlug">) {
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),
};
}