Files
calendar/packages/lib/hashedLinksUtils.ts
T
Syed Ali ShahbazandGitHub 66b52bb19f feat: Enhance private link expiration with usage and date limits (#22304)
* init

* fix type

* fix a re-render infinite loop because of missing readOnly (╯°□°)╯︵ ┻━┻)

* further fixes

* improvement

* fix expiry datetime check

* remove unnecessary prismaMock def

* revert

* fix test

* add test ids

* remove unit tests in favor of e2e

* e2e test update

* fix e2e

* fix e2e

* remove unnecessary change

* abstract into injectable object

* further improvements

* fix label not selecting radio

* fix type

* code improvement

* DI implementation

* fix type

* fix quick copy

* code improvement and a few fixes

* further improvements and NITS

* further into DI

* select

* improve link list sorting

* prep for easier conflict resolution

* add back translations

* using useCopy instead

* improvement

* add index to update salt and have different hash generation

* fix private link description

* fix increment regression in expiry logic

* fixes

* address feedback

* use extractHostTimezone in event type listing

* remove unused function

* remove translationBundler

* -_-

* address feedback

* further changes

* address more feedback

* NIT

* address improvement suggestions

* use extractHostTimezone

* remove console log

* pre update

* code improvement

* further fixes

* cleanup

* -_-
2025-07-24 19:54:44 +01:00

146 lines
4.3 KiB
TypeScript

import dayjs from "@calcom/dayjs";
import { ErrorCode } from "@calcom/lib/errorCodes";
export type EventTypeForTimezone = {
userId?: number | null;
teamId?: number | null;
hosts?: Array<{
user: {
timeZone: string | null;
} | null;
}> | null;
owner?: {
timeZone: string | null;
} | null;
team?: {
members?: Array<{
user?: {
timeZone: string | null;
} | null;
}> | null;
} | null;
};
export type HashedLinkData = {
id: number;
expiresAt: Date | null;
maxUsageCount: number | null;
usageCount: number;
eventType: EventTypeForTimezone;
};
/**
* Extracts the host timezone from event type data
* @param eventType - Event type data with potential timezone information
* @returns Host timezone string or null if not found
*/
export function extractHostTimezone(eventType: EventTypeForTimezone): string {
if (eventType?.userId && eventType?.owner?.timeZone) {
// Personal event type - use owner's timezone
return eventType.owner.timeZone;
} else if (eventType?.teamId) {
// Team event type - try hosts first, then team members
if (eventType.hosts && eventType.hosts.length > 0 && eventType.hosts[0]?.user?.timeZone) {
return eventType.hosts[0].user.timeZone;
} else if (
eventType.team?.members &&
eventType.team.members.length > 0 &&
eventType.team.members[0]?.user?.timeZone
) {
return eventType.team.members[0]?.user?.timeZone;
}
}
return dayjs.tz.guess();
}
/**
* Core time-based expiration logic
* @param expiresAt - The expiration date
* @param timezone - Optional timezone for comparison
* @returns true if expired, false if valid
*/
function hasExpiryTimePassed(expiresAt: Date | string | null, timezone?: string | null): boolean {
if (!expiresAt) return false;
if (timezone) {
const now = dayjs().tz(timezone);
const expiration = dayjs(expiresAt).tz(timezone);
return expiration.isBefore(now);
} else {
const now = dayjs();
const expiration = dayjs(expiresAt);
return expiration.isBefore(now);
}
}
/**
* Validates if a hashed link has expired based on time
* @param expiresAt - The expiration date
* @param eventType - Event type data for timezone extraction
* @returns true if expired, false if valid
*/
export function isTimeBasedExpired(expiresAt: Date | null, eventType: EventTypeForTimezone): boolean {
const hostTimezone = extractHostTimezone(eventType);
return hasExpiryTimePassed(expiresAt, hostTimezone);
}
/**
* Validates if a hashed link has exceeded its usage limit
* @param usageCount - Current usage count
* @param maxUsageCount - Maximum allowed usage count
* @returns true if usage exceeded, false if valid
*/
export function isUsageBasedExpired(usageCount: number, maxUsageCount?: number | null): boolean {
if (!maxUsageCount || maxUsageCount <= 0) return false;
return usageCount >= maxUsageCount;
}
/**
* Validates hashed link data for expiration
* @param linkData - The hashed link data to validate
* @throws Error with ErrorCode.PrivateLinkExpired if link is expired
*/
export function validateHashedLinkData(linkData: HashedLinkData): void {
if (isTimeBasedExpired(linkData.expiresAt, linkData.eventType)) {
throw new Error(ErrorCode.PrivateLinkExpired);
}
if (isUsageBasedExpired(linkData.usageCount, linkData.maxUsageCount)) {
throw new Error(ErrorCode.PrivateLinkExpired);
}
}
/**
* Checks if a link has expired
* @param link - Link object with expiration and usage data
* @param timezone - Optional timezone for time-based expiration
* @returns true if expired, false if valid
*/
export function isLinkExpired(
link: {
expiresAt?: Date | string | null;
maxUsageCount?: number | null;
usageCount?: number | null;
},
timezone?: string | null
): boolean {
if (link.expiresAt) return hasExpiryTimePassed(link.expiresAt, timezone);
return isUsageBasedExpired(link.usageCount || 0, link.maxUsageCount);
}
/**
* filters out expired links
* @param links - Array of links to filter
* @param timezone - Optional timezone for time-based expiration
* @returns Array of active (non-expired) links
*/
export function filterActiveLinks<
T extends {
expiresAt?: Date | null;
maxUsageCount?: number | null;
usageCount?: number | null;
}
>(links: T[], timezone?: string | null): T[] {
return links.filter((link) => !isLinkExpired(link, timezone));
}