code
This commit is contained in:
@@ -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),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, '"').replace(/</g, '<');
|
||||
const safeFallbackUrl = fallbackUrl.replace(/"/g, '"').replace(/</g, '<');
|
||||
const safeTitle = (title || 'the app').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user