import { randomUUID } from 'crypto'; import { FastifyInstance } from 'fastify'; import { db } from '../lib/database.js'; import { getClientIp } from '../lib/client-ip.js'; import { parseUserAgent, getLocationFromIP, buildRedirectUrl, detectDevice } from '../lib/utils.js'; import { storeFingerprintForClick, type FingerprintData } from '../lib/fingerprint.js'; import { emitClickEvent } from '../lib/event-emitter.js'; import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js'; /** * Detect iOS in-app browsers where Universal Links don't fire. * These browsers use WKWebView which bypasses the Universal Links mechanism. */ export function isIOSInAppBrowser(userAgent: string): boolean { const inAppPatterns = [ /GSA\//i, // Google Search App (Gmail in-app browser) /Gmail\//i, // Gmail /FBAN|FBAV/i, // Facebook /Instagram/i, // Instagram /Twitter/i, // Twitter/X /LinkedIn/i, // LinkedIn /MicroMessenger/i, // WeChat /Outlook/i, // Outlook /YahooMobile/i, // Yahoo Mail ]; return inAppPatterns.some(pattern => pattern.test(userAgent)); } /** * Detect Android in-app browsers where App Links don't fire. * These browsers use Android WebView (or app-specific webviews) that bypass * the App Link / Digital Asset Link mechanism. */ export function isAndroidInAppBrowser(userAgent: string): boolean { const inAppPatterns = [ /FB_IAB|FBAN|FBAV/i, // Facebook in-app browser /Instagram/i, /Line\//i, /KAKAOTALK/i, /Twitter/i, /LinkedIn/i, /MicroMessenger/i, // WeChat /Outlook-Android/i, /WhatsApp/i, /Pinterest/i, /Telegram/i, /Snapchat/i, /\swv\)/, // Generic Android WebView marker (e.g. "Mobile Safari/537.36; wv)") ]; return inAppPatterns.some(pattern => pattern.test(userAgent)); } /** * Pick the destination URL for a mobile click that has fallen through the * Universal Link / App Link / app_scheme priority steps. The choice depends on * whether the click is from an in-app browser: * * - Regular browser (Safari, Chrome): the OS-level UL/App Link check ran and * didn't fire, so the app must not be installed → prefer the App/Play Store URL. * * - In-app browser (Gmail, GSA, FB, Instagram, Outlook, etc.): UL is bypassed * regardless of install state, so we don't know if the app is installed → * prefer the web fallback URL, which gives the OS another chance to fire UL * if the fallback is on the app's UL/App-Link domain. * * Returns null if no URL is available (caller should fall back to original_url). */ export function pickMobileFallbackUrl( device: 'ios' | 'android', userAgent: string, iosUrl: string | null, androidUrl: string | null, webFallbackUrl: string | null, ): { url: string; reason: string } | null { const inApp = device === 'ios' ? isIOSInAppBrowser(userAgent) : isAndroidInAppBrowser(userAgent); const storeUrl = device === 'ios' ? iosUrl : androidUrl; const storeReason = device === 'ios' ? 'ios_app_store_url' : 'android_app_store_url'; if (inApp) { if (webFallbackUrl) return { url: webFallbackUrl, reason: 'web_fallback_url' }; if (storeUrl) return { url: storeUrl, reason: storeReason }; } else { if (storeUrl) return { url: storeUrl, reason: storeReason }; if (webFallbackUrl) return { url: webFallbackUrl, reason: 'web_fallback_url' }; } return null; } /** * Generate an interstitial HTML page that tries to open the app via custom scheme, * then falls back to the App Store / Play Store. * * The JavaScript reads the URL fragment (window.location.hash) and appends it * to the scheme URL. This preserves the E2E encryption key, which lives only * in the fragment and is never sent to the server. */ function generateInterstitialHTML(schemeUrl: string, fallbackUrl: string, title?: string): string { const safeSchemeUrl = schemeUrl.replace(/"/g, '"').replace(//g, '>'); return ` Opening ${safeTitle}...

Opening ${safeTitle}...

If the app doesn't open automatically:

Open App Download App
`; } export async function redirectRoutes(fastify: FastifyInstance) { // Helper function to handle the actual redirect logic async function handleRedirect(request: any, reply: any, shortCode: string, templateSlug?: string) { let linkData: string | null = null; // Build cache key (include template if present) const cacheKey = templateSlug ? `link:${templateSlug}:${shortCode}` : `link:${shortCode}`; // Try to get link from cache if Redis is available if (fastify.redis) { try { linkData = await fastify.redis.get(cacheKey); } catch (error) { fastify.log.warn('Redis cache lookup failed, falling back to database'); } } if (!linkData) { // Build query based on whether template slug is provided let query: string; let params: any[]; if (templateSlug) { // Template-based URL: verify both template and link match // Also fetch template settings and org settings for URL fallback chain query = ` SELECT l.*, t.settings AS template_settings, o.settings AS org_settings FROM links l LEFT JOIN link_templates t ON l.template_id = t.id LEFT JOIN organizations o ON l.organization_id = o.id WHERE l.short_code = $1 AND t.slug = $2 AND l.is_active = true AND (l.expires_at IS NULL OR l.expires_at > NOW()) `; params = [shortCode, templateSlug]; } else { // Legacy URL: just lookup by short code // Also fetch template settings and org settings for URL fallback chain query = ` SELECT l.*, t.settings AS template_settings, o.settings AS org_settings FROM links l LEFT JOIN link_templates t ON l.template_id = t.id LEFT JOIN organizations o ON l.organization_id = o.id WHERE l.short_code = $1 AND l.is_active = true AND (l.expires_at IS NULL OR l.expires_at > NOW()) `; params = [shortCode]; } const result = await db.query(query, params); if (result.rows.length === 0) { return reply.status(404).send({ error: 'Link not found' }); } linkData = JSON.stringify(result.rows[0]); // Cache for 5 minutes if Redis is available if (fastify.redis) { try { await fastify.redis.setex(cacheKey, 300, linkData); } catch (error) { fastify.log.warn('Redis cache set failed'); } } } const link = JSON.parse(linkData); // Check targeting rules BEFORE redirecting if (link.targeting_rules) { const userAgent = request.headers['user-agent'] || ''; const ip = getClientIp(request); const acceptLanguage = request.headers['accept-language'] || ''; // Get user's actual data for targeting checks const device = detectDevice(userAgent); const { countryCode } = getLocationFromIP(ip); // Extract primary language from accept-language header (e.g., "en-US,en;q=0.9" -> "en") const primaryLanguage = acceptLanguage.split(',')[0]?.split('-')[0]?.toLowerCase(); const rules = link.targeting_rules; let isTargeted = true; // Check country targeting if (rules.countries && rules.countries.length > 0) { const targetCountries = rules.countries.map((c: string) => c.toUpperCase()); if (!countryCode || !targetCountries.includes(countryCode.toUpperCase())) { isTargeted = false; } } // Check device targeting if (rules.devices && rules.devices.length > 0) { if (!rules.devices.includes(device)) { isTargeted = false; } } // Check language targeting if (rules.languages && rules.languages.length > 0) { const targetLanguages = rules.languages.map((l: string) => l.toLowerCase()); if (!primaryLanguage || !targetLanguages.includes(primaryLanguage)) { isTargeted = false; } } // If targeting rules exist but user doesn't match, return 404 if (!isTargeted) { return reply.status(404).send({ error: 'Link not found' }); } } // Generate the click id up front (rather than letting the DB default it on // insert) so the synchronous redirect below can carry it on the destination // URL while the click row is still written asynchronously with the same id. const clickId = randomUUID(); // Track click asynchronously setImmediate(async () => { try { const userAgent = request.headers['user-agent'] || ''; const ip = getClientIp(request); const referrer = request.headers.referer || null; const acceptLanguage = request.headers['accept-language'] || ''; const deviceType = detectDevice(userAgent); const { platform, platformVersion } = parseUserAgent(userAgent); const { countryCode, countryName, region, city, latitude, longitude, timezone } = getLocationFromIP(ip); // Classify bots at ingestion (SIT-298) — persisted on the row so // analytics reads a consistent flag instead of re-detecting from the // stored user-agent. const { isBot, reason: botReason } = classifyBot( userAgent, request.method, edgeBotSignal(request.headers['x-lf-bot']) ); // Extract UTM parameters from query string const query = request.query as Record; const utmSource = query?.utm_source; const utmMedium = query?.utm_medium; const utmCampaign = query?.utm_campaign; // Extract fingerprint data from query params (sent by SDK/client) const fpTimezone = query?.fp_tz || timezone || undefined; const fpLanguage = query?.fp_lang || acceptLanguage.split(',')[0]?.split(';')[0] || undefined; const fpScreenWidth = query?.fp_sw ? parseInt(query.fp_sw, 10) : undefined; const fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined; const fpPlatform = query?.fp_platform || deviceType; const fpPlatformVersion = query?.fp_pv || platformVersion; // Insert click event with the pre-generated id (see above) so the row // matches the lf_click value already placed on the redirect URL. await db.query( `INSERT INTO click_events ( id, link_id, ip_address, user_agent, device_type, platform, country_code, country_name, region, city, latitude, longitude, timezone, utm_source, utm_medium, utm_campaign, referrer, is_bot, bot_reason ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`, [ clickId, link.id, ip, userAgent, deviceType, platform, countryCode, countryName, region, city, latitude, longitude, timezone, utmSource, utmMedium, utmCampaign, referrer, isBot, botReason, ] ); // Store device fingerprint for deferred deep linking const fingerprintData: FingerprintData = { ipAddress: ip, userAgent, timezone: fpTimezone, language: fpLanguage, screenWidth: fpScreenWidth, screenHeight: fpScreenHeight, platform: fpPlatform, platformVersion: fpPlatformVersion, }; await storeFingerprintForClick(clickId, fingerprintData); // Determine redirect URL for event emission (using same logic as main redirect) // Use the same fallback chain: link → template → workspace const tplSettings = link.template_settings || {}; const oSettings = link.org_settings || {}; const oAppConfig = oSettings.appConfig || {}; const iosStoreUrl = link.ios_app_store_url || tplSettings.defaultIosUrl || oAppConfig.iosAppStoreUrl || null; const androidStoreUrl = link.android_app_store_url || tplSettings.defaultAndroidUrl || oAppConfig.androidAppStoreUrl || null; const webFallback = link.web_fallback_url || tplSettings.defaultWebFallbackUrl || oAppConfig.webFallbackUrl || null; let redirectUrl = link.original_url; let redirectReason = 'original_url'; if (deviceType === 'ios') { if (link.ios_universal_link) { redirectUrl = link.ios_universal_link; redirectReason = 'ios_universal_link'; } else if (link.app_scheme && link.deep_link_path) { redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`; redirectReason = 'app_scheme'; } else { const fb = pickMobileFallbackUrl('ios', userAgent, iosStoreUrl, androidStoreUrl, webFallback); if (fb) { redirectUrl = fb.url; redirectReason = fb.reason; } } } else if (deviceType === 'android') { if (link.android_app_link) { redirectUrl = link.android_app_link; redirectReason = 'android_app_link'; } else if (link.app_scheme && link.deep_link_path) { redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`; redirectReason = 'app_scheme'; } else { const fb = pickMobileFallbackUrl('android', userAgent, iosStoreUrl, androidStoreUrl, webFallback); if (fb) { redirectUrl = fb.url; redirectReason = fb.reason; } } } else if (deviceType === 'web' && webFallback) { redirectUrl = webFallback; redirectReason = 'web_fallback_url'; } const finalRedirectUrl = buildRedirectUrl(redirectUrl, link.utm_parameters) || redirectUrl; // Emit click event for real-time streaming to WebSocket clients emitClickEvent({ eventId: clickId, timestamp: new Date().toISOString(), linkId: link.id, shortCode: link.short_code, userId: link.user_id, ipAddress: ip, userAgent, country: countryCode || undefined, city: city || undefined, deviceType, platform: platform || undefined, redirectUrl: finalRedirectUrl, redirectReason, targetingMatched: true, // If we got here, targeting matched utmParameters: link.utm_parameters || undefined, referer: referrer || undefined, language: fpLanguage, }); // Trigger webhooks for click_event try { const webhooksResult = await db.query( 'SELECT * FROM webhooks WHERE user_id = $1 AND is_active = true', [link.user_id] ); if (webhooksResult.rows.length > 0) { const { triggerWebhooks } = await import('../lib/webhook.js'); const clickEventData = { id: clickId, linkId: link.id, clickedAt: new Date().toISOString(), ipAddress: ip, userAgent, deviceType, platform, countryCode, countryName, region, city, latitude, longitude, timezone, utmSource, utmMedium, utmCampaign, referrer, }; // Trigger webhooks without delivery logging (basic version) // For delivery logging, use @linkforty/cloud premium features await triggerWebhooks( webhooksResult.rows, 'click_event', clickId, clickEventData ); } } catch (webhookError) { fastify.log.error(`Error triggering click webhooks: ${webhookError}`); } } catch (error) { fastify.log.error(`Error tracking click: ${error}`); } }); // Determine redirect URL based on device with smart fallback chain // Fallback chain: link URLs → template default URLs → workspace settings URLs const userAgent = request.headers['user-agent'] || ''; const device = detectDevice(userAgent); // Extract fallback URLs from template settings and org settings const templateSettings = link.template_settings || {}; const orgSettings = link.org_settings || {}; const orgAppConfig = orgSettings.appConfig || {}; // Resolve platform URLs with fallback chain: link → template → workspace const iosUrl = link.ios_app_store_url || templateSettings.defaultIosUrl || orgAppConfig.iosAppStoreUrl || null; const androidUrl = link.android_app_store_url || templateSettings.defaultAndroidUrl || orgAppConfig.androidAppStoreUrl || null; const webFallbackUrl = link.web_fallback_url || templateSettings.defaultWebFallbackUrl || orgAppConfig.webFallbackUrl || null; let redirectUrl = link.original_url; let useSchemeUrl = false; // Track if we're using a URI scheme URL if (device === 'ios') { // iOS Priority: // 1. Universal Link (HTTPS URL with AASA file) — if app installed, OS opens app // (this branch only runs when UL didn't fire upstream, e.g. in-app browser) // 2. URI scheme (myapp://path) — explicit deep link // 3. Mobile fallback (browser-aware): // - regular browser: App Store URL > web fallback URL // (UL would have fired if app installed, so app is not installed) // - in-app browser: web fallback URL > App Store URL // (UL was bypassed; web fallback gives UL a second chance to fire) // 4. Original URL — ultimate fallback if (link.ios_universal_link) { redirectUrl = link.ios_universal_link; } else if (link.app_scheme && link.deep_link_path) { // Build URI scheme URL: myapp://product/123 redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`; useSchemeUrl = true; } else { const fb = pickMobileFallbackUrl('ios', userAgent, iosUrl, androidUrl, webFallbackUrl); if (fb) redirectUrl = fb.url; } } else if (device === 'android') { // Android Priority — same logic as iOS, with android_app_link in place of UL if (link.android_app_link) { redirectUrl = link.android_app_link; } else if (link.app_scheme && link.deep_link_path) { // Build URI scheme URL: myapp://product/123 redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`; useSchemeUrl = true; } else { const fb = pickMobileFallbackUrl('android', userAgent, iosUrl, androidUrl, webFallbackUrl); if (fb) redirectUrl = fb.url; } } else if (device === 'web') { // Web fallback redirectUrl = webFallbackUrl || link.original_url; } // If no URL found at all, return a user-friendly error if (!redirectUrl) { return reply.status(404).send({ error: 'No destination URL configured for this link' }); } // Build final URL with parameters let finalUrl = redirectUrl; if (!useSchemeUrl) { // For HTTP(S) URLs, add UTM parameters finalUrl = buildRedirectUrl(redirectUrl, link.utm_parameters) || redirectUrl; // Add deep link parameters as query params if (link.deep_link_parameters && Object.keys(link.deep_link_parameters).length > 0) { try { const url = new URL(finalUrl); Object.entries(link.deep_link_parameters).forEach(([key, value]) => { url.searchParams.set(key, String(value)); }); finalUrl = url.toString(); } catch (error) { // If URL parsing fails, continue without deep link parameters console.error('Failed to add deep link parameters:', error); } } // When opted in per link (append_click_id), append the originating click id // so a downstream analytics tool on the landing page can correlate the // landing visit to this exact click. Opt-in (default off), web/HTTPS only — // an absent/false flag (incl. stale cache) leaves the destination untouched. if (link.append_click_id === true) { try { const url = new URL(finalUrl); url.searchParams.set('lf_click', clickId); finalUrl = url.toString(); } catch { // Non-absolute / unparseable URL — skip the correlation param. } } } else { // For URI scheme URLs, append query params differently if (link.deep_link_parameters && Object.keys(link.deep_link_parameters).length > 0) { const params = new URLSearchParams( Object.entries(link.deep_link_parameters).map(([k, v]) => [k, String(v)] as [string, string]) ); finalUrl += `?${params.toString()}`; } } // Serve an interstitial page for mobile requests when a custom scheme is available. // The interstitial tries to open the app via URI scheme, then falls back to the store. // This works for both in-app browsers (where Universal Links don't fire) and regular // browsers (where a 302 to a custom scheme fails silently if the app isn't installed). // The interstitial JavaScript preserves the URL fragment (E2E encryption key). if ((device === 'ios' || device === 'android') && link.app_scheme) { const deepPath = link.deep_link_path ? link.deep_link_path.replace(/^\//, '') : ''; const schemeUrl = link.custom_scheme_url || `${link.app_scheme}://${deepPath}`; // The interstitial JS tries the scheme first; storeFallback is what we // navigate to if the scheme doesn't open the app within ~1.5s. Pick it // browser-aware: regular browsers prefer the store URL, in-app browsers // prefer the web fallback (gives UL a second chance to fire). const fb = pickMobileFallbackUrl(device, userAgent, iosUrl, androidUrl, webFallbackUrl); const storeFallback = fb?.url || link.original_url; if (storeFallback) { let fullSchemeUrl = schemeUrl; if (link.deep_link_parameters && Object.keys(link.deep_link_parameters).length > 0) { const params = new URLSearchParams( Object.entries(link.deep_link_parameters).map(([k, v]: [string, any]) => [k, String(v)] as [string, string]) ); fullSchemeUrl += (fullSchemeUrl.includes('?') ? '&' : '?') + params.toString(); } return reply .header('Content-Type', 'text/html; charset=utf-8') .send(generateInterstitialHTML(fullSchemeUrl, storeFallback, link.title || link.og_title)); } } // Redirect return reply.redirect(302, finalUrl); } // Template-based shortlink route: /:templateSlug/:shortCode fastify.get('/:templateSlug/:shortCode', async (request, reply) => { const { templateSlug, shortCode } = request.params as { templateSlug: string; shortCode: string }; return handleRedirect(request, reply, shortCode, templateSlug); }); // Legacy shortlink route (no template): /:shortCode fastify.get('/:shortCode', async (request, reply) => { const { shortCode } = request.params as { shortCode: string }; return handleRedirect(request, reply, shortCode); }); }