Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/web/lib/api/links/process-link.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -72,6 +73,8 @@ export async function processLink<T extends Record<string, any>>({
programId,
webhookIds,
testVariants,
isRule,
rulePattern,
} = payload;

let expiresAt: string | Date | null | undefined = payload.expiresAt;
Expand Down Expand Up @@ -151,6 +154,36 @@ export async function processLink<T extends Record<string, any>>({
};
}

// 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 },
Expand Down
102 changes: 102 additions & 0 deletions apps/web/lib/api/links/rule-cache.ts
Original file line number Diff line number Diff line change
@@ -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<string, RedisLinkProps[]>({
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<RedisRuleLink[] | null> {
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<RedisRuleLink[]>(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();
96 changes: 69 additions & 27 deletions apps/web/lib/middleware/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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({
Expand All @@ -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 {
Expand Down Expand Up @@ -262,6 +298,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
clickId,
workspaceId,
linkId,
aliasLinkId: ruleMatch?.matchedPath,
domain,
key,
url,
Expand Down Expand Up @@ -320,6 +357,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
clickId,
workspaceId,
linkId,
aliasLinkId: ruleMatch?.matchedPath,
domain,
key,
url: finalUrl,
Expand Down Expand Up @@ -356,6 +394,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
clickId,
workspaceId,
linkId,
aliasLinkId: ruleMatch?.matchedPath,
domain,
key,
url: finalUrl,
Expand Down Expand Up @@ -394,6 +433,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
clickId,
workspaceId,
linkId,
aliasLinkId: ruleMatch?.matchedPath,
domain,
key,
url: finalUrl,
Expand Down Expand Up @@ -463,6 +503,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
clickId,
workspaceId,
linkId,
aliasLinkId: ruleMatch?.matchedPath,
domain,
key,
url: finalUrl,
Expand Down Expand Up @@ -531,6 +572,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
clickId,
workspaceId,
linkId,
aliasLinkId: ruleMatch?.matchedPath,
domain,
key,
url: finalUrl,
Expand Down
Loading