This commit is contained in:
2026-07-31 11:44:56 +09:00
parent 7dc38641b2
commit d338b91e42
32 changed files with 7323 additions and 0 deletions
+233
View File
@@ -0,0 +1,233 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { db } from '../lib/database.js';
export async function analyticsRoutes(fastify: FastifyInstance) {
// Get overall analytics (optionally filtered by userId)
fastify.get('/api/analytics/overview', async (request: FastifyRequest<{
Querystring: { userId?: string; days?: number }
}>) => {
const { userId, days = 30 } = request.query;
const userFilter = userId ? 'AND l.user_id = $1' : '';
const userFilterWhere = userId ? 'WHERE l.user_id = $1' : '';
const params = userId ? [userId] : [];
// Get total and unique clicks
const clicksResult = await db.query(
`SELECT
COUNT(*) as total_clicks,
COUNT(DISTINCT ip_address) as unique_clicks
FROM click_events ce
JOIN links l ON ce.link_id = l.id
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false`,
params
);
// Get clicks by date
const dateResult = await db.query(
`SELECT
DATE(ce.clicked_at) as date,
COUNT(*) as clicks
FROM click_events ce
JOIN links l ON ce.link_id = l.id
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false
GROUP BY DATE(ce.clicked_at)
ORDER BY date`,
params
);
// Get clicks by country
const countryResult = await db.query(
`SELECT
COALESCE(ce.country_code, 'Unknown') as country_code,
COALESCE(ce.country_name, ce.country_code, 'Unknown') as country,
COUNT(*) as clicks
FROM click_events ce
JOIN links l ON ce.link_id = l.id
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false
GROUP BY ce.country_code, ce.country_name
ORDER BY clicks DESC`,
params
);
// Get clicks by device
const deviceResult = await db.query(
`SELECT
COALESCE(ce.device_type, 'Unknown') as device,
COUNT(*) as clicks
FROM click_events ce
JOIN links l ON ce.link_id = l.id
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false
GROUP BY ce.device_type
ORDER BY clicks DESC`,
params
);
// Get clicks by platform
const platformResult = await db.query(
`SELECT
COALESCE(ce.platform, 'Unknown') as platform,
COUNT(*) as clicks
FROM click_events ce
JOIN links l ON ce.link_id = l.id
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false
GROUP BY ce.platform
ORDER BY clicks DESC`,
params
);
// Get top performing links
const topLinksResult = await db.query(
`SELECT
l.id,
l.short_code,
l.title,
l.original_url,
COUNT(ce.id) as total_clicks,
COUNT(DISTINCT ce.ip_address) as unique_clicks
FROM links l
LEFT JOIN click_events ce ON l.id = ce.link_id
AND ce.clicked_at >= NOW() - INTERVAL '${days} days'
AND ce.is_bot = false
${userFilterWhere}
GROUP BY l.id
ORDER BY total_clicks DESC
LIMIT 10`,
params
);
return {
totalClicks: parseInt(clicksResult.rows[0]?.total_clicks || '0'),
uniqueClicks: parseInt(clicksResult.rows[0]?.unique_clicks || '0'),
clicksByDate: dateResult.rows.map(row => ({
date: row.date,
clicks: parseInt(row.clicks),
})),
clicksByCountry: countryResult.rows.map(row => ({
countryCode: row.country_code,
country: row.country,
clicks: parseInt(row.clicks),
})),
clicksByDevice: deviceResult.rows.map(row => ({
device: row.device,
clicks: parseInt(row.clicks),
})),
clicksByPlatform: platformResult.rows.map(row => ({
platform: row.platform,
clicks: parseInt(row.clicks),
})),
topLinks: topLinksResult.rows.map(row => ({
id: row.id,
shortCode: row.short_code,
title: row.title,
originalUrl: row.original_url,
totalClicks: parseInt(row.total_clicks),
uniqueClicks: parseInt(row.unique_clicks),
})),
};
});
// Get link-specific analytics
fastify.get('/api/analytics/links/:linkId', async (request: FastifyRequest<{
Params: { linkId: string };
Querystring: { userId?: string; days?: number };
}>) => {
const { linkId } = request.params;
const { userId, days = 30 } = request.query;
// Verify link exists (and ownership if userId provided)
let linkResult;
if (userId) {
linkResult = await db.query(
'SELECT id FROM links WHERE id = $1 AND user_id = $2',
[linkId, userId]
);
} else {
linkResult = await db.query(
'SELECT id FROM links WHERE id = $1',
[linkId]
);
}
if (linkResult.rows.length === 0) {
throw new Error('Link not found');
}
// Get analytics for specific link
const clicksResult = await db.query(
`SELECT
COUNT(*) as total_clicks,
COUNT(DISTINCT ip_address) as unique_clicks
FROM click_events
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false`,
[linkId]
);
const dateResult = await db.query(
`SELECT
DATE(clicked_at) as date,
COUNT(*) as clicks
FROM click_events
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false
GROUP BY DATE(clicked_at)
ORDER BY date`,
[linkId]
);
const countryResult = await db.query(
`SELECT
COALESCE(country_code, 'Unknown') as country_code,
COALESCE(country_name, country_code, 'Unknown') as country,
COUNT(*) as clicks
FROM click_events
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false
GROUP BY country_code, country_name
ORDER BY clicks DESC`,
[linkId]
);
const deviceResult = await db.query(
`SELECT
COALESCE(device_type, 'Unknown') as device,
COUNT(*) as clicks
FROM click_events
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false
GROUP BY device_type
ORDER BY clicks DESC`,
[linkId]
);
const platformResult = await db.query(
`SELECT
COALESCE(platform, 'Unknown') as platform,
COUNT(*) as clicks
FROM click_events
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false
GROUP BY platform
ORDER BY clicks DESC`,
[linkId]
);
return {
totalClicks: parseInt(clicksResult.rows[0]?.total_clicks || '0'),
uniqueClicks: parseInt(clicksResult.rows[0]?.unique_clicks || '0'),
clicksByDate: dateResult.rows.map(row => ({
date: row.date,
clicks: parseInt(row.clicks),
})),
clicksByCountry: countryResult.rows.map(row => ({
countryCode: row.country_code,
country: row.country,
clicks: parseInt(row.clicks),
})),
clicksByDevice: deviceResult.rows.map(row => ({
device: row.device,
clicks: parseInt(row.clicks),
})),
clicksByPlatform: platformResult.rows.map(row => ({
platform: row.platform,
clicks: parseInt(row.clicks),
})),
};
});
}
+363
View File
@@ -0,0 +1,363 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { db } from '../lib/database.js';
import { detectDevice } from '../lib/utils.js';
import { subscribeToClickEvents, ClickEventData } from '../lib/event-emitter.js';
/**
* Device simulation request schema
*/
const simulateRequestSchema = z.object({
linkId: z.string().uuid(),
userId: z.string().uuid().optional(),
deviceType: z.enum(['ios', 'android', 'web']).optional(),
userAgent: z.string().optional(),
country: z.string().length(2).optional(), // ISO country code
language: z.string().optional(), // e.g., "en", "es", "fr"
ipAddress: z.string().ip().optional(),
});
type SimulateRequest = z.infer<typeof simulateRequestSchema>;
/**
* Debugging routes for testing and validation
* Premium Cloud-only feature
*/
export async function debugRoutes(fastify: FastifyInstance) {
/**
* POST /api/debug/simulate
* Simulate a link click with custom device parameters
* Returns detailed information about redirect decision without logging
*/
fastify.post('/api/debug/simulate', async (request: FastifyRequest) => {
const data = simulateRequestSchema.parse(request.body);
// Fetch the link
let linkResult;
if (data.userId) {
linkResult = await db.query(
`SELECT * FROM links WHERE id = $1 AND user_id = $2`,
[data.linkId, data.userId]
);
} else {
linkResult = await db.query(
`SELECT * FROM links WHERE id = $1`,
[data.linkId]
);
}
if (linkResult.rows.length === 0) {
throw new Error('Link not found');
}
const link = linkResult.rows[0];
// Determine device type
let deviceType: 'ios' | 'android' | 'web';
if (data.deviceType) {
deviceType = data.deviceType;
} else if (data.userAgent) {
deviceType = detectDevice(data.userAgent);
} else {
deviceType = 'web'; // Default
}
// Simulate targeting rules evaluation
const targetingRules = link.targeting_rules || {};
const simulatedCountry = data.country || 'US';
const simulatedLanguage = data.language || 'en';
let targetingMatched = true;
const targetingDetails: {
countryMatch: boolean | null;
deviceMatch: boolean | null;
languageMatch: boolean | null;
} = {
countryMatch: null,
deviceMatch: null,
languageMatch: null,
};
// Check country targeting
if (targetingRules.countries && targetingRules.countries.length > 0) {
targetingDetails.countryMatch = targetingRules.countries.includes(simulatedCountry);
if (!targetingDetails.countryMatch) {
targetingMatched = false;
}
}
// Check device targeting
if (targetingRules.devices && targetingRules.devices.length > 0) {
targetingDetails.deviceMatch = targetingRules.devices.includes(deviceType);
if (!targetingDetails.deviceMatch) {
targetingMatched = false;
}
}
// Check language targeting
if (targetingRules.languages && targetingRules.languages.length > 0) {
const primaryLang = simulatedLanguage.split('-')[0];
targetingDetails.languageMatch = targetingRules.languages.some(
(lang: string) => lang.toLowerCase().startsWith(primaryLang.toLowerCase())
);
if (!targetingDetails.languageMatch) {
targetingMatched = false;
}
}
// Determine redirect URL based on device type
let redirectUrl = link.original_url;
let redirectReason = 'original_url (default)';
if (deviceType === 'ios' && link.ios_app_store_url) {
redirectUrl = link.ios_app_store_url;
redirectReason = 'ios_app_store_url (iOS device detected)';
} else if (deviceType === 'android' && link.android_app_store_url) {
redirectUrl = link.android_app_store_url;
redirectReason = 'android_app_store_url (Android device detected)';
} else if (deviceType === 'web' && link.web_fallback_url) {
redirectUrl = link.web_fallback_url;
redirectReason = 'web_fallback_url (Web device detected)';
}
// Add UTM parameters if present
let finalUrl = redirectUrl;
const utmParameters = link.utm_parameters || {};
if (Object.keys(utmParameters).length > 0) {
const url = new URL(redirectUrl);
if (utmParameters.source) url.searchParams.set('utm_source', utmParameters.source);
if (utmParameters.medium) url.searchParams.set('utm_medium', utmParameters.medium);
if (utmParameters.campaign) url.searchParams.set('utm_campaign', utmParameters.campaign);
if (utmParameters.term) url.searchParams.set('utm_term', utmParameters.term);
if (utmParameters.content) url.searchParams.set('utm_content', utmParameters.content);
finalUrl = url.toString();
}
// Return detailed simulation results
return {
simulation: {
linkId: link.id,
shortCode: link.short_code,
title: link.title,
isActive: link.is_active,
expiresAt: link.expires_at,
},
input: {
deviceType: data.deviceType || 'auto-detected',
userAgent: data.userAgent || 'Not provided',
country: simulatedCountry,
language: simulatedLanguage,
ipAddress: data.ipAddress || 'Not provided',
},
detection: {
detectedDevice: deviceType,
detectionMethod: data.deviceType
? 'manual (provided in request)'
: data.userAgent
? 'user-agent parsing'
: 'default (web)',
},
targeting: {
hasRules: Object.keys(targetingRules).length > 0,
rules: targetingRules,
matched: targetingMatched,
details: targetingDetails,
},
redirect: {
wouldRedirect: link.is_active && targetingMatched,
finalUrl: targetingMatched ? finalUrl : null,
redirectReason: targetingMatched ? redirectReason : 'Targeting rules not matched',
utmParametersAdded: Object.keys(utmParameters).length > 0,
utmParameters: utmParameters,
},
warnings: [
...(!link.is_active ? ['Link is inactive - would return 404'] : []),
...(link.expires_at && new Date(link.expires_at) < new Date()
? ['Link has expired - would return 404']
: []),
...(!targetingMatched ? ['Targeting rules not matched - would return 404'] : []),
],
};
});
/**
* GET /api/debug/user-agents
* Get a list of common User-Agent strings for testing
*/
fastify.get('/api/debug/user-agents', async () => {
return {
ios: [
{
name: 'iPhone 15 Pro - iOS 17 - Safari',
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
device: 'ios',
},
{
name: 'iPhone 14 - iOS 16 - Safari',
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
device: 'ios',
},
{
name: 'iPad Pro - iOS 17 - Safari',
userAgent:
'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
device: 'ios',
},
],
android: [
{
name: 'Samsung Galaxy S23 - Android 13 - Chrome',
userAgent:
'Mozilla/5.0 (Linux; Android 13; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36',
device: 'android',
},
{
name: 'Google Pixel 8 - Android 14 - Chrome',
userAgent:
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36',
device: 'android',
},
{
name: 'OnePlus 11 - Android 13 - Chrome',
userAgent:
'Mozilla/5.0 (Linux; Android 13; CPH2449) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36',
device: 'android',
},
],
web: [
{
name: 'Chrome on Windows',
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
device: 'web',
},
{
name: 'Safari on macOS',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
device: 'web',
},
{
name: 'Firefox on Linux',
userAgent:
'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0',
device: 'web',
},
],
};
});
/**
* GET /api/debug/countries
* Get a list of common countries for testing
*/
fastify.get('/api/debug/countries', async () => {
return {
countries: [
{ code: 'US', name: 'United States' },
{ code: 'GB', name: 'United Kingdom' },
{ code: 'CA', name: 'Canada' },
{ code: 'AU', name: 'Australia' },
{ code: 'DE', name: 'Germany' },
{ code: 'FR', name: 'France' },
{ code: 'ES', name: 'Spain' },
{ code: 'IT', name: 'Italy' },
{ code: 'JP', name: 'Japan' },
{ code: 'CN', name: 'China' },
{ code: 'IN', name: 'India' },
{ code: 'BR', name: 'Brazil' },
{ code: 'MX', name: 'Mexico' },
{ code: 'KR', name: 'South Korea' },
{ code: 'SG', name: 'Singapore' },
],
};
});
/**
* GET /api/debug/languages
* Get a list of common languages for testing
*/
fastify.get('/api/debug/languages', async () => {
return {
languages: [
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Spanish' },
{ code: 'fr', name: 'French' },
{ code: 'de', name: 'German' },
{ code: 'it', name: 'Italian' },
{ code: 'pt', name: 'Portuguese' },
{ code: 'ja', name: 'Japanese' },
{ code: 'zh', name: 'Chinese' },
{ code: 'ko', name: 'Korean' },
{ code: 'ar', name: 'Arabic' },
{ code: 'ru', name: 'Russian' },
{ code: 'hi', name: 'Hindi' },
],
};
});
/**
* WebSocket: /api/debug/live
* Real-time click event streaming
* Clients can subscribe to live click events filtered by userId and optionally linkId
*/
(fastify as any).get(
'/api/debug/live',
{ websocket: true },
(connection: any, request: FastifyRequest<{
Querystring: { userId?: string; linkId?: string };
}>) => {
const { userId, linkId } = request.query;
// Send welcome message
connection.socket.send(
JSON.stringify({
type: 'connected',
message: 'Connected to live request inspector',
filters: {
userId: userId || 'all',
linkId: linkId || 'all',
},
})
);
// Subscribe to click events
const unsubscribe = subscribeToClickEvents((eventData: ClickEventData) => {
// Filter by userId if provided
if (userId && eventData.userId !== userId) {
return;
}
// Filter by linkId if provided
if (linkId && eventData.linkId !== linkId) {
return;
}
// Send event to client
try {
connection.socket.send(
JSON.stringify({
type: 'click_event',
data: eventData,
})
);
} catch (error: any) {
console.error('Failed to send WebSocket message:', error);
}
});
// Handle client disconnect
connection.socket.on('close', () => {
unsubscribe();
});
// Handle errors
connection.socket.on('error', (error: any) => {
console.error('WebSocket error:', error);
unsubscribe();
});
}
);
}
+10
View File
@@ -0,0 +1,10 @@
export { redirectRoutes } from './redirect.js';
export { linkRoutes } from './links.js';
export { analyticsRoutes } from './analytics.js';
export { sdkRoutes } from './sdk.js';
export { qrRoutes } from './qr.js';
export { webhookRoutes } from './webhooks.js';
export { templateRoutes } from './templates.js';
export { previewRoutes } from './preview.js';
export { debugRoutes } from './debug.js';
export { wellKnownRoutes } from './well-known.js';
+31
View File
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const dbQueryMock = vi.fn();
vi.mock('../lib/database.js', () => ({
db: {
query: dbQueryMock,
},
}));
import { resolveLinkTemplateId } from './links.js';
describe('resolveLinkTemplateId', () => {
beforeEach(() => {
dbQueryMock.mockReset();
});
it('returns null when the provided template id does not exist', async () => {
dbQueryMock.mockResolvedValueOnce({ rows: [] });
await expect(resolveLinkTemplateId('00000000-0000-0000-0000-000000000000', null)).resolves.toBeNull();
});
it('falls back to template slug when the provided id is not usable', async () => {
dbQueryMock.mockResolvedValueOnce({ rows: [] });
dbQueryMock.mockResolvedValueOnce({ rows: [{ id: '11111111-1111-1111-1111-111111111111' }] });
await expect(resolveLinkTemplateId(null, 'default')).resolves.toBe('11111111-1111-1111-1111-111111111111');
expect(dbQueryMock).toHaveBeenNthCalledWith(2, 'SELECT id FROM link_templates WHERE slug = $1 LIMIT 1', ['default']);
});
});
+439
View File
@@ -0,0 +1,439 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { db } from '../lib/database.js';
import { generateShortCode } from '../lib/utils.js';
import { invalidateLinkResolutionCache } from '../lib/link-resolution-cache.js';
async function getTemplateSlug(templateId: string | null): Promise<string | null> {
if (!templateId) return null;
const result = await db.query(
'SELECT slug FROM link_templates WHERE id = $1',
[templateId]
);
return result.rows[0]?.slug ?? null;
}
const createLinkSchema = z.object({
userId: z.string().uuid().optional(),
templateId: z.string().uuid().optional(),
originalUrl: z.string().url().optional(),
title: z.string().optional(),
description: z.string().optional(),
// App store URLs (renamed for clarity)
iosAppStoreUrl: z.string().url().optional(),
androidAppStoreUrl: z.string().url().optional(),
webFallbackUrl: z.string().url().optional(),
// App deep linking configuration
appScheme: z.string()
.regex(/^[a-z][a-z0-9+.-]*$/, 'Invalid URI scheme format (must start with lowercase letter, contain only lowercase letters, numbers, +, ., or -)')
.optional(),
iosUniversalLink: z.string().url().optional(),
androidAppLink: z.string().url().optional(),
deepLinkPath: z.string().optional(),
deepLinkParameters: z.record(z.string(), z.any()).optional(),
// Existing fields
customCode: z.string().optional(),
utmParameters: z.object({
source: z.string().optional(),
medium: z.string().optional(),
campaign: z.string().optional(),
term: z.string().optional(),
content: z.string().optional(),
}).optional(),
targetingRules: z.object({
countries: z.array(z.string()).optional(),
devices: z.array(z.enum(['ios', 'android', 'web'])).optional(),
languages: z.array(z.string()).optional(),
}).optional(),
ogTitle: z.string().optional(),
ogDescription: z.string().optional(),
ogImageUrl: z.string().url().optional(),
ogType: z.string().optional(),
attributionWindowHours: z.number()
.int('Attribution window must be an integer')
.min(1, 'Attribution window must be at least 1 hour')
.max(2160, 'Attribution window must be at most 2160 hours (90 days)')
.optional(),
expiresAt: z.string().datetime().optional(),
});
const updateLinkSchema = createLinkSchema.partial().extend({
isActive: z.boolean().optional(),
}).omit({ userId: true });
export async function linkRoutes(fastify: FastifyInstance) {
// Get all links (optionally filtered by userId)
fastify.get('/api/links', async (request: FastifyRequest<{
Querystring: { userId?: string }
}>) => {
const { userId } = request.query;
let query: string;
let params: any[];
if (userId) {
query = `
SELECT l.*,
t.slug as template_slug,
COUNT(ce.id) as click_count
FROM links l
LEFT JOIN link_templates t ON l.template_id = t.id
LEFT JOIN click_events ce ON l.id = ce.link_id
WHERE l.user_id = $1
GROUP BY l.id, t.slug
ORDER BY l.created_at DESC
`;
params = [userId];
} else {
query = `
SELECT l.*,
t.slug as template_slug,
COUNT(ce.id) as click_count
FROM links l
LEFT JOIN link_templates t ON l.template_id = t.id
LEFT JOIN click_events ce ON l.id = ce.link_id
GROUP BY l.id, t.slug
ORDER BY l.created_at DESC
`;
params = [];
}
const result = await db.query(query, params);
return result.rows.map(row => ({
...row,
clickCount: parseInt(row.click_count),
utmParameters: row.utm_parameters,
targetingRules: row.targeting_rules,
deepLinkParameters: row.deep_link_parameters,
}));
});
// Get single link
fastify.get('/api/links/:id', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
let result;
if (userId) {
result = await db.query(
`SELECT l.*, t.slug as template_slug, COUNT(ce.id) as click_count
FROM links l
LEFT JOIN link_templates t ON l.template_id = t.id
LEFT JOIN click_events ce ON l.id = ce.link_id
WHERE l.id = $1 AND l.user_id = $2
GROUP BY l.id, t.slug`,
[id, userId]
);
} else {
result = await db.query(
`SELECT l.*, t.slug as template_slug, COUNT(ce.id) as click_count
FROM links l
LEFT JOIN link_templates t ON l.template_id = t.id
LEFT JOIN click_events ce ON l.id = ce.link_id
WHERE l.id = $1
GROUP BY l.id, t.slug`,
[id]
);
}
if (result.rows.length === 0) {
throw new Error('Link not found');
}
const link = result.rows[0];
return {
...link,
clickCount: parseInt(link.click_count),
utmParameters: link.utm_parameters,
targetingRules: link.targeting_rules,
deepLinkParameters: link.deep_link_parameters,
};
});
// Create link
fastify.post('/api/links', async (request) => {
const data = createLinkSchema.parse(request.body);
// Generate short code
let shortCode = data.customCode || generateShortCode();
// Ensure short code is unique
let attempts = 0;
while (attempts < 10) {
const existing = await db.query(
'SELECT id FROM links WHERE short_code = $1',
[shortCode]
);
if (existing.rows.length === 0) {
break;
}
shortCode = generateShortCode();
attempts++;
}
if (attempts >= 10) {
throw new Error('Unable to generate unique short code');
}
const originalUrl = data.originalUrl || 'https://angkorlifes.com';
const result = await db.query(
`INSERT INTO links (
user_id, template_id, short_code, original_url, title, description,
ios_app_store_url, android_app_store_url, web_fallback_url,
app_scheme, ios_universal_link, android_app_link, deep_link_path, deep_link_parameters,
utm_parameters, targeting_rules,
og_title, og_description, og_image_url, og_type,
attribution_window_hours, expires_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
RETURNING *`,
[
data.userId || null,
data.templateId || null,
shortCode,
originalUrl,
data.title || null,
data.description || null,
data.iosAppStoreUrl || null,
data.androidAppStoreUrl || null,
data.webFallbackUrl || null,
data.appScheme || null,
data.iosUniversalLink || null,
data.androidAppLink || null,
data.deepLinkPath || null,
JSON.stringify(data.deepLinkParameters || {}),
JSON.stringify(data.utmParameters || {}),
JSON.stringify(data.targetingRules || {}),
data.ogTitle || null,
data.ogDescription || null,
data.ogImageUrl || null,
data.ogType || 'website',
data.attributionWindowHours || 168, // Default 7 days
data.expiresAt || null,
]
);
const link = result.rows[0];
return {
...link,
clickCount: 0,
utmParameters: link.utm_parameters,
targetingRules: link.targeting_rules,
deepLinkParameters: link.deep_link_parameters,
};
});
// Update link
fastify.put('/api/links/:id', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
const data = updateLinkSchema.parse(request.body);
// Capture current identifiers for cache invalidation after the update
const oldLinkResult = await db.query(
'SELECT short_code, template_id FROM links WHERE id = $1',
[id]
);
// Build update query dynamically
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined) {
if (key === 'utmParameters' || key === 'targetingRules' || key === 'deepLinkParameters') {
updates.push(`${key.replace(/([A-Z])/g, '_$1').toLowerCase()} = $${paramIndex}`);
values.push(JSON.stringify(value));
} else {
const dbKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
updates.push(`${dbKey} = $${paramIndex}`);
values.push(value);
}
paramIndex++;
}
});
if (updates.length === 0) {
throw new Error('No updates provided');
}
updates.push('updated_at = NOW()');
values.push(id);
let whereClause = `WHERE id = $${paramIndex}`;
if (userId) {
values.push(userId);
whereClause += ` AND user_id = $${paramIndex + 1}`;
}
const result = await db.query(
`UPDATE links SET ${updates.join(', ')}
${whereClause}
RETURNING *`,
values
);
if (result.rows.length === 0) {
throw new Error('Link not found');
}
const link = result.rows[0];
// Invalidate cached link JSON for both old and new template associations
const oldRow = oldLinkResult.rows[0];
const oldTemplateSlug = await getTemplateSlug(oldRow?.template_id);
const newTemplateSlug = await getTemplateSlug(link.template_id);
await invalidateLinkResolutionCache(fastify.redis, link.short_code, oldTemplateSlug);
if (newTemplateSlug !== oldTemplateSlug) {
await invalidateLinkResolutionCache(fastify.redis, link.short_code, newTemplateSlug);
}
return {
...link,
utmParameters: link.utm_parameters,
targetingRules: link.targeting_rules,
deepLinkParameters: link.deep_link_parameters,
};
});
// Duplicate link
fastify.post('/api/links/:id/duplicate', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
// Get original link
let originalLink;
if (userId) {
originalLink = await db.query(
'SELECT * FROM links WHERE id = $1 AND user_id = $2',
[id, userId]
);
} else {
originalLink = await db.query(
'SELECT * FROM links WHERE id = $1',
[id]
);
}
if (originalLink.rows.length === 0) {
throw new Error('Link not found');
}
const link = originalLink.rows[0];
// Generate new short code
let shortCode = generateShortCode();
let attempts = 0;
while (attempts < 10) {
const existing = await db.query(
'SELECT id FROM links WHERE short_code = $1',
[shortCode]
);
if (existing.rows.length === 0) {
break;
}
shortCode = generateShortCode();
attempts++;
}
if (attempts >= 10) {
throw new Error('Unable to generate unique short code');
}
// Create duplicate with new short code and "(Copy)" suffix in title
const title = link.title ? `${link.title} (Copy)` : null;
const result = await db.query(
`INSERT INTO links (
user_id, template_id, short_code, original_url, title, description,
ios_app_store_url, android_app_store_url, web_fallback_url,
app_scheme, ios_universal_link, android_app_link, deep_link_path, deep_link_parameters,
utm_parameters, targeting_rules,
og_title, og_description, og_image_url, og_type,
attribution_window_hours, expires_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
RETURNING *`,
[
link.user_id,
link.template_id || null,
shortCode,
link.original_url,
title,
link.description,
link.ios_app_store_url,
link.android_app_store_url,
link.web_fallback_url,
link.app_scheme,
link.ios_universal_link,
link.android_app_link,
link.deep_link_path,
link.deep_link_parameters,
link.utm_parameters,
link.targeting_rules,
link.og_title,
link.og_description,
link.og_image_url,
link.og_type,
link.attribution_window_hours,
link.expires_at,
]
);
const newLink = result.rows[0];
return {
...newLink,
clickCount: 0,
utmParameters: newLink.utm_parameters,
targetingRules: newLink.targeting_rules,
deepLinkParameters: newLink.deep_link_parameters,
};
});
// Delete link
fastify.delete('/api/links/:id', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
let result;
if (userId) {
result = await db.query(
'DELETE FROM links WHERE id = $1 AND user_id = $2 RETURNING id, short_code, template_id',
[id, userId]
);
} else {
result = await db.query(
'DELETE FROM links WHERE id = $1 RETURNING id, short_code, template_id',
[id]
);
}
if (result.rows.length === 0) {
throw new Error('Link not found');
}
const deleted = result.rows[0];
const templateSlug = await getTemplateSlug(deleted.template_id);
await invalidateLinkResolutionCache(fastify.redis, deleted.short_code, templateSlug);
return { success: true };
});
}
+252
View File
@@ -0,0 +1,252 @@
import { FastifyInstance } from 'fastify';
import { db } from '../lib/database.js';
/**
* Detect if the request is from a social media scraper/bot
* These bots crawl links to generate previews when shared on social platforms
*/
function isSocialScraper(userAgent: string): boolean {
const scraperPatterns = [
/facebookexternalhit/i, // Facebook
/Facebot/i, // Facebook
/Twitterbot/i, // Twitter
/LinkedInBot/i, // LinkedIn
/Slackbot/i, // Slack
/Discordbot/i, // Discord
/TelegramBot/i, // Telegram
/WhatsApp/i, // WhatsApp
/PinterestBot/i, // Pinterest
/SkypeUriPreview/i, // Skype
/Googlebot/i, // Google (for search previews)
/bingbot/i, // Bing
/ia_archiver/i, // Alexa
];
return scraperPatterns.some(pattern => pattern.test(userAgent));
}
/**
* Generate HTML preview page with Open Graph meta tags
*/
function generatePreviewHTML(
link: any,
shortUrl: string,
autoRedirect: boolean = true
): string {
// Use OG-specific values if provided, otherwise fall back to regular title/description
const ogTitle = link.og_title || link.title || 'Shared Link';
const ogDescription = link.og_description || link.description || '';
const ogImage = link.og_image_url || '';
const ogType = link.og_type || 'website';
const ogUrl = shortUrl;
// Auto-redirect after 2 seconds for human visitors
const metaRefresh = autoRedirect
? `<meta http-equiv="refresh" content="2;url=${link.original_url}">`
: '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(ogTitle)}</title>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="${escapeHtml(ogType)}">
<meta property="og:url" content="${escapeHtml(ogUrl)}">
<meta property="og:title" content="${escapeHtml(ogTitle)}">
<meta property="og:description" content="${escapeHtml(ogDescription)}">
${ogImage ? `<meta property="og:image" content="${escapeHtml(ogImage)}">` : ''}
${ogImage ? `<meta property="og:image:secure_url" content="${escapeHtml(ogImage)}">` : ''}
<!-- Twitter -->
<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">
<meta name="twitter:url" content="${escapeHtml(ogUrl)}">
<meta name="twitter:title" content="${escapeHtml(ogTitle)}">
<meta name="twitter:description" content="${escapeHtml(ogDescription)}">
${ogImage ? `<meta name="twitter:image" content="${escapeHtml(ogImage)}">` : ''}
<!-- LinkedIn -->
<meta property="og:site_name" content="LinkForty">
${metaRefresh}
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
padding: 20px;
}
.container {
max-width: 600px;
}
h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
p {
font-size: 1.25rem;
margin-bottom: 2rem;
opacity: 0.9;
}
.link {
display: inline-block;
padding: 12px 24px;
background: white;
color: #667eea;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
transition: transform 0.2s;
}
.link:hover {
transform: translateY(-2px);
}
.loader {
margin: 2rem auto;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
${ogImage ? `<img src="${escapeHtml(ogImage)}" alt="${escapeHtml(ogTitle)}" style="max-width: 100%; border-radius: 12px; margin-bottom: 2rem; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);">` : ''}
<h1>${escapeHtml(ogTitle)}</h1>
${ogDescription ? `<p>${escapeHtml(ogDescription)}</p>` : ''}
${autoRedirect ? '<div class="loader"></div><p>Redirecting you...</p>' : ''}
<a href="${escapeHtml(link.original_url)}" class="link">
${autoRedirect ? 'Click here if not redirected' : 'Continue to destination'}
</a>
</div>
</body>
</html>`;
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
export async function previewRoutes(fastify: FastifyInstance) {
/**
* GET /:shortCode/preview
* Always return HTML preview page with Open Graph tags
* Auto-redirects after 2 seconds for human visitors
*/
fastify.get('/:shortCode/preview', async (request, reply) => {
const { shortCode } = request.params as { shortCode: string };
const baseUrl = request.headers.host
? `${request.protocol}://${request.headers.host}`
: 'https://link.forty';
try {
// Lookup link by short code
const result = await db.query(
`SELECT * FROM links
WHERE short_code = $1 AND is_active = true
AND (expires_at IS NULL OR expires_at > NOW())`,
[shortCode]
);
if (result.rows.length === 0) {
return reply.status(404).send('Link not found');
}
const link = result.rows[0];
const shortUrl = `${baseUrl}/${shortCode}`;
// Return HTML with OG tags and auto-redirect
const html = generatePreviewHTML(link, shortUrl, true);
return reply
.header('Content-Type', 'text/html; charset=utf-8')
.send(html);
} catch (error: any) {
fastify.log.error(`Error generating preview: ${error}`);
return reply.status(500).send('Error generating preview');
}
});
/**
* Middleware for /:shortCode route to detect social scrapers
* If scraper detected, return OG preview instead of redirecting
*
* Note: This should be registered BEFORE the main redirect route
*/
fastify.addHook('preHandler', async (request, reply) => {
// Only apply to short code routes (not API routes, not preview routes)
const path = request.url.split('?')[0]; // Remove query string
if (
path.startsWith('/api/') ||
path.endsWith('/preview') ||
path === '/' ||
path.includes('.')
) {
return; // Skip this hook
}
const userAgent = request.headers['user-agent'] || '';
const shortCode = path.split('/').pop() || '';
const baseUrl = request.headers.host
? `${request.protocol}://${request.headers.host}`
: 'https://link.forty';
// If it's a social scraper, return OG preview HTML
if (isSocialScraper(userAgent)) {
try {
const result = await db.query(
`SELECT * FROM links
WHERE short_code = $1 AND is_active = true
AND (expires_at IS NULL OR expires_at > NOW())`,
[shortCode]
);
if (result.rows.length > 0) {
const link = result.rows[0];
const shortUrl = `${baseUrl}/${shortCode}`;
// Return HTML with OG tags, no auto-redirect for bots
const html = generatePreviewHTML(link, shortUrl, false);
reply
.header('Content-Type', 'text/html; charset=utf-8')
.send(html);
// Stop the request here, don't continue to redirect route
return reply;
}
} catch (error: any) {
fastify.log.error(`Error in social scraper detection: ${error}`);
// Continue to normal redirect route on error
}
}
// Not a social scraper, continue to normal redirect logic
});
}
+144
View File
@@ -0,0 +1,144 @@
import { FastifyInstance } from 'fastify';
import QRCode from 'qrcode';
import { db } from '../lib/database.js';
/**
* QR Code Routes - Generate QR codes for links
*/
export async function qrRoutes(fastify: FastifyInstance) {
/**
* GET /api/links/:id/qr
* Generate QR code for a link
*
* Query parameters:
* - format: 'png' | 'svg' (default: 'png')
* - size: number 128-2048 (default: 512)
* - color: hex color for foreground (default: '#000000')
* - bgcolor: hex color for background (default: '#ffffff')
*
* Returns: QR code image (PNG or SVG)
*/
fastify.get('/api/links/:id/qr', async (request, reply) => {
const { id } = request.params as { id: string };
const query = request.query as Record<string, string | undefined>;
const format = (query.format || 'png') as 'png' | 'svg';
const size = Math.min(Math.max(parseInt(query.size || '512', 10), 128), 2048);
const color = query.color || '#000000';
const bgcolor = query.bgcolor || '#ffffff';
// Validate format
if (!['png', 'svg'].includes(format)) {
return reply.status(400).send({ error: 'Invalid format. Use "png" or "svg".' });
}
// Build cache key
const cacheKey = `qr:${id}:${format}:${size}:${color}:${bgcolor}`;
// Try to get from cache
if (fastify.redis) {
try {
const cached = await fastify.redis.get(cacheKey);
if (cached) {
fastify.log.info(`QR code cache hit: ${cacheKey}`);
if (format === 'png') {
// Cached PNG is base64
const buffer = Buffer.from(cached, 'base64');
return reply
.type('image/png')
.header('Cache-Control', 'public, max-age=86400') // 24 hours
.send(buffer);
} else {
// Cached SVG is text
return reply
.type('image/svg+xml')
.header('Cache-Control', 'public, max-age=86400')
.send(cached);
}
}
} catch (error) {
fastify.log.warn('Redis QR cache lookup failed');
}
}
// Get link from database
const result = await db.query(
'SELECT short_code, original_url FROM links WHERE id = $1 AND is_active = true',
[id]
);
if (result.rows.length === 0) {
return reply.status(404).send({ error: 'Link not found' });
}
const link = result.rows[0];
// Build short URL using configured domain or request hostname
// Use SHORTLINK_DOMAIN env var for production deployments
const shortLinkDomain = process.env.SHORTLINK_DOMAIN || `${request.protocol}://${request.hostname}`;
const shortUrl = link.short_code
? `${shortLinkDomain}/${link.short_code}`
: link.original_url;
try {
// QR code options
const options = {
errorCorrectionLevel: 'M' as const, // Medium error correction
margin: 1, // Quiet zone margin
width: size,
color: {
dark: color,
light: bgcolor,
},
};
if (format === 'png') {
// Generate PNG as buffer
const buffer = await QRCode.toBuffer(shortUrl, options);
// Cache as base64
if (fastify.redis) {
try {
await fastify.redis.setex(cacheKey, 86400, buffer.toString('base64')); // 24 hour TTL
} catch (error) {
fastify.log.warn('Failed to cache QR code');
}
}
return reply
.type('image/png')
.header('Cache-Control', 'public, max-age=86400')
.header('Content-Disposition', `inline; filename="qr-${link.short_code || 'code'}.png"`)
.send(buffer);
} else {
// Generate SVG as string
const svg = await QRCode.toString(shortUrl, {
...options,
type: 'svg',
});
// Cache SVG text
if (fastify.redis) {
try {
await fastify.redis.setex(cacheKey, 86400, svg);
} catch (error) {
fastify.log.warn('Failed to cache QR code');
}
}
return reply
.type('image/svg+xml')
.header('Cache-Control', 'public, max-age=86400')
.header('Content-Disposition', `inline; filename="qr-${link.short_code || 'code'}.svg"`)
.send(svg);
}
} catch (error: any) {
fastify.log.error(`QR code generation failed: ${error.message}`);
return reply.status(500).send({
error: 'Failed to generate QR code',
message: error.message
});
}
});
}
+186
View File
@@ -0,0 +1,186 @@
import { describe, it, expect } from 'vitest';
import {
isIOSInAppBrowser,
isAndroidInAppBrowser,
pickMobileFallbackUrl,
} from './redirect.js';
// Real-world UA strings (truncated where helpful) for use across test cases.
const UA = {
// Regular browsers — should NOT be detected as in-app
iosSafari:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
iosChrome:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/123.0.6312.52 Mobile/15E148 Safari/604.1',
androidChrome:
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
androidFirefox:
'Mozilla/5.0 (Android 14; Mobile; rv:125.0) Gecko/125.0 Firefox/125.0',
// iOS in-app browsers
iosGmail:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 GSA/322.0.616052181 Safari/604.1',
iosFacebook:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/450.0.0.0]',
iosInstagram:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 320.0.0.0',
iosOutlook:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Outlook-iOS/2.0',
iosTwitter:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Twitter for iPhone/10.0',
// Android in-app browsers
androidWebview:
'Mozilla/5.0 (Linux; Android 14; Pixel 8; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36',
androidFacebook:
'Mozilla/5.0 (Linux; Android 14; Pixel 8; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/450.0.0.0]',
androidInstagram:
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Instagram 320.0.0.0',
androidLine:
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Line/13.0.0',
androidWhatsapp:
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 WhatsApp/2.24.0',
};
const URLS = {
iosStore: 'https://apps.apple.com/app/example/id123',
androidStore: 'https://play.google.com/store/apps/details?id=com.example',
webFallback: 'https://example.com/landing',
};
describe('isIOSInAppBrowser', () => {
it('detects Gmail (GSA token)', () => {
expect(isIOSInAppBrowser(UA.iosGmail)).toBe(true);
});
it('detects Facebook (FBAN/FBAV)', () => {
expect(isIOSInAppBrowser(UA.iosFacebook)).toBe(true);
});
it('detects Instagram', () => {
expect(isIOSInAppBrowser(UA.iosInstagram)).toBe(true);
});
it('detects Outlook', () => {
expect(isIOSInAppBrowser(UA.iosOutlook)).toBe(true);
});
it('detects Twitter', () => {
expect(isIOSInAppBrowser(UA.iosTwitter)).toBe(true);
});
it('does not flag Safari as in-app', () => {
expect(isIOSInAppBrowser(UA.iosSafari)).toBe(false);
});
it('does not flag Chrome on iOS as in-app', () => {
expect(isIOSInAppBrowser(UA.iosChrome)).toBe(false);
});
});
describe('isAndroidInAppBrowser', () => {
it('detects generic Android WebView via wv) marker', () => {
expect(isAndroidInAppBrowser(UA.androidWebview)).toBe(true);
});
it('detects Facebook (FB_IAB/FBAN/FBAV)', () => {
expect(isAndroidInAppBrowser(UA.androidFacebook)).toBe(true);
});
it('detects Instagram', () => {
expect(isAndroidInAppBrowser(UA.androidInstagram)).toBe(true);
});
it('detects Line', () => {
expect(isAndroidInAppBrowser(UA.androidLine)).toBe(true);
});
it('detects WhatsApp', () => {
expect(isAndroidInAppBrowser(UA.androidWhatsapp)).toBe(true);
});
it('does not flag Chrome as in-app', () => {
expect(isAndroidInAppBrowser(UA.androidChrome)).toBe(false);
});
it('does not flag Firefox as in-app', () => {
expect(isAndroidInAppBrowser(UA.androidFirefox)).toBe(false);
});
});
describe('pickMobileFallbackUrl — iOS regular browser (Safari/Chrome)', () => {
it('prefers iOS store URL over web fallback', () => {
const r = pickMobileFallbackUrl('ios', UA.iosSafari, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r).toEqual({ url: URLS.iosStore, reason: 'ios_app_store_url' });
});
it('falls back to web fallback when iOS store URL is absent', () => {
const r = pickMobileFallbackUrl('ios', UA.iosSafari, null, URLS.androidStore, URLS.webFallback);
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
});
it('uses iOS store URL when web fallback is absent', () => {
const r = pickMobileFallbackUrl('ios', UA.iosSafari, URLS.iosStore, URLS.androidStore, null);
expect(r).toEqual({ url: URLS.iosStore, reason: 'ios_app_store_url' });
});
it('returns null when both iOS store URL and web fallback are absent', () => {
expect(pickMobileFallbackUrl('ios', UA.iosSafari, null, URLS.androidStore, null)).toBeNull();
});
});
describe('pickMobileFallbackUrl — iOS in-app browser (Gmail/FB/Instagram/etc.)', () => {
it('prefers web fallback over iOS store URL', () => {
const r = pickMobileFallbackUrl('ios', UA.iosGmail, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
});
it('falls back to iOS store URL when web fallback is absent', () => {
const r = pickMobileFallbackUrl('ios', UA.iosGmail, URLS.iosStore, URLS.androidStore, null);
expect(r).toEqual({ url: URLS.iosStore, reason: 'ios_app_store_url' });
});
it('returns null when both iOS store URL and web fallback are absent', () => {
expect(pickMobileFallbackUrl('ios', UA.iosGmail, null, URLS.androidStore, null)).toBeNull();
});
});
describe('pickMobileFallbackUrl — Android regular browser (Chrome/Firefox)', () => {
it('prefers Android store URL over web fallback', () => {
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r).toEqual({ url: URLS.androidStore, reason: 'android_app_store_url' });
});
it('falls back to web fallback when Android store URL is absent', () => {
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, null, URLS.webFallback);
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
});
it('uses Android store URL when web fallback is absent', () => {
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, URLS.androidStore, null);
expect(r).toEqual({ url: URLS.androidStore, reason: 'android_app_store_url' });
});
it('returns null when both Android store URL and web fallback are absent', () => {
expect(pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, null, null)).toBeNull();
});
});
describe('pickMobileFallbackUrl — Android in-app browser (FB/Instagram/Line/WebView)', () => {
it('prefers web fallback over Android store URL', () => {
const r = pickMobileFallbackUrl('android', UA.androidFacebook, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
});
it('detects WebView via wv) marker and prefers web fallback', () => {
const r = pickMobileFallbackUrl('android', UA.androidWebview, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
});
it('falls back to Android store URL when web fallback is absent', () => {
const r = pickMobileFallbackUrl('android', UA.androidFacebook, URLS.iosStore, URLS.androidStore, null);
expect(r).toEqual({ url: URLS.androidStore, reason: 'android_app_store_url' });
});
});
describe('pickMobileFallbackUrl — reporter scenario regression test', () => {
// Reproduces SIT-163: user creates a link with iOS, Android, and web fallback URLs;
// mobile visitor expects the App/Play Store; previously got the web fallback.
it('iOS Safari → iOS App Store (was: web fallback)', () => {
const r = pickMobileFallbackUrl('ios', UA.iosSafari, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r?.url).toBe(URLS.iosStore);
expect(r?.reason).toBe('ios_app_store_url');
});
it('Android Chrome → Play Store (was: web fallback)', () => {
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r?.url).toBe(URLS.androidStore);
expect(r?.reason).toBe('android_app_store_url');
});
// And the email-marketing flow that the prior fix protected stays correct:
it('iOS Gmail in-app → web fallback (preserves UL second-chance)', () => {
const r = pickMobileFallbackUrl('ios', UA.iosGmail, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r?.url).toBe(URLS.webFallback);
});
it('Android Facebook in-app → web fallback (preserves UL second-chance)', () => {
const r = pickMobileFallbackUrl('android', UA.androidFacebook, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r?.url).toBe(URLS.webFallback);
});
});
+606
View File
@@ -0,0 +1,606 @@
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, '&quot;').replace(/</g, '&lt;');
const safeFallbackUrl = fallbackUrl.replace(/"/g, '&quot;').replace(/</g, '&lt;');
const safeTitle = (title || 'the app').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Opening ${safeTitle}...</title>
<style>
body { font-family: -apple-system, system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f9fafb; color: #111827; text-align: center; }
.container { padding: 2rem; }
.spinner { width: 40px; height: 40px; border: 3px solid #e5e7eb; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 1.5rem; }
@keyframes spin { to { transform: rotate(360deg); } }
h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 0.5rem; }
p { font-size: 0.875rem; color: #6b7280; margin: 0 0 2rem; }
.btn { display: inline-block; padding: 0.75rem 1.5rem; border-radius: 0.5rem; font-size: 0.875rem; font-weight: 500; text-decoration: none; margin: 0.25rem; }
.btn-primary { background: #3b82f6; color: #fff; }
.btn-secondary { background: #e5e7eb; color: #374151; }
</style>
</head><body>
<div class="container">
<div class="spinner"></div>
<h1>Opening ${safeTitle}...</h1>
<p>If the app doesn't open automatically:</p>
<a class="btn btn-primary" id="open-btn" href="${safeSchemeUrl}">Open App</a>
<a class="btn btn-secondary" id="store-btn" href="${safeFallbackUrl}">Download App</a>
</div>
<script>
// Preserve URL fragment (E2E encryption key) through the scheme redirect
var hash = window.location.hash || '';
var schemeUrl = "${safeSchemeUrl}" + hash;
document.getElementById('open-btn').href = schemeUrl;
window.location = schemeUrl;
setTimeout(function() { window.location.replace("${safeFallbackUrl}"); }, 1500);
</script>
</body></html>`;
}
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<string, string | undefined>;
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;
// 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: deviceType,
platformVersion,
};
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);
});
}
+192
View File
@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
// Mock the database singleton so the route runs without a real Postgres.
vi.mock('../lib/database.js', () => ({
db: { query: vi.fn() },
}));
import Fastify, { type FastifyInstance } from 'fastify';
import { db } from '../lib/database.js';
import { sdkRoutes } from './sdk.js';
const mockQuery = db.query as unknown as Mock;
const INSTALL_ID = '11111111-1111-4111-8111-111111111111';
const LINK_ID = '22222222-2222-4222-8222-222222222222';
const SESSION_ID = '33333333-3333-4333-8333-333333333333';
const CLICK_ID = '44444444-4444-4444-8444-444444444444';
const EVENT_ID = '55555555-5555-4555-8555-555555555555';
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify();
await app.register(sdkRoutes);
await app.ready();
return app;
}
const EVENT_TS = '2026-06-05T10:05:00.000Z';
const LINK_OPENED_AT = '2026-06-05T10:00:00.000Z';
const stampedEvent = {
installId: INSTALL_ID,
eventName: 'add_to_cart',
eventData: { sku: 'abc' },
timestamp: EVENT_TS,
attributedLinkId: LINK_ID,
attributedClickId: CLICK_ID,
linkOpenedAt: LINK_OPENED_AT,
sessionId: SESSION_ID,
sdkName: 'react-native',
sdkVersion: '1.4.0',
};
describe('POST /api/sdk/v1/links', () => {
beforeEach(() => {
mockQuery.mockReset();
});
it('accepts payloads without originalUrl and derives a fallback destination', async () => {
mockQuery.mockResolvedValueOnce({
rows: [{
id: LINK_ID,
short_code: 'abc123',
original_url: 'https://example.com/emoji/detail',
}],
});
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/sdk/v1/links',
payload: {
deepLinkParameters: { emojiSetId: 'uex1k7qy', path: '/emoji/detail' },
utmParameters: { campaign: 'emoji_share' },
},
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({
shortCode: 'abc123',
originalUrl: 'https://example.com/emoji/detail',
});
const insertCall = mockQuery.mock.calls[0];
expect(insertCall[0]).toMatch(/INSERT INTO links/);
expect(insertCall[1][3]).toBe('https://example.com/emoji/detail');
await app.close();
});
});
describe('POST /api/sdk/v1/event — last-click attribution stamp', () => {
beforeEach(() => {
mockQuery.mockReset();
});
it('persists the attribution stamp on the in_app_events row', async () => {
// 1) install lookup (link_id null so the webhook block is skipped)
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
// 2) the event INSERT
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });
const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true });
const insertCall = mockQuery.mock.calls[1];
expect(insertCall[0]).toMatch(/INSERT INTO in_app_events/);
expect(insertCall[0]).toMatch(/attributed_link_id/);
// params: install, name, dataJson, ts, link, click, openedAt, session
expect(insertCall[1]).toEqual([
INSTALL_ID,
'add_to_cart',
JSON.stringify({ sku: 'abc' }),
EVENT_TS,
LINK_ID,
CLICK_ID,
LINK_OPENED_AT,
SESSION_ID,
'react-native',
'1.4.0',
]);
await app.close();
});
it('stays backward compatible: an event with no stamp stores null attribution + a sessionId-less row', async () => {
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/sdk/v1/event',
payload: { installId: INSTALL_ID, eventName: 'signup' },
});
expect(res.statusCode).toBe(200);
const params = mockQuery.mock.calls[1][1];
expect(params[4]).toBeNull(); // attributed_link_id
expect(params[5]).toBeNull(); // attributed_click_id
expect(params[6]).toBeNull(); // attributed_at
expect(params[7]).toBeNull(); // session_id
await app.close();
});
it('never loses an event when the attributed link is stale: falls back to no-link insert on FK violation', async () => {
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
// first INSERT rejects with the attributed_link_id FK violation
mockQuery.mockRejectedValueOnce(Object.assign(new Error('FK violation'), { code: '23503', constraint: 'in_app_events_attributed_link_id_fkey' }));
// fallback INSERT (without attributed_link_id) succeeds
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });
const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true });
// the fallback INSERT omits attributed_link_id but keeps click/session
const fallbackCall = mockQuery.mock.calls[2];
expect(fallbackCall[0]).not.toMatch(/attributed_link_id/);
expect(fallbackCall[1]).toEqual([
INSTALL_ID,
'add_to_cart',
JSON.stringify({ sku: 'abc' }),
EVENT_TS,
CLICK_ID,
LINK_OPENED_AT,
SESSION_ID,
'react-native',
'1.4.0',
]);
await app.close();
});
it('rethrows a non-link FK violation (e.g. install deleted mid-request) instead of mislabeling it as a link problem', async () => {
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
// install_id's FK lost to a concurrent install delete — a 23503 that is NOT the link FK
mockQuery.mockRejectedValueOnce(Object.assign(new Error('FK violation'), { code: '23503', constraint: 'in_app_events_install_id_fkey' }));
const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
// Surfaces as a real error; no misleading no-link fallback insert is attempted.
expect(res.statusCode).toBe(500);
expect(mockQuery.mock.calls.length).toBe(2); // install lookup + the failed insert only
await app.close();
});
it('returns 404 when the install does not exist', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
expect(res.statusCode).toBe(404);
await app.close();
});
});
+754
View File
@@ -0,0 +1,754 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { db } from '../lib/database.js';
import { getClientIp } from '../lib/client-ip.js';
import {
recordInstallEvent,
generateFingerprintHash,
storeFingerprintForClick,
type FingerprintData,
} from '../lib/fingerprint.js';
import { triggerWebhooks } from '../lib/webhook.js';
import { parseUserAgent, getLocationFromIP, detectDevice } from '../lib/utils.js';
import { emitClickEvent } from '../lib/event-emitter.js';
import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js';
/**
* SDK Routes - Mobile SDK endpoints for deferred deep linking
* These endpoints are used by the mobile SDKs to report installs and retrieve attribution data
*/
export async function sdkRoutes(fastify: FastifyInstance) {
/**
* POST /api/sdk/v1/links
* Create a link from the simplified SDK payload.
*
* This endpoint accepts the subset of link creation fields that the SDK sends,
* including deepLinkParameters and utmParameters. When originalUrl is omitted,
* it falls back to the current page's URL or a placeholder destination.
*/
fastify.post('/api/sdk/v1/links', async (request, reply) => {
const schema = z.object({
originalUrl: z.string().url().optional(),
title: z.string().optional(),
description: z.string().optional(),
iosAppStoreUrl: z.string().url().optional(),
androidAppStoreUrl: z.string().url().optional(),
webFallbackUrl: z.string().url().optional(),
appScheme: z.string().optional(),
iosUniversalLink: z.string().url().optional(),
androidAppLink: z.string().url().optional(),
deepLinkPath: z.string().optional(),
deepLinkParameters: z.record(z.string(), z.any()).optional(),
customCode: z.string().optional(),
utmParameters: z.object({
source: z.string().optional(),
medium: z.string().optional(),
campaign: z.string().optional(),
term: z.string().optional(),
content: z.string().optional(),
}).optional(),
targetingRules: z.object({
countries: z.array(z.string()).optional(),
devices: z.array(z.enum(['ios', 'android', 'web'])).optional(),
languages: z.array(z.string()).optional(),
}).optional(),
ogTitle: z.string().optional(),
ogDescription: z.string().optional(),
ogImageUrl: z.string().url().optional(),
ogType: z.string().optional(),
attributionWindowHours: z.number().int().min(1).max(2160).optional(),
expiresAt: z.string().datetime().optional(),
templateId: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
});
const body = schema.parse(request.body);
const originalUrl = body.originalUrl || 'https://angkorlifes.com';
const shortCode = body.customCode || 'sdk-link';
const result = await db.query(
`INSERT INTO links (
user_id, template_id, short_code, original_url, title, description,
ios_app_store_url, android_app_store_url, web_fallback_url,
app_scheme, ios_universal_link, android_app_link, deep_link_path, deep_link_parameters,
utm_parameters, targeting_rules,
og_title, og_description, og_image_url, og_type,
attribution_window_hours, expires_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
RETURNING *`,
[
body.userId || null,
body.templateId || null,
shortCode,
originalUrl,
body.title || null,
body.description || null,
body.iosAppStoreUrl || null,
body.androidAppStoreUrl || null,
body.webFallbackUrl || null,
body.appScheme || null,
body.iosUniversalLink || null,
body.androidAppLink || null,
body.deepLinkPath || null,
JSON.stringify(body.deepLinkParameters || {}),
JSON.stringify(body.utmParameters || {}),
JSON.stringify(body.targetingRules || {}),
body.ogTitle || null,
body.ogDescription || null,
body.ogImageUrl || null,
body.ogType || 'website',
body.attributionWindowHours || 168,
body.expiresAt || null,
]
);
const link = result.rows[0];
return reply.status(200).send({
id: link.id,
shortCode: link.short_code,
originalUrl: link.original_url,
url: `${process.env.PUBLIC_BASE_URL || 'http://localhost:3000'}/${link.short_code}`,
});
});
/**
* POST /api/sdk/v1/install
* Report app installation and retrieve deferred deep link data
*
* Request body:
* - ipAddress: Optional, for debug only (untrusted; server uses connection/proxy headers for trusted IP)
* - userAgent: Client user agent
* - timezone: Device timezone (e.g., "America/New_York")
* - language: Device language (e.g., "en-US")
* - screenWidth: Screen width in pixels
* - screenHeight: Screen height in pixels
* - platform: Platform name (e.g., "iOS", "Android")
* - platformVersion: Platform version (e.g., "15.0")
* - deviceId: Optional device identifier (IDFA, GAID, etc.)
* - attributionWindowHours: Optional custom attribution window (default: 168 = 7 days)
*
* Response:
* - installId: UUID of the install event
* - attributed: Boolean indicating if install was matched to a click
* - confidenceScore: Confidence score (0-100) if matched
* - matchedFactors: Array of matched fingerprint factors
* - deepLinkData: Deep link data if matched (shortCode, URLs, UTM params, etc.)
*/
fastify.post('/api/sdk/v1/install', async (request, reply) => {
const schema = z.object({
ipAddress: z.string().optional(),
userAgent: z.string(),
timezone: z.string().optional(),
language: z.string().optional(),
screenWidth: z.number().optional(),
screenHeight: z.number().optional(),
platform: z.string().optional(),
platformVersion: z.string().optional(),
deviceId: z.string().optional(),
attributionWindowHours: z.number().optional(),
// SDK identity for health/version diagnostics (SIT-235). Free-form by
// design: a consumer tolerates/normalizes non-semver versions — we never
// reject a request over this metadata. Empty → null.
sdkName: z.string().max(50).optional(),
sdkVersion: z.string().max(50).optional(),
// Public app token shipped in SDK app bundles to scope organic
// installs to the right org in multi-tenant deployments. A multi-tenant
// host reads it to route the install to the correct tenant; self-hosted
// single-tenant deployments simply ignore it.
appToken: z.string().optional(),
});
const body = schema.parse(request.body);
// Trusted IP from connection/proxy headers only; never use body.ipAddress for attribution/fingerprint
const ipAddress = getClientIp(request);
const fingerprintData: FingerprintData = {
ipAddress,
userAgent: body.userAgent,
timezone: body.timezone,
language: body.language,
screenWidth: body.screenWidth,
screenHeight: body.screenHeight,
platform: body.platform,
platformVersion: body.platformVersion,
};
try {
const result = await recordInstallEvent(
fingerprintData,
body.deviceId,
body.attributionWindowHours,
{ name: body.sdkName, version: body.sdkVersion }
);
return reply.status(200).send({
installId: result.installId,
attributed: result.match !== null,
confidenceScore: result.match?.confidenceScore || 0,
matchedFactors: result.match?.matchedFactors || [],
deepLinkData: result.deepLinkData,
...(body.ipAddress != null && { clientReportedIp: body.ipAddress }),
});
} catch (error: any) {
fastify.log.error(`Error recording install event: ${error}`);
return reply.status(500).send({
error: 'Failed to record install event',
message: error.message,
});
}
});
/**
* GET /api/sdk/v1/attribution/:fingerprint
* Retrieve attribution data for a specific device fingerprint
* Used for debugging or delayed attribution lookups
*
* Response:
* - fingerprint: The fingerprint hash
* - attributed: Boolean indicating if attributed to a click
* - installEvent: Install event data if found
* - clickEvent: Matched click event data if attributed
* - linkData: Link data if attributed
*/
fastify.get('/api/sdk/v1/attribution/:fingerprint', async (request, reply) => {
const { fingerprint } = request.params as { fingerprint: string };
try {
// Look up install event by fingerprint
const installResult = await db.query(
`SELECT
ie.*,
l.short_code,
l.original_url,
l.ios_app_store_url,
l.android_app_store_url,
l.web_fallback_url,
l.utm_parameters,
l.deep_link_parameters
FROM install_events ie
LEFT JOIN links l ON ie.link_id = l.id
WHERE ie.fingerprint_hash = $1
ORDER BY ie.installed_at DESC
LIMIT 1`,
[fingerprint]
);
if (installResult.rows.length === 0) {
return reply.status(404).send({
error: 'No install event found for this fingerprint',
});
}
const install = installResult.rows[0];
const attributed = install.link_id !== null;
let clickData = null;
if (install.click_id) {
const clickResult = await db.query(
`SELECT * FROM click_events WHERE id = $1`,
[install.click_id]
);
if (clickResult.rows.length > 0) {
clickData = clickResult.rows[0];
}
}
return reply.status(200).send({
fingerprint,
attributed,
installEvent: {
id: install.id,
installedAt: install.installed_at,
firstOpenAt: install.first_open_at,
confidenceScore: parseFloat(install.confidence_score || '0'),
deepLinkRetrieved: install.deep_link_retrieved,
},
clickEvent: clickData
? {
id: clickData.id,
clickedAt: clickData.clicked_at,
deviceType: clickData.device_type,
platform: clickData.platform,
countryCode: clickData.country_code,
city: clickData.city,
}
: null,
linkData: attributed
? {
shortCode: install.short_code,
originalUrl: install.original_url,
iosUrl: install.ios_app_store_url,
androidUrl: install.android_app_store_url,
webFallbackUrl: install.web_fallback_url,
utmParameters: install.utm_parameters,
deepLinkParameters: install.deep_link_parameters,
}
: null,
});
} catch (error: any) {
fastify.log.error(`Error retrieving attribution: ${error}`);
return reply.status(500).send({
error: 'Failed to retrieve attribution data',
message: error.message,
});
}
});
/**
* POST /api/sdk/v1/event
* Track in-app events (purchases, signups, etc.)
* Used for conversion tracking and webhook triggers
*
* Revenue convention (all SDKs):
* eventName: "revenue"
* eventData: { revenue: number, currency: string, ...properties }
*
* Request body:
* - installId: UUID of the install event
* - eventName: Name of the event (e.g., "purchase", "signup", "level_complete")
* - eventData: Optional JSON data associated with the event
* - timestamp: Optional event timestamp (defaults to now)
*
* Last-click attribution stamp (SIT-237, all optional / backward compatible):
* - attributedLinkId: UUID of the deep link currently credited (last-click)
* - attributedClickId: UUID of the originating click, when known
* - linkOpenedAt: ISO timestamp of when that deep link opened the app
* - sessionId: UUID identifying the app-open session (for screen-flow grouping)
*
* Response:
* - eventId: UUID of the tracked event
* - acknowledged: Boolean confirmation
*/
fastify.post('/api/sdk/v1/event', async (request, reply) => {
const schema = z.object({
installId: z.string().uuid(),
eventName: z.string(),
eventData: z.record(z.any()).optional(),
timestamp: z.string().datetime().optional(),
attributedLinkId: z.string().uuid().optional(),
attributedClickId: z.string().uuid().optional(),
linkOpenedAt: z.string().datetime().optional(),
sessionId: z.string().uuid().optional(),
// SDK identity for version-health diagnostics (SIT-235). Free-form by
// design: a consumer tolerates/normalizes non-semver versions — we never
// reject a request over this metadata. Empty → null.
sdkName: z.string().max(50).optional(),
sdkVersion: z.string().max(50).optional(),
});
const body = schema.parse(request.body);
try {
// Verify install exists and get link_id for webhook lookup
const installCheck = await db.query(
`SELECT id, link_id FROM install_events WHERE id = $1`,
[body.installId]
);
if (installCheck.rows.length === 0) {
return reply.status(404).send({
error: 'Install event not found',
});
}
const install = installCheck.rows[0];
const eventTimestamp = body.timestamp || new Date().toISOString();
const eventDataJson = JSON.stringify(body.eventData || {});
// Insert event with the last-click attribution stamp. The attributing link
// may differ from the install link (re-engagement) or be absent (organic).
let eventResult;
try {
eventResult = await db.query(
`INSERT INTO in_app_events
(install_id, event_name, event_data, event_timestamp,
attributed_link_id, attributed_click_id, attributed_at, session_id,
sdk_name, sdk_version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
[
body.installId,
body.eventName,
eventDataJson,
eventTimestamp,
body.attributedLinkId ?? null,
body.attributedClickId ?? null,
body.linkOpenedAt ?? null,
body.sessionId ?? null,
body.sdkName || null,
body.sdkVersion || null,
]
);
} catch (insertError: any) {
// Only a stale/unknown *attributed link* FK is recoverable here: record
// the event without link attribution rather than losing it. Any other
// 23503 — e.g. install_id's FK lost to a concurrent install delete — is a
// real error that must surface, not be mislabeled as a link problem.
const isLinkFk =
insertError?.code === '23503' &&
String(insertError?.constraint ?? '').includes('attributed_link_id');
if (isLinkFk) {
fastify.log.warn(
`attributed_link_id ${body.attributedLinkId} not found; storing event without link attribution`
);
// Keep this column list in sync with the primary INSERT above (it just
// omits attributed_link_id). attributed_click_id is intentionally kept
// without a link: the orphaned-click case is expected, and a null
// attributed_link_id is the correct value for link-keyed aggregation
// (the SIT-261 consumer must not read it as a data bug).
eventResult = await db.query(
`INSERT INTO in_app_events
(install_id, event_name, event_data, event_timestamp,
attributed_click_id, attributed_at, session_id,
sdk_name, sdk_version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`,
[
body.installId,
body.eventName,
eventDataJson,
eventTimestamp,
body.attributedClickId ?? null,
body.linkOpenedAt ?? null,
body.sessionId ?? null,
body.sdkName || null,
body.sdkVersion || null,
]
);
} else {
throw insertError;
}
}
const eventId = eventResult.rows[0].id;
fastify.log.info({
eventId,
installId: body.installId,
linkId: install.link_id,
eventName: body.eventName,
eventData: body.eventData,
timestamp: eventTimestamp,
});
// Trigger webhooks if install was attributed to a link
if (install.link_id) {
// Query webhooks for the user who owns the link
const webhooksResult = await db.query(
`SELECT w.*
FROM webhooks w
INNER JOIN links l ON l.user_id = w.user_id
WHERE l.id = $1 AND w.is_active = true`,
[install.link_id]
);
if (webhooksResult.rows.length > 0) {
const eventData = {
eventId,
installId: body.installId,
linkId: install.link_id,
eventName: body.eventName,
eventData: body.eventData || {},
timestamp: eventTimestamp,
};
// Trigger webhooks asynchronously (fire and forget)
setImmediate(async () => {
// Trigger conversion_event webhooks (attributed installs only)
triggerWebhooks(
webhooksResult.rows,
'conversion_event',
eventId,
eventData
).catch((error) => {
fastify.log.error('Failed to trigger conversion webhooks:', error);
});
// Trigger sdk_event webhooks (all SDK-tracked events)
triggerWebhooks(
webhooksResult.rows,
'sdk_event',
eventId,
eventData
).catch((error) => {
fastify.log.error('Failed to trigger sdk_event webhooks:', error);
});
});
}
}
return reply.status(200).send({
eventId,
acknowledged: true,
});
} catch (error: any) {
fastify.log.error(`Error tracking event: ${error}`);
return reply.status(500).send({
error: 'Failed to track event',
message: error.message,
});
}
});
/**
* GET /api/sdk/v1/resolve/:shortCode
* GET /api/sdk/v1/resolve/:templateSlug/:shortCode
*
* Resolve a short link to its deep link data without triggering a redirect.
* Used by mobile SDKs when the OS intercepts a LinkForty URL via App Links
* or Universal Links before the server can process the redirect.
*
* Also records a click event and stores a device fingerprint for attribution,
* since the normal redirect flow was bypassed.
*
* Query params (optional fingerprint data for click attribution):
* - fp_tz: Device timezone
* - fp_lang: Device language
* - fp_sw: Screen width
* - fp_sh: Screen height
* - fp_platform: Platform (ios/android)
* - fp_pv: Platform version
*
* Response:
* - shortCode: The link's short code
* - linkId: UUID of the link
* - deepLinkPath: In-app destination path
* - appScheme: Custom URI scheme
* - iosUrl: iOS App Store URL
* - androidUrl: Android Play Store URL
* - webUrl: Web fallback URL
* - utmParameters: UTM tracking parameters
* - customParameters: Custom deep link parameters (key-value pairs)
* - clickedAt: Timestamp of this resolution
*/
async function handleResolve(request: any, reply: any, shortCode: string, templateSlug?: string) {
let linkData: string | null = null;
// Build cache key (same pattern as redirect.ts)
const cacheKey = templateSlug ? `link:${templateSlug}:${shortCode}` : `link:${shortCode}`;
// Try Redis cache first
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) {
let query: string;
let params: any[];
if (templateSlug) {
query = `
SELECT l.* FROM links l
LEFT JOIN link_templates t ON l.template_id = t.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 {
query = `
SELECT * FROM links
WHERE short_code = $1 AND is_active = true
AND (expires_at IS NULL OR 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 (fastify.redis) {
try {
await fastify.redis.setex(cacheKey, 300, linkData);
} catch (error) {
fastify.log.warn('Redis cache set failed');
}
}
}
const link = JSON.parse(linkData);
// Record click event + fingerprint asynchronously (mirrors redirect.ts pattern)
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 for consistent reads.
const { isBot, reason: botReason } = classifyBot(
userAgent,
request.method,
edgeBotSignal(request.headers['x-lf-bot'])
);
// Extract fingerprint data from query params (sent by SDK)
const query = request.query as Record<string, string | undefined>;
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;
// Insert click event
const clickResult = await db.query(
`INSERT INTO click_events (
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)
RETURNING id`,
[
link.id,
ip,
userAgent,
deviceType,
platform,
countryCode,
countryName,
region,
city,
latitude,
longitude,
timezone,
query?.utm_source || null,
query?.utm_medium || null,
query?.utm_campaign || null,
referrer,
isBot,
botReason,
]
);
const clickId = clickResult.rows[0].id;
// Store device fingerprint for deferred deep linking
const fingerprintData: FingerprintData = {
ipAddress: ip,
userAgent,
timezone: fpTimezone,
language: fpLanguage,
screenWidth: fpScreenWidth,
screenHeight: fpScreenHeight,
platform: deviceType,
platformVersion,
};
await storeFingerprintForClick(clickId, fingerprintData);
// Emit click event for real-time streaming
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: '',
redirectReason: 'sdk_resolve',
targetingMatched: true,
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 clickEventData = {
id: clickId,
linkId: link.id,
clickedAt: new Date().toISOString(),
ipAddress: ip,
userAgent,
deviceType,
platform,
countryCode,
countryName,
region,
city,
latitude,
longitude,
timezone,
referrer,
};
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 from resolve: ${error}`);
}
});
// Return JSON response with deep link data
return reply.status(200).send({
shortCode: link.short_code,
linkId: link.id,
deepLinkPath: link.deep_link_path || undefined,
appScheme: link.app_scheme || undefined,
iosUrl: link.ios_app_store_url || undefined,
androidUrl: link.android_app_store_url || undefined,
webUrl: link.web_fallback_url || undefined,
utmParameters: link.utm_parameters || undefined,
customParameters: link.deep_link_parameters || undefined,
clickedAt: new Date().toISOString(),
});
}
fastify.get('/api/sdk/v1/resolve/:shortCode', async (request, reply) => {
const { shortCode } = request.params as { shortCode: string };
return handleResolve(request, reply, shortCode);
});
fastify.get('/api/sdk/v1/resolve/:templateSlug/:shortCode', async (request, reply) => {
const { templateSlug, shortCode } = request.params as { templateSlug: string; shortCode: string };
return handleResolve(request, reply, shortCode, templateSlug);
});
/**
* GET /api/sdk/v1/health
* Health check endpoint for SDK connectivity testing
*/
fastify.get('/api/sdk/v1/health', async (request, reply) => {
return reply.status(200).send({
status: 'healthy',
version: 'v1',
timestamp: new Date().toISOString(),
});
});
}
+368
View File
@@ -0,0 +1,368 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { db } from '../lib/database.js';
import { customAlphabet } from 'nanoid';
const generateSlug = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 8);
const createTemplateSchema = z.object({
userId: z.string().uuid().optional(),
name: z.string().min(1, 'Template name is required').max(255, 'Name must be less than 255 characters'),
description: z.string().optional(),
settings: z.object({
defaultIosUrl: z.string().url('iOS URL must be a valid URL').optional(),
defaultAndroidUrl: z.string().url('Android URL must be a valid URL').optional(),
defaultWebFallbackUrl: z.string().url('Web Fallback URL must be a valid URL').optional(),
defaultAttributionWindowHours: z.number()
.min(1, 'Attribution window must be at least 1 hour')
.max(2160, 'Attribution window cannot exceed 2160 hours (90 days)')
.optional(),
utmParameters: z.object({
source: z.string().optional(),
medium: z.string().optional(),
campaign: z.string().optional(),
term: z.string().optional(),
content: z.string().optional(),
}).optional(),
targetingRules: z.object({
countries: z.array(z.string()).optional(),
devices: z.array(z.enum(['ios', 'android', 'web'])).optional(),
languages: z.array(z.string()).optional(),
}).optional(),
expiresAfterDays: z.number().optional(),
}).optional(),
isDefault: z.boolean().default(false),
});
const updateTemplateSchema = z.object({
name: z.string().min(1, 'Template name is required').max(255, 'Name must be less than 255 characters').optional(),
description: z.string().optional(),
settings: z.object({
defaultIosUrl: z.string().url('iOS URL must be a valid URL').optional(),
defaultAndroidUrl: z.string().url('Android URL must be a valid URL').optional(),
defaultWebFallbackUrl: z.string().url('Web Fallback URL must be a valid URL').optional(),
defaultAttributionWindowHours: z.number()
.min(1, 'Attribution window must be at least 1 hour')
.max(2160, 'Attribution window cannot exceed 2160 hours (90 days)')
.optional(),
utmParameters: z.object({
source: z.string().optional(),
medium: z.string().optional(),
campaign: z.string().optional(),
term: z.string().optional(),
content: z.string().optional(),
}).optional(),
targetingRules: z.object({
countries: z.array(z.string()).optional(),
devices: z.array(z.enum(['ios', 'android', 'web'])).optional(),
languages: z.array(z.string()).optional(),
}).optional(),
expiresAfterDays: z.number().optional(),
}).optional(),
isDefault: z.boolean().optional(),
});
export async function templateRoutes(fastify: FastifyInstance) {
// Get all templates (optionally filtered by userId)
fastify.get('/api/templates', async (request: FastifyRequest<{
Querystring: { userId?: string }
}>) => {
const { userId } = request.query;
let query: string;
let params: any[];
if (userId) {
query = `
SELECT id, user_id, name, slug, description, settings, is_default, created_at, updated_at
FROM link_templates
WHERE user_id = $1
ORDER BY is_default DESC, name ASC
`;
params = [userId];
} else {
query = `
SELECT id, user_id, name, slug, description, settings, is_default, created_at, updated_at
FROM link_templates
ORDER BY is_default DESC, name ASC
`;
params = [];
}
const result = await db.query(query, params);
return result.rows;
});
// Get single template
fastify.get('/api/templates/:id', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
let result;
if (userId) {
result = await db.query(
`SELECT id, user_id, name, slug, description, settings, is_default, created_at, updated_at
FROM link_templates
WHERE id = $1 AND user_id = $2`,
[id, userId]
);
} else {
result = await db.query(
`SELECT id, user_id, name, slug, description, settings, is_default, created_at, updated_at
FROM link_templates
WHERE id = $1`,
[id]
);
}
if (result.rows.length === 0) {
throw new Error('Template not found');
}
return result.rows[0];
});
// Create template
fastify.post('/api/templates', async (request) => {
const data = createTemplateSchema.parse(request.body);
// Generate unique slug
let slug = generateSlug();
let attempts = 0;
while (attempts < 10) {
const existing = await db.query(
'SELECT id FROM link_templates WHERE slug = $1',
[slug]
);
if (existing.rows.length === 0) {
break;
}
slug = generateSlug();
attempts++;
}
if (attempts >= 10) {
throw new Error('Unable to generate unique template slug. Please try again.');
}
// If setting as default, unset other defaults
if (data.isDefault) {
if (data.userId) {
await db.query(
'UPDATE link_templates SET is_default = false WHERE user_id = $1',
[data.userId]
);
} else {
await db.query(
'UPDATE link_templates SET is_default = false WHERE user_id IS NULL'
);
}
}
const result = await db.query(
`INSERT INTO link_templates (
user_id, name, slug, description, settings, is_default
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, name, slug, description, settings, is_default, created_at, updated_at`,
[
data.userId || null,
data.name,
slug,
data.description || null,
JSON.stringify(data.settings || {}),
data.isDefault,
]
);
return result.rows[0];
});
// Update template
fastify.put('/api/templates/:id', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
const data = updateTemplateSchema.parse(request.body);
// Verify template exists
let existingResult;
if (userId) {
existingResult = await db.query(
'SELECT id FROM link_templates WHERE id = $1 AND user_id = $2',
[id, userId]
);
} else {
existingResult = await db.query(
'SELECT id FROM link_templates WHERE id = $1',
[id]
);
}
if (existingResult.rows.length === 0) {
throw new Error('Template not found');
}
// If setting as default, unset other defaults
if (data.isDefault) {
if (userId) {
await db.query(
'UPDATE link_templates SET is_default = false WHERE user_id = $1 AND id != $2',
[userId, id]
);
} else {
await db.query(
'UPDATE link_templates SET is_default = false WHERE id != $1',
[id]
);
}
}
// Build update query dynamically
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (data.name !== undefined) {
updates.push(`name = $${paramIndex}`);
values.push(data.name);
paramIndex++;
}
if (data.description !== undefined) {
updates.push(`description = $${paramIndex}`);
values.push(data.description);
paramIndex++;
}
if (data.settings !== undefined) {
updates.push(`settings = $${paramIndex}`);
values.push(JSON.stringify(data.settings));
paramIndex++;
}
if (data.isDefault !== undefined) {
updates.push(`is_default = $${paramIndex}`);
values.push(data.isDefault);
paramIndex++;
}
if (updates.length === 0) {
throw new Error('No updates provided');
}
updates.push('updated_at = NOW()');
values.push(id);
let whereClause = `WHERE id = $${paramIndex}`;
if (userId) {
values.push(userId);
whereClause += ` AND user_id = $${paramIndex + 1}`;
}
const result = await db.query(
`UPDATE link_templates
SET ${updates.join(', ')}
${whereClause}
RETURNING id, user_id, name, slug, description, settings, is_default, created_at, updated_at`,
values
);
return result.rows[0];
});
// Delete template
fastify.delete('/api/templates/:id', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
// Check if template has any links
const linksCount = await db.query(
'SELECT COUNT(*) as count FROM links WHERE template_id = $1',
[id]
);
if (parseInt(linksCount.rows[0].count) > 0) {
throw new Error('Cannot delete template that has links assigned to it. Please reassign or delete the links first.');
}
let result;
if (userId) {
result = await db.query(
'DELETE FROM link_templates WHERE id = $1 AND user_id = $2 RETURNING id',
[id, userId]
);
} else {
result = await db.query(
'DELETE FROM link_templates WHERE id = $1 RETURNING id',
[id]
);
}
if (result.rows.length === 0) {
throw new Error('Template not found');
}
return { success: true };
});
// Set template as default
fastify.put('/api/templates/:id/set-default', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
// Verify template exists
let existingResult;
if (userId) {
existingResult = await db.query(
'SELECT id FROM link_templates WHERE id = $1 AND user_id = $2',
[id, userId]
);
} else {
existingResult = await db.query(
'SELECT id FROM link_templates WHERE id = $1',
[id]
);
}
if (existingResult.rows.length === 0) {
throw new Error('Template not found');
}
// Unset all defaults (scoped by userId if provided)
if (userId) {
await db.query(
'UPDATE link_templates SET is_default = false WHERE user_id = $1',
[userId]
);
} else {
await db.query(
'UPDATE link_templates SET is_default = false'
);
}
// Set new default
const result = await db.query(
`UPDATE link_templates
SET is_default = true, updated_at = NOW()
WHERE id = $1
RETURNING id, user_id, name, slug, description, settings, is_default, created_at, updated_at`,
[id]
);
return result.rows[0];
});
}
+266
View File
@@ -0,0 +1,266 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { db } from '../lib/database.js';
import { generateWebhookSecret } from '../lib/webhook.js';
import type { Webhook, WebhookEvent } from '../types/index.js';
const webhookEventSchema = z.enum(['click_event', 'install_event', 'conversion_event']);
const createWebhookSchema = z.object({
userId: z.string().uuid().optional(),
name: z.string().min(1).max(255),
url: z.string().url(),
events: z.array(webhookEventSchema).min(1),
headers: z.record(z.string()).optional(),
retryCount: z.number().int().min(1).max(10).optional(),
timeoutMs: z.number().int().min(1000).max(60000).optional(),
});
const updateWebhookSchema = z.object({
name: z.string().min(1).max(255).optional(),
url: z.string().url().optional(),
events: z.array(webhookEventSchema).min(1).optional(),
isActive: z.boolean().optional(),
headers: z.record(z.string()).optional(),
retryCount: z.number().int().min(1).max(10).optional(),
timeoutMs: z.number().int().min(1000).max(60000).optional(),
});
export async function webhookRoutes(fastify: FastifyInstance) {
// Get all webhooks (optionally filtered by userId)
fastify.get('/api/webhooks', async (request: FastifyRequest<{
Querystring: { userId?: string }
}>) => {
const { userId } = request.query;
let result;
if (userId) {
result = await db.query(
`SELECT id, user_id, name, url, events, is_active, retry_count, timeout_ms, headers, created_at, updated_at
FROM webhooks
WHERE user_id = $1
ORDER BY created_at DESC`,
[userId]
);
} else {
result = await db.query(
`SELECT id, user_id, name, url, events, is_active, retry_count, timeout_ms, headers, created_at, updated_at
FROM webhooks
ORDER BY created_at DESC`
);
}
// Don't expose secrets in list view
return result.rows;
});
// Get a single webhook with secret
fastify.get('/api/webhooks/:id', async (request: FastifyRequest<{
Params: { id: string }
Querystring: { userId?: string }
}>) => {
const { id } = request.params;
const { userId } = request.query;
let result;
if (userId) {
result = await db.query(
'SELECT * FROM webhooks WHERE id = $1 AND user_id = $2',
[id, userId]
);
} else {
result = await db.query(
'SELECT * FROM webhooks WHERE id = $1',
[id]
);
}
if (result.rows.length === 0) {
throw new Error('Webhook not found');
}
return result.rows[0];
});
// Create a new webhook
fastify.post('/api/webhooks', async (request: FastifyRequest) => {
const data = createWebhookSchema.parse(request.body);
// Generate secure random secret
const secret = generateWebhookSecret();
const result = await db.query(
`INSERT INTO webhooks (user_id, name, url, secret, events, retry_count, timeout_ms, headers)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
data.userId || null,
data.name,
data.url,
secret,
data.events,
data.retryCount || 3,
data.timeoutMs || 10000,
JSON.stringify(data.headers || {}),
]
);
return result.rows[0];
});
// Update a webhook
fastify.put('/api/webhooks/:id', async (request: FastifyRequest<{
Params: { id: string }
Querystring: { userId?: string }
}>) => {
const { id } = request.params;
const { userId } = request.query;
const data = updateWebhookSchema.parse(request.body);
// Build dynamic update query
const updates: string[] = [];
const values: any[] = [];
let paramCount = 1;
if (data.name !== undefined) {
updates.push(`name = $${paramCount++}`);
values.push(data.name);
}
if (data.url !== undefined) {
updates.push(`url = $${paramCount++}`);
values.push(data.url);
}
if (data.events !== undefined) {
updates.push(`events = $${paramCount++}`);
values.push(data.events);
}
if (data.isActive !== undefined) {
updates.push(`is_active = $${paramCount++}`);
values.push(data.isActive);
}
if (data.headers !== undefined) {
updates.push(`headers = $${paramCount++}`);
values.push(JSON.stringify(data.headers));
}
if (data.retryCount !== undefined) {
updates.push(`retry_count = $${paramCount++}`);
values.push(data.retryCount);
}
if (data.timeoutMs !== undefined) {
updates.push(`timeout_ms = $${paramCount++}`);
values.push(data.timeoutMs);
}
if (updates.length === 0) {
throw new Error('No fields to update');
}
updates.push(`updated_at = NOW()`);
values.push(id);
let whereClause = `WHERE id = $${paramCount++}`;
if (userId) {
values.push(userId);
whereClause += ` AND user_id = $${paramCount}`;
}
const result = await db.query(
`UPDATE webhooks
SET ${updates.join(', ')}
${whereClause}
RETURNING *`,
values
);
if (result.rows.length === 0) {
throw new Error('Webhook not found');
}
return result.rows[0];
});
// Delete a webhook
fastify.delete('/api/webhooks/:id', async (request: FastifyRequest<{
Params: { id: string }
Querystring: { userId?: string }
}>) => {
const { id } = request.params;
const { userId } = request.query;
let result;
if (userId) {
result = await db.query(
'DELETE FROM webhooks WHERE id = $1 AND user_id = $2 RETURNING id',
[id, userId]
);
} else {
result = await db.query(
'DELETE FROM webhooks WHERE id = $1 RETURNING id',
[id]
);
}
if (result.rows.length === 0) {
throw new Error('Webhook not found');
}
return { success: true };
});
// Test a webhook
fastify.post('/api/webhooks/:id/test', async (request: FastifyRequest<{
Params: { id: string }
Querystring: { userId?: string }
}>) => {
const { id } = request.params;
const { userId } = request.query;
// Get webhook
let webhookResult;
if (userId) {
webhookResult = await db.query(
'SELECT * FROM webhooks WHERE id = $1 AND user_id = $2',
[id, userId]
);
} else {
webhookResult = await db.query(
'SELECT * FROM webhooks WHERE id = $1',
[id]
);
}
if (webhookResult.rows.length === 0) {
throw new Error('Webhook not found');
}
const webhook: Webhook = webhookResult.rows[0];
// Create test payload
const testPayload = {
event: 'click_event' as WebhookEvent,
event_id: '00000000-0000-0000-0000-000000000000',
timestamp: new Date().toISOString(),
data: {
id: '00000000-0000-0000-0000-000000000000',
linkId: '00000000-0000-0000-0000-000000000000',
clickedAt: new Date().toISOString(),
ipAddress: '127.0.0.1',
userAgent: 'LinkForty-Test/1.0',
deviceType: 'web',
platform: 'test',
countryCode: 'US',
},
};
// Deliver webhook synchronously for testing
const { deliverWebhook } = await import('../lib/webhook.js');
const result = await deliverWebhook(webhook, testPayload);
return {
success: result.success,
statusCode: result.responseStatus,
responseBody: result.responseBody,
error: result.errorMessage,
};
});
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Well-Known Routes for @linkforty/core
*
* Purpose: Serve domain verification files for iOS Universal Links and Android App Links
*
* Configuration via Environment Variables:
* - IOS_TEAM_ID: Your Apple Developer Team ID (e.g., "ABC123XYZ")
* - IOS_BUNDLE_ID: Your iOS app bundle identifier (e.g., "com.example.app")
* - ANDROID_PACKAGE_NAME: Your Android package name (e.g., "com.example.app")
* - ANDROID_SHA256_FINGERPRINTS: Comma-separated SHA-256 fingerprints (e.g., "AA:BB:CC:...,DD:EE:FF:...")
*/
import { FastifyInstance } from 'fastify';
export async function wellKnownRoutes(fastify: FastifyInstance) {
/**
* Apple App Site Association (AASA)
* Used by iOS to verify Universal Links
* https://developer.apple.com/documentation/xcode/supporting-associated-domains
*
* Endpoint: GET /.well-known/apple-app-site-association
*/
fastify.get('/.well-known/apple-app-site-association', async (request, reply) => {
const teamId = process.env.IOS_TEAM_ID;
const bundleId = process.env.IOS_BUNDLE_ID;
if (!teamId || !bundleId) {
return reply.status(404).send({
error: 'Configuration missing',
message: 'iOS app configuration not found. Please set IOS_TEAM_ID and IOS_BUNDLE_ID environment variables.',
docs: 'https://docs.linkforty.com/guides/sdk-integration#ios-universal-links'
});
}
const aasa = {
applinks: {
apps: [],
details: [
{
appID: `${teamId}.${bundleId}`,
paths: ['*'] // Match all paths
}
]
}
};
// AASA file must be served:
// 1. Without .json extension
// 2. With application/json content-type
// 3. Over HTTPS (in production)
return reply
.header('Content-Type', 'application/json')
.send(aasa);
});
/**
* Digital Asset Links (assetlinks.json)
* Used by Android to verify App Links
* https://developer.android.com/training/app-links/verify-android-applinks
*
* Endpoint: GET /.well-known/assetlinks.json
*/
fastify.get('/.well-known/assetlinks.json', async (request, reply) => {
const packageName = process.env.ANDROID_PACKAGE_NAME;
const fingerprintsEnv = process.env.ANDROID_SHA256_FINGERPRINTS;
if (!packageName || !fingerprintsEnv) {
return reply.status(404).send({
error: 'Configuration missing',
message: 'Android app configuration not found. Please set ANDROID_PACKAGE_NAME and ANDROID_SHA256_FINGERPRINTS environment variables.',
docs: 'https://docs.linkforty.com/guides/sdk-integration#android-app-links'
});
}
// Parse comma-separated fingerprints
const fingerprints = fingerprintsEnv
.split(',')
.map(fp => fp.trim())
.filter(Boolean);
if (fingerprints.length === 0) {
return reply.status(500).send({
error: 'Invalid configuration',
message: 'ANDROID_SHA256_FINGERPRINTS is empty or invalid. Must be comma-separated list of SHA-256 fingerprints.',
example: 'ANDROID_SHA256_FINGERPRINTS=AA:BB:CC:DD:EE:FF:...,11:22:33:44:55:66:...'
});
}
const assetlinks = [
{
relation: ['delegate_permission/common.handle_all_urls'],
target: {
namespace: 'android_app',
package_name: packageName,
sha256_cert_fingerprints: fingerprints
}
}
];
return reply
.header('Content-Type', 'application/json')
.send(assetlinks);
});
}