diff --git a/apps/web/lib/api/links/process-link.ts b/apps/web/lib/api/links/process-link.ts index 9b772647865..dde50bb1274 100644 --- a/apps/web/lib/api/links/process-link.ts +++ b/apps/web/lib/api/links/process-link.ts @@ -1,5 +1,6 @@ import { isBlacklistedDomain } from "@/lib/edge-config"; import { verifyFolderAccess } from "@/lib/folder/permissions"; +import { validatePattern } from "@/lib/middleware/utils/match-rule"; import { checkIfUserExists, getRandomKey } from "@/lib/planetscale"; import { isNotHostedImage } from "@/lib/storage"; import { NewLinkProps, ProcessedLinkProps } from "@/lib/types"; @@ -72,6 +73,8 @@ export async function processLink>({ programId, webhookIds, testVariants, + isRule, + rulePattern, } = payload; let expiresAt: string | Date | null | undefined = payload.expiresAt; @@ -151,6 +154,36 @@ export async function processLink>({ }; } + // Redirect rule validation + if (isRule) { + if (!rulePattern) { + return { + link: payload, + error: "Rule pattern is required when creating a redirect rule.", + code: "bad_request", + }; + } + + const patternError = validatePattern(rulePattern); + if (patternError) { + return { + link: payload, + error: patternError, + code: "unprocessable_entity", + }; + } + + // Redirect rules require a Pro plan or higher + if (!workspace || workspace.plan === "free") { + return { + link: payload, + error: + "Redirect rules are only available on Pro plan and above. Upgrade to Pro to use this feature.", + code: "forbidden", + }; + } + } + const domains = workspace ? await prisma.domain.findMany({ where: { projectId: workspace.id }, diff --git a/apps/web/lib/api/links/rule-cache.ts b/apps/web/lib/api/links/rule-cache.ts new file mode 100644 index 00000000000..78809220cf7 --- /dev/null +++ b/apps/web/lib/api/links/rule-cache.ts @@ -0,0 +1,102 @@ +import { RedisLinkProps } from "@/lib/types"; +import { formatRedisLink, redis } from "@/lib/upstash"; +import { LRUCache } from "lru-cache"; +import { ExpandedLink } from "./utils/transform-link"; + +/** + * Cache for redirect rules (wildcard/pattern-based links). + * Rules are cached per domain to enable efficient pattern matching fallback + * when exact link matches are not found. + */ + +// LRU cache to reduce Redis load - max 500 domains with 30-second TTL +const ruleLRUCache = new LRUCache({ + max: 500, + ttl: 30000, // 30 seconds +}); + +// Redis cache expiration: 1 hour for rules +export const RULE_CACHE_EXPIRATION = 60 * 60; + +export type RedisRuleLink = RedisLinkProps & { + rulePattern: string; +}; + +class RuleCache { + /** + * Set all rules for a domain in cache + */ + async set(domain: string, rules: ExpandedLink[]) { + if (rules.length === 0) { + return; + } + + const redisRules = rules.map((rule) => ({ + ...formatRedisLink(rule), + rulePattern: rule.rulePattern!, + })); + + const cacheKey = this._createKey(domain); + + // Update LRU cache + ruleLRUCache.set(cacheKey, redisRules); + + // Store in Redis + return await redis.set(cacheKey, redisRules, { ex: RULE_CACHE_EXPIRATION }); + } + + /** + * Get all rules for a domain + */ + async get(domain: string): Promise { + const cacheKey = this._createKey(domain); + + // Check LRU cache first + let cachedRules = ruleLRUCache.get(cacheKey) as RedisRuleLink[] | undefined; + + if (cachedRules) { + console.log(`[Rule LRU Cache HIT] ${cacheKey}`); + return cachedRules; + } + + console.log(`[Rule LRU Cache MISS] ${cacheKey} - Checking Redis...`); + + try { + cachedRules = await redis.get(cacheKey); + + if (cachedRules) { + console.log( + `[Rule Redis Cache HIT] ${cacheKey} - Populating LRU cache...`, + ); + ruleLRUCache.set(cacheKey, cachedRules); + } + + return cachedRules || null; + } catch (error) { + console.error("[RuleCache] - Error getting rules from Redis:", error); + return null; + } + } + + /** + * Delete all rules for a domain (used when rules are updated) + */ + async delete(domain: string) { + const cacheKey = this._createKey(domain); + ruleLRUCache.delete(cacheKey); + return await redis.del(cacheKey); + } + + /** + * Invalidate cache when a rule is added/updated/deleted + */ + async invalidate(domain: string) { + return await this.delete(domain); + } + + _createKey(domain: string) { + return `rulecache:${domain.toLowerCase()}`; + } +} + +export const ruleCache = new RuleCache(); diff --git a/apps/web/lib/middleware/link.ts b/apps/web/lib/middleware/link.ts index 648f7d1e0ef..a86a986ab2f 100644 --- a/apps/web/lib/middleware/link.ts +++ b/apps/web/lib/middleware/link.ts @@ -21,7 +21,8 @@ import { import { linkCache } from "../api/links/cache"; import { isCaseSensitiveDomain } from "../api/links/case-sensitivity"; import { recordClickCache } from "../api/links/record-click-cache"; -import { getLinkViaEdge } from "../planetscale"; +import { ruleCache, RedisRuleLink } from "../api/links/rule-cache"; +import { getLinkViaEdge, getRulesViaEdge } from "../planetscale"; import { getPartnerEnrollmentInfo } from "../planetscale/get-partner-enrollment-info"; import { cacheDeepLinkClickData } from "./utils/cache-deeplink-click-data"; import { crawlBitly } from "./utils/crawl-bitly"; @@ -33,6 +34,7 @@ import { handleNotFoundLink } from "./utils/handle-not-found-link"; import { isIosAppStoreUrl } from "./utils/is-ios-app-store-url"; import { isSingularTrackingUrl } from "./utils/is-singular-tracking-url"; import { isSupportedCustomURIScheme } from "./utils/is-supported-custom-uri-scheme"; +import { matchRules, MatchResult } from "./utils/match-rule"; import { parse } from "./utils/parse"; import { resolveABTestURL } from "./utils/resolve-ab-test-url"; @@ -76,6 +78,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { let cachedLink = await linkCache.get({ domain, key }); let isPartnerLink = Boolean(cachedLink?.programId && cachedLink?.partnerId); + let ruleMatch: MatchResult | null = null; if (!cachedLink) { let linkData = await getLinkViaEdge({ @@ -84,38 +87,71 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { }); if (!linkData) { - if (domain === "buff.ly") { - return await crawlBitly(req); + // Try to match against redirect rules before returning 404 + let rules = await ruleCache.get(domain); + + if (!rules) { + // Fetch rules from database and cache them + const rulesData = await getRulesViaEdge(domain); + if (rulesData.length > 0) { + const formattedRules = rulesData.map((rule) => ({ + ...formatRedisLink(rule as any), + rulePattern: rule.rulePattern!, + })) as RedisRuleLink[]; + await ruleCache.set(domain, formattedRules as any); + rules = formattedRules; + } } - return await handleNotFoundLink(req); - } + if (rules && rules.length > 0) { + // Match the path against rules + const pathToMatch = `/${key}`; + ruleMatch = matchRules(pathToMatch, rules); + + if (ruleMatch) { + // Use the matched rule as the link + cachedLink = ruleMatch.rule; + isPartnerLink = Boolean( + cachedLink?.programId && cachedLink?.partnerId, + ); + } + } - isPartnerLink = Boolean(linkData.programId && linkData.partnerId); + // If still no match, return 404 + if (!cachedLink) { + if (domain === "buff.ly") { + return await crawlBitly(req); + } - // format link to fit the RedisLinkProps interface - cachedLink = formatRedisLink(linkData as any); + return await handleNotFoundLink(req); + } + } else { + isPartnerLink = Boolean(linkData.programId && linkData.partnerId); - ev.waitUntil( - (async () => { - if (!isPartnerLink) { - await linkCache.set(linkData as any); - return; - } + // format link to fit the RedisLinkProps interface + cachedLink = formatRedisLink(linkData as any); - const { partner, discount } = await getPartnerEnrollmentInfo({ - programId: linkData.programId, - partnerId: linkData.partnerId, - }); - - // we'll use this data on /track/click - await linkCache.set({ - ...(linkData as any), - ...(partner && { partner }), - ...(discount && { discount }), - }); - })(), - ); + ev.waitUntil( + (async () => { + if (!isPartnerLink) { + await linkCache.set(linkData as any); + return; + } + + const { partner, discount } = await getPartnerEnrollmentInfo({ + programId: linkData.programId, + partnerId: linkData.partnerId, + }); + + // we'll use this data on /track/click + await linkCache.set({ + ...(linkData as any), + ...(partner && { partner }), + ...(discount && { discount }), + }); + })(), + ); + } } const { @@ -262,6 +298,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { clickId, workspaceId, linkId, + aliasLinkId: ruleMatch?.matchedPath, domain, key, url, @@ -320,6 +357,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { clickId, workspaceId, linkId, + aliasLinkId: ruleMatch?.matchedPath, domain, key, url: finalUrl, @@ -356,6 +394,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { clickId, workspaceId, linkId, + aliasLinkId: ruleMatch?.matchedPath, domain, key, url: finalUrl, @@ -394,6 +433,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { clickId, workspaceId, linkId, + aliasLinkId: ruleMatch?.matchedPath, domain, key, url: finalUrl, @@ -463,6 +503,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { clickId, workspaceId, linkId, + aliasLinkId: ruleMatch?.matchedPath, domain, key, url: finalUrl, @@ -531,6 +572,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { clickId, workspaceId, linkId, + aliasLinkId: ruleMatch?.matchedPath, domain, key, url: finalUrl, diff --git a/apps/web/lib/middleware/utils/match-rule.ts b/apps/web/lib/middleware/utils/match-rule.ts new file mode 100644 index 00000000000..eb7f04d9fb7 --- /dev/null +++ b/apps/web/lib/middleware/utils/match-rule.ts @@ -0,0 +1,167 @@ +import { RedisRuleLink } from "../api/links/rule-cache"; + +/** + * Pattern matching for redirect rules. + * + * Supports two pattern syntaxes: + * 1. Wildcard: /blog/* matches /blog/anything, /blog/foo/bar + * 2. Named params: /docs/:slug matches /docs/intro, captures { slug: "intro" } + * + * Patterns are sorted by specificity (more specific patterns match first). + */ + +export interface MatchResult { + rule: RedisRuleLink; + params: Record; + matchedPath: string; +} + +/** + * Convert a user-friendly pattern to a regex + * Examples: + * /blog/* -> /^\/blog\/(.*)$/ + * /docs/:slug -> /^\/docs\/([^\/]+)$/ + * /api/:version/:endpoint/* -> /^\/api\/([^\/]+)\/([^\/]+)\/(.*)$/ + */ +export function patternToRegex(pattern: string): RegExp { + // Escape special regex characters except * and : + let regexStr = pattern + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + // Convert :param to capture group for single path segment + .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "([^/]+)") + // Convert /* to capture group for rest of path (must be at end) + .replace(/\/\*$/, "/(.*)"); + + // Handle standalone * at end + if (regexStr.endsWith("*")) { + regexStr = regexStr.slice(0, -1) + "(.*)"; + } + + return new RegExp(`^${regexStr}$`); +} + +/** + * Extract parameter names from a pattern + * Example: /docs/:slug/:section -> ["slug", "section"] + */ +export function extractParamNames(pattern: string): string[] { + const paramRegex = /:([a-zA-Z_][a-zA-Z0-9_]*)/g; + const names: string[] = []; + let match; + + while ((match = paramRegex.exec(pattern)) !== null) { + names.push(match[1]); + } + + // Add "rest" for wildcard captures + if (pattern.endsWith("/*") || pattern.endsWith("*")) { + names.push("rest"); + } + + return names; +} + +/** + * Calculate pattern specificity for sorting. + * Higher score = more specific = should match first. + * + * Scoring: + * - Each literal segment: +10 + * - Each :param segment: +5 + * - Wildcard at end: +1 + */ +export function getPatternSpecificity(pattern: string): number { + const segments = pattern.split("/").filter(Boolean); + let score = 0; + + for (const segment of segments) { + if (segment === "*") { + score += 1; + } else if (segment.startsWith(":")) { + score += 5; + } else { + score += 10; + } + } + + return score; +} + +/** + * Match a path against a set of rules. + * Returns the most specific matching rule, or null if no match. + */ +export function matchRules( + path: string, + rules: RedisRuleLink[], +): MatchResult | null { + // Sort rules by specificity (most specific first) + const sortedRules = [...rules].sort( + (a, b) => + getPatternSpecificity(b.rulePattern) - + getPatternSpecificity(a.rulePattern), + ); + + for (const rule of sortedRules) { + const regex = patternToRegex(rule.rulePattern); + const match = path.match(regex); + + if (match) { + const paramNames = extractParamNames(rule.rulePattern); + const params: Record = {}; + + // Map captured groups to param names + for (let i = 0; i < paramNames.length; i++) { + if (match[i + 1] !== undefined) { + params[paramNames[i]] = match[i + 1]; + } + } + + return { + rule, + params, + matchedPath: path, + }; + } + } + + return null; +} + +/** + * Validate a pattern syntax. + * Returns error message if invalid, null if valid. + */ +export function validatePattern(pattern: string): string | null { + if (!pattern) { + return "Pattern is required"; + } + + if (!pattern.startsWith("/")) { + return "Pattern must start with /"; + } + + // Check for invalid characters + if (/[<>{}]/.test(pattern)) { + return "Pattern contains invalid characters"; + } + + // Check that :params have valid names + const invalidParams = pattern.match(/:([^a-zA-Z_]|$)/g); + if (invalidParams) { + return "Invalid parameter name in pattern"; + } + + // Wildcards can only appear at the end + const wildcardIndex = pattern.indexOf("*"); + if (wildcardIndex !== -1 && wildcardIndex !== pattern.length - 1) { + return "Wildcard (*) can only appear at the end of a pattern"; + } + + try { + patternToRegex(pattern); + return null; + } catch { + return "Invalid pattern syntax"; + } +} diff --git a/apps/web/lib/planetscale/get-rules-via-edge.ts b/apps/web/lib/planetscale/get-rules-via-edge.ts new file mode 100644 index 00000000000..1e61efe88e9 --- /dev/null +++ b/apps/web/lib/planetscale/get-rules-via-edge.ts @@ -0,0 +1,20 @@ +import { conn } from "./connection"; +import { EdgeLinkProps } from "./types"; + +/** + * Get all redirect rules for a domain from the database. + * Rules are links with isRule = true. + */ +export const getRulesViaEdge = async (domain: string) => { + const { rows } = + (await conn.execute( + "SELECT * FROM Link WHERE domain = ? AND isRule = 1", + [domain], + )) || {}; + + if (!rows || !Array.isArray(rows) || rows.length === 0) { + return []; + } + + return rows as EdgeLinkProps[]; +}; diff --git a/apps/web/lib/planetscale/index.ts b/apps/web/lib/planetscale/index.ts index 54c4d6b3e08..3022b08cb45 100644 --- a/apps/web/lib/planetscale/index.ts +++ b/apps/web/lib/planetscale/index.ts @@ -3,6 +3,7 @@ export * from "./check-if-user-exists"; export * from "./connection"; export * from "./get-link-via-edge"; export * from "./get-random-key"; +export * from "./get-rules-via-edge"; export * from "./get-shortlink-via-edge"; export * from "./get-workspace-via-edge"; export * from "./types"; diff --git a/apps/web/lib/planetscale/types.ts b/apps/web/lib/planetscale/types.ts index 3c1fff53292..c8e11f158be 100644 --- a/apps/web/lib/planetscale/types.ts +++ b/apps/web/lib/planetscale/types.ts @@ -22,6 +22,8 @@ export interface EdgeLinkProps { trackConversion: boolean; programId: string | null; partnerId: string | null; + isRule: number; + rulePattern: string | null; } export interface EdgeDomainProps { diff --git a/apps/web/lib/tinybird/record-click.ts b/apps/web/lib/tinybird/record-click.ts index 12b6a6f93e0..b43a91a24cb 100644 --- a/apps/web/lib/tinybird/record-click.ts +++ b/apps/web/lib/tinybird/record-click.ts @@ -32,6 +32,7 @@ export async function recordClick({ clickId, workspaceId, linkId, + aliasLinkId, domain, key, url, @@ -47,6 +48,7 @@ export async function recordClick({ req: Request; clickId?: string; linkId: string; + aliasLinkId?: string; // For redirect rules: stores the matched path as a pseudo-link ID workspaceId?: string; domain: string; key: string; @@ -134,6 +136,7 @@ export async function recordClick({ click_id: clickId, workspace_id: workspaceId || "", link_id: linkId, + alias_link_id: aliasLinkId || null, // For redirect rules: the matched path domain, key, url: url || "", diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index b443db32122..c03244af994 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -203,6 +203,8 @@ export interface RedisLinkProps { >; testVariants?: z.infer; testCompletedAt?: Date; + isRule?: boolean; + rulePattern?: string; } export type ResourceColorsEnum = (typeof RESOURCE_COLORS)[number]; diff --git a/apps/web/lib/upstash/format-redis-link.ts b/apps/web/lib/upstash/format-redis-link.ts index 270804cdc48..61e4f529575 100644 --- a/apps/web/lib/upstash/format-redis-link.ts +++ b/apps/web/lib/upstash/format-redis-link.ts @@ -26,6 +26,8 @@ export function formatRedisLink(link: ExpandedLink): RedisLinkProps { discount, testVariants, testCompletedAt, + isRule, + rulePattern, } = link; const webhookIds = webhooks?.map(({ webhookId }) => webhookId) ?? []; @@ -76,5 +78,7 @@ export function formatRedisLink(link: ExpandedLink): RedisLinkProps { testVariants: testVariants as z.infer, testCompletedAt: new Date(testCompletedAt!), }), + ...(isRule && { isRule: true }), + ...(rulePattern && { rulePattern }), }; } diff --git a/apps/web/lib/zod/schemas/links.ts b/apps/web/lib/zod/schemas/links.ts index 1ff2800b6f8..bba4118fd44 100644 --- a/apps/web/lib/zod/schemas/links.ts +++ b/apps/web/lib/zod/schemas/links.ts @@ -521,6 +521,22 @@ export const createLinkBodySchema = z.object({ .nullish() .describe("The date and time when the tests were or will be completed."), + // Redirect rules (wildcard/pattern matching) + isRule: z + .boolean() + .optional() + .default(false) + .describe( + "Whether this link is a redirect rule. Redirect rules use pattern matching to redirect multiple URLs to a single destination.", + ), + rulePattern: z + .string() + .max(500) + .nullish() + .describe( + "The pattern to match for redirect rules. Supports wildcards (e.g., /blog/*) and named parameters (e.g., /docs/:slug). Required when isRule is true.", + ), + // deprecated fields publicStats: z .boolean() diff --git a/packages/prisma/schema/link.prisma b/packages/prisma/schema/link.prisma index ac6f6825313..4fd9310d79d 100644 --- a/packages/prisma/schema/link.prisma +++ b/packages/prisma/schema/link.prisma @@ -38,6 +38,10 @@ model Link { testStartedAt DateTime? // When tests were started testCompletedAt DateTime? // When tests were or will be completed + // Redirect Rules (wildcard/pattern matching) + isRule Boolean @default(false) // whether this link is a redirect rule + rulePattern String? @db.VarChar(500) // pattern to match (e.g. /blog/*, /docs/:slug) + // User who created the link user User? @relation(fields: [userId], references: [id]) userId String? @@ -99,6 +103,7 @@ model Link { @@index([programId, partnerId]) // for getting a referral link (programId + partnerId) @@index(partnerId) // for getting links by partnerId @@index([domain, createdAt]) // for bulk link deletion workflows (by domain) + deleting old short-lived links + @@index([domain, isRule]) // for fetching redirect rules by domain @@index(folderId) // used in /api/folders @@index(userId) // for relation to User table, used in /api/cron/cleanup/e2e-tests too @@index(partnerGroupDefaultLinkId)