Skip to content

Commit 3e3ee12

Browse files
authored
fix: implement URL fallback chain in redirect handler to prevent 500 errors (#17)
Links created via the API without explicit destination URLs would crash the redirect handler with `new URL(null)`. Now the redirect resolves URLs through a fallback chain: link → template defaults → workspace settings. - buildRedirectUrl() handles null/undefined URLs gracefully - Redirect query JOINs template settings and org settings - Returns 404 instead of 500 when no destination URL exists - Async click tracking uses the same fallback chain
1 parent b880ec6 commit 3e3ee12

2 files changed

Lines changed: 83 additions & 33 deletions

File tree

src/lib/utils.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -95,20 +95,27 @@ export function getLocationFromIP(ip: string) {
9595
}
9696

9797
export function buildRedirectUrl(
98-
originalUrl: string,
98+
originalUrl: string | null | undefined,
9999
utmParameters?: Record<string, string>
100-
): string {
101-
const url = new URL(originalUrl);
100+
): string | null {
101+
if (!originalUrl) return null;
102102

103-
if (utmParameters) {
104-
Object.entries(utmParameters).forEach(([key, value]) => {
105-
if (value) {
106-
url.searchParams.set(`utm_${key}`, value);
107-
}
108-
});
109-
}
103+
try {
104+
const url = new URL(originalUrl);
105+
106+
if (utmParameters) {
107+
Object.entries(utmParameters).forEach(([key, value]) => {
108+
if (value) {
109+
url.searchParams.set(`utm_${key}`, value);
110+
}
111+
});
112+
}
110113

111-
return url.toString();
114+
return url.toString();
115+
} catch {
116+
// If URL is invalid, return it as-is rather than crashing
117+
return originalUrl;
118+
}
112119
}
113120

114121
export function detectDevice(userAgent: string): 'ios' | 'android' | 'web' {

src/routes/redirect.ts

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -88,20 +88,27 @@ export async function redirectRoutes(fastify: FastifyInstance) {
8888

8989
if (templateSlug) {
9090
// Template-based URL: verify both template and link match
91+
// Also fetch template settings and org settings for URL fallback chain
9192
query = `
92-
SELECT l.* FROM links l
93+
SELECT l.*, t.settings AS template_settings, o.settings AS org_settings
94+
FROM links l
9395
LEFT JOIN link_templates t ON l.template_id = t.id
96+
LEFT JOIN organizations o ON l.organization_id = o.id
9497
WHERE l.short_code = $1 AND t.slug = $2
9598
AND l.is_active = true
9699
AND (l.expires_at IS NULL OR l.expires_at > NOW())
97100
`;
98101
params = [shortCode, templateSlug];
99102
} else {
100103
// Legacy URL: just lookup by short code
104+
// Also fetch template settings and org settings for URL fallback chain
101105
query = `
102-
SELECT * FROM links
103-
WHERE short_code = $1 AND is_active = true
104-
AND (expires_at IS NULL OR expires_at > NOW())
106+
SELECT l.*, t.settings AS template_settings, o.settings AS org_settings
107+
FROM links l
108+
LEFT JOIN link_templates t ON l.template_id = t.id
109+
LEFT JOIN organizations o ON l.organization_id = o.id
110+
WHERE l.short_code = $1 AND l.is_active = true
111+
AND (l.expires_at IS NULL OR l.expires_at > NOW())
105112
`;
106113
params = [shortCode];
107114
}
@@ -240,6 +247,14 @@ export async function redirectRoutes(fastify: FastifyInstance) {
240247
await storeFingerprintForClick(clickId, fingerprintData);
241248

242249
// Determine redirect URL for event emission (using same logic as main redirect)
250+
// Use the same fallback chain: link → template → workspace
251+
const tplSettings = link.template_settings || {};
252+
const oSettings = link.org_settings || {};
253+
const oAppConfig = oSettings.appConfig || {};
254+
const iosStoreUrl = link.ios_app_store_url || tplSettings.defaultIosUrl || oAppConfig.iosAppStoreUrl || null;
255+
const androidStoreUrl = link.android_app_store_url || tplSettings.defaultAndroidUrl || oAppConfig.androidAppStoreUrl || null;
256+
const webFallback = link.web_fallback_url || tplSettings.defaultWebFallbackUrl || oAppConfig.webFallbackUrl || null;
257+
243258
let redirectUrl = link.original_url;
244259
let redirectReason = 'original_url';
245260

@@ -250,9 +265,12 @@ export async function redirectRoutes(fastify: FastifyInstance) {
250265
} else if (link.app_scheme && link.deep_link_path) {
251266
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
252267
redirectReason = 'app_scheme';
253-
} else if (link.ios_app_store_url) {
254-
redirectUrl = link.ios_app_store_url;
268+
} else if (iosStoreUrl) {
269+
redirectUrl = iosStoreUrl;
255270
redirectReason = 'ios_app_store_url';
271+
} else if (webFallback) {
272+
redirectUrl = webFallback;
273+
redirectReason = 'web_fallback_url';
256274
}
257275
} else if (deviceType === 'android') {
258276
if (link.android_app_link) {
@@ -261,16 +279,19 @@ export async function redirectRoutes(fastify: FastifyInstance) {
261279
} else if (link.app_scheme && link.deep_link_path) {
262280
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
263281
redirectReason = 'app_scheme';
264-
} else if (link.android_app_store_url) {
265-
redirectUrl = link.android_app_store_url;
282+
} else if (androidStoreUrl) {
283+
redirectUrl = androidStoreUrl;
266284
redirectReason = 'android_app_store_url';
285+
} else if (webFallback) {
286+
redirectUrl = webFallback;
287+
redirectReason = 'web_fallback_url';
267288
}
268-
} else if (deviceType === 'web' && link.web_fallback_url) {
269-
redirectUrl = link.web_fallback_url;
289+
} else if (deviceType === 'web' && webFallback) {
290+
redirectUrl = webFallback;
270291
redirectReason = 'web_fallback_url';
271292
}
272293

273-
const finalRedirectUrl = buildRedirectUrl(redirectUrl, link.utm_parameters);
294+
const finalRedirectUrl = buildRedirectUrl(redirectUrl, link.utm_parameters) || redirectUrl;
274295

275296
// Emit click event for real-time streaming to WebSocket clients
276297
emitClickEvent({
@@ -342,57 +363,79 @@ export async function redirectRoutes(fastify: FastifyInstance) {
342363
});
343364

344365
// Determine redirect URL based on device with smart fallback chain
366+
// Fallback chain: link URLs → template default URLs → workspace settings URLs
345367
const userAgent = request.headers['user-agent'] || '';
346368
const device = detectDevice(userAgent);
347369

370+
// Extract fallback URLs from template settings and org settings
371+
const templateSettings = link.template_settings || {};
372+
const orgSettings = link.org_settings || {};
373+
const orgAppConfig = orgSettings.appConfig || {};
374+
375+
// Resolve platform URLs with fallback chain: link → template → workspace
376+
const iosUrl = link.ios_app_store_url || templateSettings.defaultIosUrl || orgAppConfig.iosAppStoreUrl || null;
377+
const androidUrl = link.android_app_store_url || templateSettings.defaultAndroidUrl || orgAppConfig.androidAppStoreUrl || null;
378+
const webFallbackUrl = link.web_fallback_url || templateSettings.defaultWebFallbackUrl || orgAppConfig.webFallbackUrl || null;
379+
348380
let redirectUrl = link.original_url;
349381
let useSchemeUrl = false; // Track if we're using a URI scheme URL
350382

351383
if (device === 'ios') {
352384
// iOS Priority:
353385
// 1. Universal Link (HTTPS URL with AASA file) - if app installed, opens app
354386
// 2. URI scheme (myapp://path) - fallback when Universal Links fail
355-
// 3. App Store URL - for users who don't have the app
356-
// 4. Original URL - ultimate fallback
387+
// 3. App Store URL (link → template → workspace) - for users who don't have the app
388+
// 4. Web fallback URL - browser-based fallback
389+
// 5. Original URL - ultimate fallback
357390

358391
if (link.ios_universal_link) {
359392
redirectUrl = link.ios_universal_link;
360393
} else if (link.app_scheme && link.deep_link_path) {
361394
// Build URI scheme URL: myapp://product/123
362395
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
363396
useSchemeUrl = true;
364-
} else if (link.ios_app_store_url) {
365-
redirectUrl = link.ios_app_store_url;
397+
} else if (iosUrl) {
398+
redirectUrl = iosUrl;
399+
} else if (webFallbackUrl) {
400+
redirectUrl = webFallbackUrl;
366401
}
367402

368403
} else if (device === 'android') {
369404
// Android Priority:
370405
// 1. App Link (HTTPS URL with Digital Asset Links) - if app installed, opens app
371406
// 2. URI scheme (myapp://path) - fallback when App Links fail
372-
// 3. Play Store URL - for users who don't have the app
373-
// 4. Original URL - ultimate fallback
407+
// 3. Play Store URL (link → template → workspace) - for users who don't have the app
408+
// 4. Web fallback URL - browser-based fallback
409+
// 5. Original URL - ultimate fallback
374410

375411
if (link.android_app_link) {
376412
redirectUrl = link.android_app_link;
377413
} else if (link.app_scheme && link.deep_link_path) {
378414
// Build URI scheme URL: myapp://product/123
379415
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
380416
useSchemeUrl = true;
381-
} else if (link.android_app_store_url) {
382-
redirectUrl = link.android_app_store_url;
417+
} else if (androidUrl) {
418+
redirectUrl = androidUrl;
419+
} else if (webFallbackUrl) {
420+
redirectUrl = webFallbackUrl;
383421
}
384422

385423
} else if (device === 'web') {
386424
// Web fallback
387-
redirectUrl = link.web_fallback_url || link.original_url;
425+
redirectUrl = webFallbackUrl || link.original_url;
426+
}
427+
428+
// If no URL found at all, return a user-friendly error
429+
if (!redirectUrl) {
430+
return reply.status(404).send({ error: 'No destination URL configured for this link' });
388431
}
389432

390433
// Build final URL with parameters
391434
let finalUrl = redirectUrl;
392435

393436
if (!useSchemeUrl) {
394437
// For HTTP(S) URLs, add UTM parameters
395-
finalUrl = buildRedirectUrl(redirectUrl, link.utm_parameters);
438+
finalUrl = buildRedirectUrl(redirectUrl, link.utm_parameters) || redirectUrl;
396439

397440
// Add deep link parameters as query params
398441
if (link.deep_link_parameters && Object.keys(link.deep_link_parameters).length > 0) {
@@ -435,7 +478,7 @@ export async function redirectRoutes(fastify: FastifyInstance) {
435478
fullSchemeUrl += (fullSchemeUrl.includes('?') ? '&' : '?') + params.toString();
436479
}
437480

438-
const storeFallback = link.ios_app_store_url || link.web_fallback_url || link.original_url;
481+
const storeFallback = iosUrl || webFallbackUrl || link.original_url;
439482
return reply
440483
.header('Content-Type', 'text/html; charset=utf-8')
441484
.send(generateInterstitialHTML(fullSchemeUrl, storeFallback, link.title || link.og_title));

0 commit comments

Comments
 (0)