* 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 * -_-
160 lines
5.2 KiB
TypeScript
160 lines
5.2 KiB
TypeScript
import { ErrorCode } from "@calcom/lib/errorCodes";
|
|
import { validateHashedLinkData } from "@calcom/lib/hashedLinksUtils";
|
|
|
|
import { HashedLinkRepository, type HashedLinkInputType } from "../repository/hashedLinkRepository";
|
|
import { MembershipService } from "./membershipService";
|
|
|
|
type NormalizedLink = {
|
|
link: string;
|
|
expiresAt: Date | null;
|
|
maxUsageCount?: number | null;
|
|
};
|
|
|
|
export class HashedLinkService {
|
|
constructor(
|
|
private readonly hashedLinkRepository: HashedLinkRepository = HashedLinkRepository.create(),
|
|
private readonly membershipService: MembershipService = new MembershipService()
|
|
) {}
|
|
|
|
/**
|
|
* Normalizes link input to a consistent format
|
|
* @param input - String link or object with link and options
|
|
* @returns Normalized link object
|
|
*/
|
|
private normalizeLinkInput(input: string | HashedLinkInputType): NormalizedLink {
|
|
return typeof input === "string"
|
|
? { link: input, expiresAt: null }
|
|
: {
|
|
link: input.link,
|
|
expiresAt: input.expiresAt ?? null,
|
|
maxUsageCount: input.maxUsageCount,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Handles multiple private links operations - orchestrates create, update, and delete
|
|
* @param eventTypeId - The ID of the event type
|
|
* @param multiplePrivateLinks - Array of links to create/update (can be strings or objects with expiration)
|
|
* @param connectedMultiplePrivateLinks - Array of existing link IDs for this event type
|
|
*/
|
|
async handleMultiplePrivateLinks({
|
|
eventTypeId,
|
|
multiplePrivateLinks,
|
|
connectedMultiplePrivateLinks,
|
|
}: {
|
|
eventTypeId: number;
|
|
multiplePrivateLinks?: (string | HashedLinkInputType)[];
|
|
connectedMultiplePrivateLinks: string[];
|
|
}) {
|
|
if (!eventTypeId || eventTypeId <= 0) {
|
|
throw new Error("Invalid event type ID");
|
|
}
|
|
|
|
if (!multiplePrivateLinks || multiplePrivateLinks.length === 0) {
|
|
await this.hashedLinkRepository.deleteLinks(eventTypeId, connectedMultiplePrivateLinks);
|
|
return;
|
|
}
|
|
|
|
const normalizedLinks = multiplePrivateLinks.map((input) => this.normalizeLinkInput(input));
|
|
const currentLinks = normalizedLinks.map((l) => l.link);
|
|
|
|
const currentLinksSet = new Set(currentLinks);
|
|
|
|
const linksToDelete = connectedMultiplePrivateLinks.filter((link) => !currentLinksSet.has(link));
|
|
await this.hashedLinkRepository.deleteLinks(eventTypeId, linksToDelete);
|
|
|
|
const existingLinksSet = new Set(connectedMultiplePrivateLinks);
|
|
|
|
for (const linkData of normalizedLinks) {
|
|
const exists = existingLinksSet.has(linkData.link);
|
|
if (!exists) {
|
|
await this.hashedLinkRepository.createLink(eventTypeId, linkData);
|
|
} else {
|
|
await this.hashedLinkRepository.updateLink(eventTypeId, linkData);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates a link without incrementing usage count
|
|
* Handles both time-based and usage-based expiration with timezone awareness
|
|
* @param linkId - The hashed link ID to validate
|
|
* @returns The validated link data
|
|
* @throws Error with ErrorCode.PrivateLinkExpired if link is expired or invalid
|
|
*/
|
|
async validate(linkId: string) {
|
|
if (!linkId || typeof linkId !== "string") {
|
|
throw new Error("Invalid link ID");
|
|
}
|
|
|
|
const hashedLink = await this.hashedLinkRepository.findLinkWithValidationData(linkId);
|
|
|
|
if (!hashedLink) {
|
|
throw new Error(ErrorCode.PrivateLinkExpired);
|
|
}
|
|
|
|
validateHashedLinkData(hashedLink);
|
|
|
|
return hashedLink;
|
|
}
|
|
|
|
/**
|
|
* Validates a link and increments usage count if valid
|
|
* Handles both time-based and usage-based expiration with timezone awareness
|
|
* @param linkId - The hashed link ID to validate
|
|
* @returns The validated link data
|
|
* @throws Error with ErrorCode.PrivateLinkExpired if link is expired or invalid
|
|
*/
|
|
async validateAndIncrementUsage(linkId: string) {
|
|
const hashedLink = await this.validate(linkId);
|
|
if (hashedLink.expiresAt) return hashedLink; // Time-based links don't need usage count increment
|
|
|
|
if (hashedLink.maxUsageCount && hashedLink.maxUsageCount > 0) {
|
|
try {
|
|
await this.hashedLinkRepository.incrementUsage(hashedLink.id, hashedLink.maxUsageCount);
|
|
} catch (updateError) {
|
|
throw new Error(ErrorCode.PrivateLinkExpired);
|
|
}
|
|
}
|
|
|
|
return hashedLink;
|
|
}
|
|
|
|
async findLinkWithDetails(linkId: string) {
|
|
const hashedLink = await this.hashedLinkRepository.findLinkWithDetails(linkId);
|
|
if (!hashedLink) {
|
|
return null;
|
|
}
|
|
|
|
return hashedLink;
|
|
}
|
|
|
|
/**
|
|
* Checks if a user has permission to access a link
|
|
* @param link - Link object with event type information
|
|
* @param userId - The user ID to check permissions for
|
|
* @returns true if user has permission, false otherwise
|
|
*/
|
|
async checkUserPermissionForLink(
|
|
link: { eventType: { teamId?: number | null; userId?: number | null } },
|
|
userId: number
|
|
): Promise<boolean> {
|
|
if (!link?.eventType || !userId || userId <= 0) {
|
|
return false;
|
|
}
|
|
|
|
// If it's a user event type, check if user owns it
|
|
if (link.eventType.userId) {
|
|
return link.eventType.userId === userId;
|
|
}
|
|
|
|
//No user ownership and no team - deny access
|
|
if (!link.eventType.teamId) {
|
|
return false;
|
|
}
|
|
|
|
const membership = await this.membershipService.checkMembership(link.eventType.teamId, userId);
|
|
return membership.isMember;
|
|
}
|
|
}
|