This commit is contained in:
2026-07-31 11:44:56 +09:00
parent 7dc38641b2
commit d338b91e42
32 changed files with 7323 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
import Fastify, { FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import redis from '@fastify/redis';
import { initializeDatabase, DatabaseOptions } from './lib/database.js';
import { redirectRoutes } from './routes/redirect.js';
import { linkRoutes } from './routes/links.js';
import { analyticsRoutes } from './routes/analytics.js';
import { sdkRoutes } from './routes/sdk.js';
import { webhookRoutes } from './routes/webhooks.js';
import { templateRoutes } from './routes/templates.js';
import { qrRoutes } from './routes/qr.js';
import { wellKnownRoutes } from './routes/well-known.js';
/**
* Configuration options for creating a LinkForty server instance.
*/
export interface ServerOptions {
database?: DatabaseOptions;
redis?: {
url: string;
};
cors?: {
origin: string | string[];
};
logger?: boolean;
/** When true or a number (proxy hop count), Fastify trusts X-Forwarded-For so request.ip is the real client IP. Set when behind a reverse proxy. */
trustProxy?: boolean | number;
}
/**
* Create and configure a LinkForty Fastify server instance.
*
* Registers CORS, optional Redis, the database connection, and all built-in
* route plugins. The returned instance is ready to call `listen()` on.
*
* @param options - Server configuration (database, Redis, CORS, logger).
* @returns A configured Fastify instance with all routes registered.
*/
export async function createServer(options: ServerOptions = {}) {
const fastify = Fastify({
logger: options.logger !== undefined ? options.logger : true,
trustProxy: options.trustProxy,
});
// CORS
await fastify.register(cors, {
origin: options.cors?.origin || '*',
});
// Redis (optional)
if (options.redis?.url) {
await fastify.register(redis, {
url: options.redis.url,
});
}
// Database
await initializeDatabase(options.database);
// Routes
await fastify.register(wellKnownRoutes);
await fastify.register(redirectRoutes);
await fastify.register(linkRoutes);
await fastify.register(analyticsRoutes);
await fastify.register(sdkRoutes);
await fastify.register(webhookRoutes);
await fastify.register(templateRoutes);
await fastify.register(qrRoutes);
return fastify;
}
// Re-export utilities and types
export * from './lib/utils.js';
export * from './lib/client-ip.js';
export * from './lib/database.js';
export * from './lib/fingerprint.js';
export * from './lib/webhook.js';
export * from './lib/event-emitter.js';
export * from './types/index.js';
export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes } from './routes/index.js';
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect, afterEach } from 'vitest';
import { classifyBot, edgeBotSignal } from './bot-detection.js';
const CHROME = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
describe('classifyBot', () => {
it('flags known crawlers/scrapers by user-agent', () => {
expect(classifyBot('Googlebot/2.1 (+http://www.google.com/bot.html)', 'GET')).toEqual({ isBot: true, reason: 'ua' });
expect(classifyBot('facebookexternalhit/1.1', 'GET').isBot).toBe(true);
expect(classifyBot('curl/8.4.0', 'GET').isBot).toBe(true);
});
it('does not flag a normal browser', () => {
expect(classifyBot(CHROME, 'GET')).toEqual({ isBot: false, reason: null });
});
it('flags HEAD/OPTIONS as non-human, case-insensitively', () => {
expect(classifyBot(CHROME, 'HEAD')).toEqual({ isBot: true, reason: 'method' });
expect(classifyBot(CHROME, 'options')).toEqual({ isBot: true, reason: 'method' });
});
it('honors the edge signal with highest authority', () => {
// Edge wins even for a normal browser UA on a GET.
expect(classifyBot(CHROME, 'GET', true)).toEqual({ isBot: true, reason: 'edge' });
// An explicit false edge signal does not override real UA/method detection.
expect(classifyBot('Googlebot/2.1', 'GET', false)).toEqual({ isBot: true, reason: 'ua' });
});
it('handles a missing/empty user-agent', () => {
expect(classifyBot(undefined, 'GET')).toEqual({ isBot: false, reason: null });
expect(classifyBot('', 'GET').isBot).toBe(false);
});
});
describe('edgeBotSignal', () => {
afterEach(() => {
delete process.env.TRUST_EDGE_BOT_HEADER;
});
it('is ignored unless TRUST_EDGE_BOT_HEADER=true', () => {
expect(edgeBotSignal('1')).toBeUndefined();
});
it('parses the header only when trusted', () => {
process.env.TRUST_EDGE_BOT_HEADER = 'true';
expect(edgeBotSignal('1')).toBe(true);
expect(edgeBotSignal('true')).toBe(true);
expect(edgeBotSignal('TRUE')).toBe(true);
expect(edgeBotSignal('0')).toBe(false);
expect(edgeBotSignal(['1'])).toBe(true);
expect(edgeBotSignal(undefined)).toBeUndefined();
});
});
+51
View File
@@ -0,0 +1,51 @@
import { isbot } from 'isbot';
/**
* Bot classification for click ingestion.
*
* A click's "is this a real human?" quality is determined by the request that
* created it, so it's classified here at write time and persisted on the
* click_events row. Downstream analytics simply read the stored flag — they
* never re-detect (the raw request signals aren't available at read time).
*
* Signals, in order of authority:
* 1. edge — a trusted upstream (reverse proxy / CDN bot management) already
* classified this request as a bot. Only honored when the caller
* opts in (the header must come from a trusted proxy, not the
* client). Highest confidence.
* 2. method — HEAD / OPTIONS requests are link probes/prefetch, never a human
* opening a link.
* 3. ua — the `isbot` user-agent database (crawlers, scrapers, monitors).
*/
export type BotReason = 'edge' | 'method' | 'ua';
export interface BotClassification {
isBot: boolean;
reason: BotReason | null;
}
const NON_HUMAN_METHODS = new Set(['HEAD', 'OPTIONS']);
export function classifyBot(
userAgent: string | undefined,
method: string | undefined,
edgeIsBot?: boolean
): BotClassification {
if (edgeIsBot === true) return { isBot: true, reason: 'edge' };
if (method && NON_HUMAN_METHODS.has(method.toUpperCase())) return { isBot: true, reason: 'method' };
if (isbot(userAgent ?? '')) return { isBot: true, reason: 'ua' };
return { isBot: false, reason: null };
}
/**
* Resolve the optional edge bot signal from request headers. Only trusted when
* `TRUST_EDGE_BOT_HEADER=true` — otherwise a client could set the header to
* mark its own clicks as bots. Deployments behind a proxy that authoritatively
* sets (and strips client-supplied copies of) `x-lf-bot` can enable it.
*/
export function edgeBotSignal(headerValue: string | string[] | undefined): boolean | undefined {
if (process.env.TRUST_EDGE_BOT_HEADER !== 'true') return undefined;
const v = Array.isArray(headerValue) ? headerValue[0] : headerValue;
if (v === undefined) return undefined;
return v === '1' || v.toLowerCase() === 'true';
}
+129
View File
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import type { FastifyRequest } from 'fastify';
import { getClientIp } from './client-ip';
declare global {
var __capturedInstallFingerprint: { ipAddress: string } | null;
}
describe('getClientIp', () => {
it('returns request.ip when set', () => {
const request = { ip: '192.168.1.1' } as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('192.168.1.1');
});
it('returns raw.socket.remoteAddress when request.ip is undefined', () => {
const request = {
ip: undefined,
raw: { socket: { remoteAddress: '10.0.0.2' } },
} as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('10.0.0.2');
});
it('unwraps IPv6-mapped IPv4', () => {
const request = { ip: '::ffff:192.168.1.1' } as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('192.168.1.1');
});
it('returns empty string when neither ip nor socket.remoteAddress is available', () => {
const request = { ip: undefined, raw: { socket: {} } } as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('');
});
it('returns empty string when request.raw has no socket', () => {
const request = { ip: undefined, raw: {} } as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('');
});
});
describe('getClientIp with TRUSTED_CLIENT_IP_HEADER', () => {
const ORIGINAL = process.env.TRUSTED_CLIENT_IP_HEADER;
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.TRUSTED_CLIENT_IP_HEADER;
else process.env.TRUSTED_CLIENT_IP_HEADER = ORIGINAL;
});
it('prefers the configured header over request.ip', () => {
process.env.TRUSTED_CLIENT_IP_HEADER = 'cf-connecting-ip';
const request = {
ip: '162.158.23.75', // a Cloudflare edge IP
headers: { 'cf-connecting-ip': '203.0.113.50' },
} as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('203.0.113.50');
});
it('falls back to request.ip when the configured header is absent', () => {
process.env.TRUSTED_CLIENT_IP_HEADER = 'cf-connecting-ip';
const request = { ip: '203.0.113.50', headers: {} } as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('203.0.113.50');
});
it('takes the first entry of a comma-separated header value', () => {
process.env.TRUSTED_CLIENT_IP_HEADER = 'true-client-ip';
const request = {
ip: '10.0.0.1',
headers: { 'true-client-ip': '203.0.113.50, 70.0.0.1' },
} as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('203.0.113.50');
});
it('ignores the header when the env var is unset (default behavior)', () => {
delete process.env.TRUSTED_CLIENT_IP_HEADER;
const request = {
ip: '203.0.113.50',
headers: { 'cf-connecting-ip': '1.2.3.4' },
} as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('203.0.113.50');
});
});
describe('getClientIp with Fastify trustProxy (proxied request)', () => {
it('uses X-Forwarded-For when trustProxy is true', async () => {
const Fastify = (await import('fastify')).default;
const { getClientIp: getIp } = await import('./client-ip.js');
const app = Fastify({ trustProxy: true });
app.get('/ip', async (request, reply) => {
return reply.send({ ip: getIp(request) });
});
const res = await app.inject({
method: 'GET',
url: '/ip',
headers: { 'x-forwarded-for': '203.0.113.50' },
});
expect(res.statusCode).toBe(200);
const body = res.json() as { ip: string };
expect(body.ip).toBe('203.0.113.50');
});
});
vi.mock('./fingerprint.js', async (importOriginal) => {
const mod = (await importOriginal()) as Record<string, unknown>;
return {
...mod,
recordInstallEvent: vi.fn().mockImplementation(async (data: { ipAddress: string }) => {
globalThis.__capturedInstallFingerprint = data;
return { installId: 'test-id', match: null, deepLinkData: null };
}),
};
});
describe('SDK install does not trust client-provided ipAddress', () => {
it('uses connection/proxy IP for attribution, not body ipAddress', async () => {
globalThis.__capturedInstallFingerprint = null;
const Fastify = (await import('fastify')).default;
const { sdkRoutes } = await import('../routes/sdk.js');
const app = Fastify({ trustProxy: true });
await app.register(sdkRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/sdk/v1/install',
payload: { ipAddress: '1.2.3.4', userAgent: 'Mozilla/5.0 Test' },
headers: { 'x-forwarded-for': '203.0.113.50', 'content-type': 'application/json' },
});
expect(res.statusCode).toBe(200);
expect(globalThis.__capturedInstallFingerprint).not.toBeNull();
expect(globalThis.__capturedInstallFingerprint!.ipAddress).toBe('203.0.113.50');
const body = res.json() as { clientReportedIp?: string };
expect(body.clientReportedIp).toBe('1.2.3.4');
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { FastifyRequest } from 'fastify';
function normalizeIp(ip: string): string {
// IPv6-mapped IPv4: ::ffff:192.168.1.1 -> 192.168.1.1
return ip.startsWith('::ffff:') ? ip.slice(7) : ip;
}
/**
* Returns the trusted client IP for the request.
* Use this everywhere client IP is needed (targeting, attribution, fingerprinting).
*
* Behind a CDN/proxy that terminates the connection (e.g. Cloudflare), the
* left-most `X-Forwarded-For` entry that Fastify exposes as `request.ip` is not
* reliably the real client — it can be a CDN edge or NAT hop. When the proxy
* sends an authoritative client-IP header (Cloudflare's `CF-Connecting-IP`, or
* `True-Client-IP`), prefer it.
*
* Set `TRUSTED_CLIENT_IP_HEADER` to that header name to opt in (e.g.
* `cf-connecting-ip`). IMPORTANT: only enable this when the origin is reachable
* ONLY through that proxy — otherwise a direct client could spoof the header.
* When unset, behavior is unchanged (uses `request.ip`).
*/
export function getClientIp(request: FastifyRequest): string {
const headerName = process.env.TRUSTED_CLIENT_IP_HEADER?.toLowerCase().trim();
if (headerName) {
const raw = request.headers[headerName];
const value = Array.isArray(raw) ? raw[0] : raw;
if (value && typeof value === 'string') {
// Some proxies send a comma-separated list — take the first entry.
const first = value.split(',')[0]?.trim();
if (first) return normalizeIp(first);
}
}
const ip = request.ip ?? request.raw.socket?.remoteAddress;
if (ip && typeof ip === 'string') {
return normalizeIp(ip);
}
return '';
}
+534
View File
@@ -0,0 +1,534 @@
import pg from 'pg';
const { Pool } = pg;
export interface DatabaseOptions {
url?: string;
pool?: {
min?: number;
max?: number;
};
}
export let db: pg.Pool;
// Helper function to wait for a specified time
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Retry database connection with exponential backoff
async function connectWithRetry(maxRetries: number = 10, baseDelay: number = 1000): Promise<pg.PoolClient> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const client = await db.connect();
console.log('Database connection established successfully');
return client;
} catch (error: any) {
if (error.code === 'ECONNREFUSED' && attempt < maxRetries) {
const delay = baseDelay * Math.pow(2, attempt - 1); // Exponential backoff
console.log(`Database connection attempt ${attempt} failed. Retrying in ${delay}ms...`);
await sleep(delay);
} else {
console.error('Failed to connect to database after all retries:', error);
throw error;
}
}
}
throw new Error('Max retries exceeded');
}
// Initialize database schema
export async function initializeDatabase(options: DatabaseOptions = {}) {
// Initialize pool
db = new Pool({
connectionString: options.url || process.env.DATABASE_URL || 'postgresql://postgres:password@localhost:5432/linkforty',
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
min: options.pool?.min || 2,
max: options.pool?.max || 10,
});
const client = await connectWithRetry();
try {
// Link templates table (must be created before links, which references it)
await client.query(`
CREATE TABLE IF NOT EXISTS link_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
settings JSONB DEFAULT '{}',
is_default BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
`);
// Links table
await client.query(`
CREATE TABLE IF NOT EXISTS links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
short_code VARCHAR(20) UNIQUE NOT NULL,
original_url TEXT NOT NULL,
title VARCHAR(255),
description TEXT,
ios_url TEXT,
android_url TEXT,
web_fallback_url TEXT,
utm_parameters JSONB DEFAULT '{}',
targeting_rules JSONB DEFAULT '{}',
is_active BOOLEAN DEFAULT true,
expires_at TIMESTAMP,
append_click_id BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
`);
// Click events table
await client.query(`
CREATE TABLE IF NOT EXISTS click_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
link_id UUID NOT NULL REFERENCES links(id) ON DELETE CASCADE,
clicked_at TIMESTAMP DEFAULT NOW(),
ip_address INET,
user_agent TEXT,
device_type VARCHAR(20),
platform VARCHAR(20),
country_code CHAR(2),
country_name VARCHAR(100),
region VARCHAR(100),
city VARCHAR(100),
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
timezone VARCHAR(100),
utm_source VARCHAR(255),
utm_medium VARCHAR(255),
utm_campaign VARCHAR(255),
referrer TEXT,
is_bot BOOLEAN NOT NULL DEFAULT false,
bot_reason VARCHAR(16)
)
`);
// Device fingerprints table - stores individual fingerprint components for probabilistic matching
await client.query(`
CREATE TABLE IF NOT EXISTS device_fingerprints (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
click_id UUID NOT NULL REFERENCES click_events(id) ON DELETE CASCADE,
fingerprint_hash VARCHAR(64) NOT NULL,
ip_address INET,
user_agent TEXT,
timezone VARCHAR(100),
language VARCHAR(10),
screen_width INTEGER,
screen_height INTEGER,
platform VARCHAR(50),
platform_version VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW()
)
`);
// Install events table - tracks app installations and matches to clicks via fingerprinting
await client.query(`
CREATE TABLE IF NOT EXISTS install_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
link_id UUID REFERENCES links(id) ON DELETE SET NULL,
click_id UUID REFERENCES click_events(id) ON DELETE SET NULL,
fingerprint_hash VARCHAR(64) NOT NULL,
confidence_score DECIMAL(5, 2),
attribution_method VARCHAR(20),
matched_factors TEXT[],
installed_at TIMESTAMP DEFAULT NOW(),
first_open_at TIMESTAMP,
deep_link_retrieved BOOLEAN DEFAULT false,
deep_link_data JSONB DEFAULT '{}',
attribution_window_hours INTEGER DEFAULT 168,
ip_address INET,
user_agent TEXT,
timezone VARCHAR(100),
language VARCHAR(10),
screen_width INTEGER,
screen_height INTEGER,
platform VARCHAR(50),
platform_version VARCHAR(50),
device_id VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW()
)
`);
// In-app events table - tracks conversion events from mobile apps
await client.query(`
CREATE TABLE IF NOT EXISTS in_app_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
install_id UUID NOT NULL REFERENCES install_events(id) ON DELETE CASCADE,
event_name VARCHAR(255) NOT NULL,
event_data JSONB DEFAULT '{}',
event_timestamp TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
`);
// Webhooks table - stores webhook configurations for event postbacks
await client.query(`
CREATE TABLE IF NOT EXISTS webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
name VARCHAR(255) NOT NULL,
url TEXT NOT NULL,
secret VARCHAR(255) NOT NULL,
events TEXT[] NOT NULL DEFAULT '{}',
is_active BOOLEAN DEFAULT true,
retry_count INTEGER DEFAULT 3,
timeout_ms INTEGER DEFAULT 10000,
headers JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
`);
// Add template_id column to links table
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='template_id'
) THEN
ALTER TABLE links ADD COLUMN template_id UUID REFERENCES link_templates(id) ON DELETE SET NULL;
END IF;
END $$;
`);
// Add description column to existing links table if it doesn't exist
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='description'
) THEN
ALTER TABLE links ADD COLUMN description TEXT;
END IF;
END $$;
`);
// Add Open Graph (OG) tag columns for social media previews
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='og_title'
) THEN
ALTER TABLE links ADD COLUMN og_title VARCHAR(255);
END IF;
END $$;
`);
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='og_description'
) THEN
ALTER TABLE links ADD COLUMN og_description TEXT;
END IF;
END $$;
`);
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='og_image_url'
) THEN
ALTER TABLE links ADD COLUMN og_image_url TEXT;
END IF;
END $$;
`);
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='og_type'
) THEN
ALTER TABLE links ADD COLUMN og_type VARCHAR(50) DEFAULT 'website';
END IF;
END $$;
`);
// Add attribution window column for configurable install attribution windows
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='attribution_window_hours'
) THEN
ALTER TABLE links ADD COLUMN attribution_window_hours INTEGER DEFAULT 168;
END IF;
END $$;
`);
// Rename ios_url to ios_app_store_url for clarity
await client.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='ios_url'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='ios_app_store_url'
) THEN
ALTER TABLE links RENAME COLUMN ios_url TO ios_app_store_url;
END IF;
END $$;
`);
// Rename android_url to android_app_store_url for clarity
await client.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='android_url'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='android_app_store_url'
) THEN
ALTER TABLE links RENAME COLUMN android_url TO android_app_store_url;
END IF;
END $$;
`);
// Add app URL scheme column (same for iOS and Android per industry best practice)
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='app_scheme'
) THEN
ALTER TABLE links ADD COLUMN app_scheme VARCHAR(255);
END IF;
END $$;
`);
// Add iOS Universal Link URL column
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='ios_universal_link'
) THEN
ALTER TABLE links ADD COLUMN ios_universal_link TEXT;
END IF;
END $$;
`);
// Add Android App Link URL column
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='android_app_link'
) THEN
ALTER TABLE links ADD COLUMN android_app_link TEXT;
END IF;
END $$;
`);
// Add deep link path column for in-app navigation
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='deep_link_path'
) THEN
ALTER TABLE links ADD COLUMN deep_link_path TEXT;
END IF;
END $$;
`);
// Add deep link parameters column for custom app parameters
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='deep_link_parameters'
) THEN
ALTER TABLE links ADD COLUMN deep_link_parameters JSONB DEFAULT '{}';
END IF;
END $$;
`);
// Click correlation id passthrough (opt-in, default off). When enabled per
// link, the redirect appends ?lf_click=<click id> to web/HTTPS destinations
// so a downstream analytics tool on the landing page can tie the landing
// visit back to the exact originating click. Off by default so the redirect
// never alters a destination's query string unless explicitly opted in.
// App-scheme/deep-link destinations are never appended to regardless.
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='append_click_id'
) THEN
ALTER TABLE links ADD COLUMN append_click_id BOOLEAN DEFAULT false;
END IF;
END $$;
`);
// Bot classification columns on click_events (SIT-298). Classified at
// ingestion (see lib/bot-detection.ts) and persisted so every consumer reads
// one consistent flag; analytics excludes is_bot rows. Backward compatible:
// legacy rows default to is_bot=false and age out of the retention window.
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='click_events' AND column_name='is_bot') THEN
ALTER TABLE click_events ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='click_events' AND column_name='bot_reason') THEN
ALTER TABLE click_events ADD COLUMN bot_reason VARCHAR(16);
END IF;
END $$;
`);
// Attribution metadata on install_events (SIT-296): how the install was
// attributed ('fingerprint' | 'none') and which fingerprint signals matched.
// Makes attribution quality measurable. Backward compatible (NULL until set).
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='attribution_method') THEN
ALTER TABLE install_events ADD COLUMN attribution_method VARCHAR(20);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='matched_factors') THEN
ALTER TABLE install_events ADD COLUMN matched_factors TEXT[];
END IF;
END $$;
`);
// Last-click attribution columns on in_app_events (SIT-237).
// Events (screen views + custom events) are attributed to the deep link that
// drove them, not just the original install link. The SDK stamps each event
// with the active link, when it opened, and the app-open session; the window
// (organic vs attributed) is applied at query time. Nullable + backward
// compatible: legacy/organic rows stay null and fall back to the install link.
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='attributed_link_id') THEN
ALTER TABLE in_app_events ADD COLUMN attributed_link_id UUID REFERENCES links(id) ON DELETE SET NULL;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='attributed_click_id') THEN
ALTER TABLE in_app_events ADD COLUMN attributed_click_id UUID;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='attributed_at') THEN
ALTER TABLE in_app_events ADD COLUMN attributed_at TIMESTAMP;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='session_id') THEN
ALTER TABLE in_app_events ADD COLUMN session_id UUID;
END IF;
END $$;
`);
// SDK identity columns (SIT-235) — name + version of the SDK that sent the
// install/event, for SDK version diagnostics. Persisted on BOTH tables:
// install_events (version at install time) and in_app_events because an app
// that updates keeps its original install row but sends events with the new
// version — so version-fragmentation / outdated-version checks must read from
// the event stream. Nullable + backward compatible (older SDKs omit them).
// NOTE: no index on sdk_name/sdk_version yet — deferred until a consumer
// aggregates them (e.g. "installs by version"), so the index can match the
// real query shape.
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='sdk_name') THEN
ALTER TABLE install_events ADD COLUMN sdk_name VARCHAR(50);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='install_events' AND column_name='sdk_version') THEN
ALTER TABLE install_events ADD COLUMN sdk_version VARCHAR(50);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='sdk_name') THEN
ALTER TABLE in_app_events ADD COLUMN sdk_name VARCHAR(50);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='in_app_events' AND column_name='sdk_version') THEN
ALTER TABLE in_app_events ADD COLUMN sdk_version VARCHAR(50);
END IF;
END $$;
`);
// Create indexes for performance
await client.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_links_short_code ON links(short_code)');
await client.query('CREATE INDEX IF NOT EXISTS idx_links_user_id ON links(user_id)');
await client.query('CREATE INDEX IF NOT EXISTS idx_links_created_at ON links(created_at DESC)');
await client.query('CREATE INDEX IF NOT EXISTS idx_clicks_link_id ON click_events(link_id)');
await client.query('CREATE INDEX IF NOT EXISTS idx_clicks_timestamp ON click_events(clicked_at DESC)');
await client.query('CREATE INDEX IF NOT EXISTS idx_clicks_link_date ON click_events(link_id, clicked_at DESC)');
// Partial index for the common analytics filter (human clicks only, is_bot = false).
await client.query('CREATE INDEX IF NOT EXISTS idx_clicks_human_link_date ON click_events(link_id, clicked_at DESC) WHERE is_bot = false');
// Indexes for deferred deep linking
await client.query('CREATE INDEX IF NOT EXISTS idx_fingerprints_hash ON device_fingerprints(fingerprint_hash)');
await client.query('CREATE INDEX IF NOT EXISTS idx_fingerprints_click_id ON device_fingerprints(click_id)');
await client.query('CREATE INDEX IF NOT EXISTS idx_installs_fingerprint ON install_events(fingerprint_hash)');
await client.query('CREATE INDEX IF NOT EXISTS idx_installs_link_id ON install_events(link_id)');
await client.query('CREATE INDEX IF NOT EXISTS idx_installs_timestamp ON install_events(installed_at DESC)');
await client.query('CREATE INDEX IF NOT EXISTS idx_installs_link_date ON install_events(link_id, installed_at DESC)');
// Indexes for link templates
await client.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_link_templates_slug ON link_templates(slug)');
await client.query('CREATE INDEX IF NOT EXISTS idx_link_templates_user_id ON link_templates(user_id)');
await client.query('CREATE INDEX IF NOT EXISTS idx_links_template_id ON links(template_id)');
// Indexes for webhooks
await client.query('CREATE INDEX IF NOT EXISTS idx_webhooks_user_id ON webhooks(user_id)');
await client.query('CREATE INDEX IF NOT EXISTS idx_webhooks_active ON webhooks(is_active) WHERE is_active = true');
// Indexes for in-app events
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_install_id ON in_app_events(install_id)');
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_name ON in_app_events(event_name)');
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_timestamp ON in_app_events(event_timestamp DESC)');
// Attribution lookups: per-link conversion aggregation + per-session screen flow
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_attributed_link ON in_app_events(attributed_link_id, event_timestamp DESC)');
// Partial: session_id is null for legacy/organic in-app events (the majority);
// per-session screen-flow lookups always filter `session_id IS NOT NULL`.
await client.query('CREATE INDEX IF NOT EXISTS idx_in_app_events_session ON in_app_events(session_id) WHERE session_id IS NOT NULL');
// Add deep_link_parameters column for custom deep link parameters
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='links' AND column_name='deep_link_parameters'
) THEN
ALTER TABLE links ADD COLUMN deep_link_parameters JSONB DEFAULT '{}';
END IF;
END $$;
`);
console.log('Database schema initialized successfully');
} catch (error) {
console.error('Error initializing database:', error);
throw error;
} finally {
client.release();
}
}
+211
View File
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
clickEventEmitter,
emitClickEvent,
subscribeToClickEvents,
ClickEventData,
} from './event-emitter';
const mockClickEvent: ClickEventData = {
eventId: 'evt-123',
timestamp: '2026-03-10T00:00:00.000Z',
linkId: 'link-abc',
shortCode: 'abc123',
userId: 'user-1',
organizationId: 'org-1',
ipAddress: '1.2.3.4',
userAgent: 'Mozilla/5.0',
country: 'US',
city: 'New York',
deviceType: 'web',
platform: 'Windows',
browser: 'Chrome',
redirectUrl: 'https://example.com',
redirectReason: 'web_fallback',
targetingMatched: true,
utmParameters: {
source: 'newsletter',
medium: 'email',
campaign: 'spring',
},
referer: 'https://google.com',
language: 'en-US',
};
describe('clickEventEmitter', () => {
it('should be an EventEmitter instance', () => {
expect(clickEventEmitter).toBeDefined();
expect(typeof clickEventEmitter.on).toBe('function');
expect(typeof clickEventEmitter.emit).toBe('function');
expect(typeof clickEventEmitter.off).toBe('function');
});
});
describe('emitClickEvent', () => {
beforeEach(() => {
clickEventEmitter.removeAllListeners('click');
});
it('should emit a click event with the provided data', () => {
const handler = vi.fn();
clickEventEmitter.on('click', handler);
emitClickEvent(mockClickEvent);
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(mockClickEvent);
});
it('should emit to all registered listeners', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
clickEventEmitter.on('click', handler1);
clickEventEmitter.on('click', handler2);
emitClickEvent(mockClickEvent);
expect(handler1).toHaveBeenCalledOnce();
expect(handler2).toHaveBeenCalledOnce();
});
it('should emit multiple times when called multiple times', () => {
const handler = vi.fn();
clickEventEmitter.on('click', handler);
emitClickEvent(mockClickEvent);
emitClickEvent({ ...mockClickEvent, eventId: 'evt-456' });
expect(handler).toHaveBeenCalledTimes(2);
});
it('should pass the exact event data to the handler', () => {
const handler = vi.fn();
clickEventEmitter.on('click', handler);
const eventData: ClickEventData = {
...mockClickEvent,
deviceType: 'ios',
country: 'CA',
targetingMatched: false,
};
emitClickEvent(eventData);
expect(handler).toHaveBeenCalledWith(eventData);
});
it('should work with minimal required fields', () => {
const handler = vi.fn();
clickEventEmitter.on('click', handler);
const minimalEvent: ClickEventData = {
eventId: 'evt-min',
timestamp: '2026-03-10T00:00:00.000Z',
linkId: 'link-min',
shortCode: 'min',
ipAddress: '0.0.0.0',
userAgent: '',
deviceType: 'web',
redirectUrl: 'https://example.com',
redirectReason: 'default',
targetingMatched: true,
};
emitClickEvent(minimalEvent);
expect(handler).toHaveBeenCalledWith(minimalEvent);
});
});
describe('subscribeToClickEvents', () => {
beforeEach(() => {
clickEventEmitter.removeAllListeners('click');
});
it('should call the callback when a click event is emitted', () => {
const callback = vi.fn();
subscribeToClickEvents(callback);
emitClickEvent(mockClickEvent);
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith(mockClickEvent);
});
it('should return an unsubscribe function', () => {
const callback = vi.fn();
const unsubscribe = subscribeToClickEvents(callback);
expect(typeof unsubscribe).toBe('function');
});
it('should stop receiving events after unsubscribing', () => {
const callback = vi.fn();
const unsubscribe = subscribeToClickEvents(callback);
emitClickEvent(mockClickEvent);
expect(callback).toHaveBeenCalledOnce();
unsubscribe();
emitClickEvent(mockClickEvent);
expect(callback).toHaveBeenCalledOnce(); // still only once
});
it('should allow multiple subscribers independently', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const unsubscribe1 = subscribeToClickEvents(callback1);
subscribeToClickEvents(callback2);
emitClickEvent(mockClickEvent);
expect(callback1).toHaveBeenCalledOnce();
expect(callback2).toHaveBeenCalledOnce();
unsubscribe1();
emitClickEvent(mockClickEvent);
expect(callback1).toHaveBeenCalledOnce(); // no new calls
expect(callback2).toHaveBeenCalledTimes(2);
});
it('should not affect other subscribers when one unsubscribes', () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const unsubscribe1 = subscribeToClickEvents(callback1);
subscribeToClickEvents(callback2);
unsubscribe1();
emitClickEvent(mockClickEvent);
expect(callback1).not.toHaveBeenCalled();
expect(callback2).toHaveBeenCalledOnce();
});
it('should handle unsubscribe called multiple times without error', () => {
const callback = vi.fn();
const unsubscribe = subscribeToClickEvents(callback);
expect(() => {
unsubscribe();
unsubscribe();
}).not.toThrow();
});
it('should pass event data correctly to callback', () => {
const received: ClickEventData[] = [];
subscribeToClickEvents((data) => received.push(data));
const event1 = { ...mockClickEvent, eventId: 'e1', deviceType: 'ios' as const };
const event2 = { ...mockClickEvent, eventId: 'e2', deviceType: 'android' as const };
emitClickEvent(event1);
emitClickEvent(event2);
expect(received).toHaveLength(2);
expect(received[0]).toEqual(event1);
expect(received[1]).toEqual(event2);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { EventEmitter } from 'events';
/**
* Global event emitter for real-time events
* Used for broadcasting click events to WebSocket clients
*/
export const clickEventEmitter = new EventEmitter();
/**
* Click event data structure for real-time streaming
*/
export interface ClickEventData {
eventId: string;
timestamp: string;
linkId: string;
shortCode: string;
userId?: string;
organizationId?: string;
// Request details
ipAddress: string;
userAgent: string;
country?: string;
city?: string;
// Device detection
deviceType: 'ios' | 'android' | 'web';
platform?: string;
browser?: string;
// Redirect decision
redirectUrl: string;
redirectReason: string;
targetingMatched: boolean;
// UTM parameters
utmParameters?: {
source?: string;
medium?: string;
campaign?: string;
term?: string;
content?: string;
};
// Additional metadata
referer?: string;
language?: string;
}
/**
* Emit a click event for real-time streaming
*/
export function emitClickEvent(eventData: ClickEventData) {
clickEventEmitter.emit('click', eventData);
}
/**
* Subscribe to click events
* Returns unsubscribe function
*/
export function subscribeToClickEvents(
callback: (eventData: ClickEventData) => void
): () => void {
clickEventEmitter.on('click', callback);
// Return unsubscribe function
return () => {
clickEventEmitter.off('click', callback);
};
}
+364
View File
@@ -0,0 +1,364 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
// Mock the database module so tests don't require a real Postgres connection.
vi.mock('./database', () => ({
db: {
query: vi.fn(),
},
}));
import * as fingerprint from './fingerprint';
import { db } from './database';
const mockDbQuery = db.query as Mock;
const baseFingerprint = {
ipAddress: '24.5.10.100',
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36',
timezone: 'America/Los_Angeles',
language: 'en-US',
screenWidth: 1080,
screenHeight: 1920,
platform: 'Windows',
platformVersion: '10',
};
describe('generateFingerprintHash', () => {
it('produces a deterministic 64-character SHA-256 hash', () => {
const hash1 = fingerprint.generateFingerprintHash(baseFingerprint);
const hash2 = fingerprint.generateFingerprintHash(baseFingerprint);
expect(hash1).toHaveLength(64);
expect(hash1).toBe(hash2);
});
it('produces different hashes for different data', () => {
const other = { ...baseFingerprint, ipAddress: '10.0.0.1' };
const hash1 = fingerprint.generateFingerprintHash(baseFingerprint);
const hash2 = fingerprint.generateFingerprintHash(other);
expect(hash1).not.toBe(hash2);
});
});
describe('calculateConfidenceScore', () => {
it('returns 0 score when nothing matches', () => {
const a = { ...baseFingerprint };
const b = {
...baseFingerprint,
ipAddress: '10.0.0.1',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
timezone: 'Asia/Tokyo',
language: 'ja-JP',
screenWidth: 800,
screenHeight: 600,
platform: 'Macintosh',
platformVersion: '11.0',
};
const { score, matchedFactors } = fingerprint.calculateConfidenceScore(a, b);
expect(score).toBe(0);
expect(matchedFactors).toEqual([]);
});
it('matches IP within the same /24 subnet and normalizes user agent', () => {
const click = {
...baseFingerprint,
ipAddress: '24.5.10.250',
timezone: 'Africa/Cairo',
language: 'fr-FR',
screenWidth: 800,
screenHeight: 600,
platform: 'Linux',
};
const install = {
...baseFingerprint,
ipAddress: '24.5.10.123',
userAgent: baseFingerprint.userAgent.replace('Chrome/95.0.4638.69', 'Chrome/116.0.0.0'),
timezone: 'Europe/London',
language: 'de-DE',
screenWidth: 1200,
screenHeight: 900,
platform: 'Windows',
};
const { score, matchedFactors } = fingerprint.calculateConfidenceScore(click, install);
expect(score).toBe(70);
expect(matchedFactors).toContain('ip');
expect(matchedFactors).toContain('user_agent');
});
it('matches language by first two characters', () => {
const a = {
...baseFingerprint,
ipAddress: '10.0.0.1',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
timezone: 'Asia/Tokyo',
screenWidth: 800,
screenHeight: 600,
platform: 'Macintosh',
platformVersion: '11.0',
language: 'en-US',
};
const b = {
...a,
ipAddress: '172.16.0.1',
userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G973F) Chrome/91.0.4472.120 Mobile Safari/537.36',
timezone: 'UTC',
screenWidth: 1024,
screenHeight: 768,
platform: 'Linux',
platformVersion: '10',
language: 'en-GB',
};
const { score, matchedFactors } = fingerprint.calculateConfidenceScore(a, b);
expect(score).toBe(10);
expect(matchedFactors).toEqual(['language']);
});
it('matches timezone and resolution', () => {
const a = {
...baseFingerprint,
ipAddress: '10.0.0.1',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
language: 'ja-JP',
platform: 'Macintosh',
platformVersion: '11.0',
timezone: 'UTC',
screenWidth: 100,
screenHeight: 200,
};
const b = {
...a,
ipAddress: '172.16.0.1',
userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G973F) Chrome/91.0.4472.120 Mobile Safari/537.36',
language: 'fr-FR',
};
const { score, matchedFactors } = fingerprint.calculateConfidenceScore(a, b);
expect(score).toBe(20);
expect(matchedFactors.sort()).toEqual(['screen', 'timezone'].sort());
});
});
describe('isAttributableIp', () => {
it('treats public IPv4 as attributable', () => {
expect(fingerprint.isAttributableIp('24.5.10.100')).toBe(true);
expect(fingerprint.isAttributableIp('8.8.8.8')).toBe(true);
});
it('rejects CGNAT (100.64.0.0/10) — the Marriage365 case', () => {
expect(fingerprint.isAttributableIp('100.64.0.5')).toBe(false);
expect(fingerprint.isAttributableIp('100.127.255.254')).toBe(false);
});
it('rejects RFC1918 private, loopback and link-local', () => {
expect(fingerprint.isAttributableIp('10.0.0.1')).toBe(false);
expect(fingerprint.isAttributableIp('172.16.0.1')).toBe(false);
expect(fingerprint.isAttributableIp('192.168.1.1')).toBe(false);
expect(fingerprint.isAttributableIp('127.0.0.1')).toBe(false);
expect(fingerprint.isAttributableIp('169.254.1.1')).toBe(false);
});
it('handles IPv6: public attributable, ULA/link-local/loopback not', () => {
expect(fingerprint.isAttributableIp('2600:1700:6508:8040::1')).toBe(true);
expect(fingerprint.isAttributableIp('fd00::1')).toBe(false); // ULA
expect(fingerprint.isAttributableIp('fe80::1')).toBe(false); // link-local
expect(fingerprint.isAttributableIp('::1')).toBe(false); // loopback
});
it('unwraps IPv4-mapped IPv6 before classifying', () => {
expect(fingerprint.isAttributableIp('::ffff:100.64.0.5')).toBe(false);
expect(fingerprint.isAttributableIp('::ffff:24.5.10.100')).toBe(true);
});
it('returns false for empty/garbage input', () => {
expect(fingerprint.isAttributableIp('')).toBe(false);
expect(fingerprint.isAttributableIp('not-an-ip')).toBe(false);
});
});
describe('calculateConfidenceScore — shared-IP filter', () => {
it('does NOT award the IP score for two devices sharing a CGNAT /24', () => {
// Exactly the Marriage365 leak: unrelated installs collapsing onto 100.64.0.x.
const click = { ...baseFingerprint, ipAddress: '100.64.0.5' };
const install = { ...baseFingerprint, ipAddress: '100.64.0.9' };
const { score, matchedFactors } = fingerprint.calculateConfidenceScore(click, install);
expect(matchedFactors).not.toContain('ip');
// UA(30)+TZ(10)+lang(10)+screen(10) = 60, below the 70 threshold → no match.
expect(score).toBe(60);
expect(score).toBeLessThan(fingerprint.CONFIDENCE_THRESHOLD);
});
it('still awards the IP score for two devices sharing a public /24', () => {
const click = { ...baseFingerprint, ipAddress: '24.5.10.5' };
const install = { ...baseFingerprint, ipAddress: '24.5.10.9' };
const { matchedFactors } = fingerprint.calculateConfidenceScore(click, install);
expect(matchedFactors).toContain('ip');
});
});
describe('matchInstallToClick', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
mockDbQuery.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns null when there are no click rows', async () => {
mockDbQuery.mockResolvedValueOnce({ rows: [] });
const result = await fingerprint.matchInstallToClick(baseFingerprint);
expect(result).toBeNull();
});
it('returns the best match above the confidence threshold', async () => {
const clickTime = new Date('2024-12-31T23:00:00Z');
// First row: only IP match (score 40)
const rowA = {
click_id: 'click-a',
link_id: 'link-a',
clicked_at: clickTime.toISOString(),
attribution_window_hours: 24,
ip_address: '24.5.10.200',
user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
timezone: 'Asia/Tokyo',
language: 'ja-JP',
screen_width: 720,
screen_height: 1280,
platform: 'Macintosh',
platform_version: '11.0',
};
// Second row: IP + user agent + timezone + language + screen (score 100)
const rowB = {
click_id: 'click-b',
link_id: 'link-b',
clicked_at: clickTime.toISOString(),
attribution_window_hours: 24,
ip_address: '24.5.10.250',
user_agent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36',
timezone: 'America/Los_Angeles',
language: 'en-US',
screen_width: 1080,
screen_height: 1920,
platform: 'Windows',
platform_version: '10',
};
mockDbQuery.mockResolvedValueOnce({ rows: [rowA, rowB] });
const installFingerprint = {
...baseFingerprint,
ipAddress: '24.5.10.123',
userAgent: baseFingerprint.userAgent.replace('Chrome/95.0.4638.69', 'Chrome/116.0.0.0'),
};
const result = await fingerprint.matchInstallToClick(installFingerprint);
expect(result).not.toBeNull();
expect(result?.clickId).toBe('click-b');
expect(result?.confidenceScore).toBe(100);
expect(result?.matchedFactors).toEqual(expect.arrayContaining(['ip', 'user_agent', 'timezone', 'language', 'screen']));
});
it('skips clicks that are outside the attribution window', async () => {
const oldClickTime = new Date('2024-01-01T00:00:00Z');
mockDbQuery.mockResolvedValueOnce({
rows: [
{
click_id: 'click-old',
link_id: 'link-old',
clicked_at: oldClickTime.toISOString(),
attribution_window_hours: 1,
ip_address: baseFingerprint.ipAddress,
user_agent: baseFingerprint.userAgent,
timezone: baseFingerprint.timezone,
language: baseFingerprint.language,
screen_width: baseFingerprint.screenWidth,
screen_height: baseFingerprint.screenHeight,
platform: baseFingerprint.platform,
platform_version: baseFingerprint.platformVersion,
},
],
});
const result = await fingerprint.matchInstallToClick(baseFingerprint);
expect(result).toBeNull();
});
});
describe('recordInstallEvent', () => {
beforeEach(() => {
mockDbQuery.mockReset();
});
it('inserts an install event and returns the install id when no match is found', async () => {
// matchInstallToClick is invoked internally by recordInstallEvent.
// The first db query is used to find click events. Return an empty list to force a null match.
mockDbQuery.mockResolvedValueOnce({ rows: [] });
mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-123', deep_link_data: {} }] });
const result = await fingerprint.recordInstallEvent(baseFingerprint, 'device-1');
expect(result.installId).toBe('install-123');
expect(result.match).toBeNull();
expect(result.deepLinkData).toEqual({});
expect(mockDbQuery).toHaveBeenCalledTimes(2);
expect(mockDbQuery).toHaveBeenLastCalledWith(
expect.any(String),
expect.arrayContaining([null, null, expect.any(String), null, expect.any(String), expect.any(String), null, null, null, null, null, null, null, 'device-1', null])
);
});
it('forwards sdk name + version as the last two positional params of the install insert (guards column misalignment)', async () => {
mockDbQuery.mockResolvedValueOnce({ rows: [] }); // matchInstallToClick -> no match
mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-456', deep_link_data: {} }] });
await fingerprint.recordInstallEvent(baseFingerprint, 'device-1', undefined, {
name: 'react-native',
version: '1.4.0',
});
const [sql, params] = mockDbQuery.mock.calls[1];
expect(sql).toMatch(/INSERT INTO install_events/);
expect(sql).toMatch(/sdk_name/);
expect(sql).toMatch(/sdk_version/);
// sdk_name/$16, sdk_version/$17, then attribution_method/$18, matched_factors/$19
expect(params).toHaveLength(19);
expect(params[15]).toBe('react-native'); // sdk_name
expect(params[16]).toBe('1.4.0'); // sdk_version
expect(params[17]).toBe('none'); // attribution_method (no fingerprint match)
expect(params[18]).toBeNull(); // matched_factors (no match)
});
it('stores null sdk on the install row when the metadata is absent or empty', async () => {
mockDbQuery.mockResolvedValueOnce({ rows: [] });
mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-789', deep_link_data: {} }] });
await fingerprint.recordInstallEvent(baseFingerprint, 'device-1', undefined, { name: '', version: undefined });
const params = mockDbQuery.mock.calls[1][1];
expect(params[15]).toBeNull(); // sdk_name '' -> null
expect(params[16]).toBeNull(); // sdk_version undefined -> null
});
});
+555
View File
@@ -0,0 +1,555 @@
import crypto from 'crypto';
import { db } from './database.js';
/**
* Device fingerprint data structure
*/
export interface FingerprintData {
ipAddress: string;
userAgent: string;
timezone?: string;
language?: string;
screenWidth?: number;
screenHeight?: number;
platform?: string;
platformVersion?: string;
}
/**
* Fingerprint match result with confidence scoring
*/
export interface FingerprintMatch {
clickId: string;
linkId: string;
confidenceScore: number;
matchedFactors: string[];
clickedAt: Date;
}
/**
* Scoring weights for probabilistic matching
* Total should equal 100 for percentage-based confidence
*/
const FINGERPRINT_WEIGHTS = {
IP_ADDRESS: 40,
USER_AGENT: 30,
TIMEZONE: 10,
LANGUAGE: 10,
SCREEN_RESOLUTION: 10,
};
/**
* Default attribution window in hours (7 days)
*/
export const DEFAULT_ATTRIBUTION_WINDOW_HOURS = 168;
/**
* Minimum confidence threshold for attribution (70%)
*/
export const CONFIDENCE_THRESHOLD = 70;
/**
* Generate a fingerprint hash from device data
* Uses SHA-256 hash of concatenated device attributes
*/
export function generateFingerprintHash(data: FingerprintData): string {
const components = [
data.ipAddress || '',
data.userAgent || '',
data.timezone || '',
data.language || '',
data.screenWidth?.toString() || '',
data.screenHeight?.toString() || '',
data.platform || '',
data.platformVersion || '',
];
const concatenated = components.join('|');
return crypto.createHash('sha256').update(concatenated).digest('hex');
}
/**
* Normalize IP address for comparison
* Handles IPv4 and IPv6, removes subnet variations
*/
function ipv4ToInt(ip: string): number | null {
const parts = ip.split('.');
if (parts.length !== 4) return null;
let n = 0;
for (const p of parts) {
const o = Number(p);
if (!Number.isInteger(o) || o < 0 || o > 255) return null;
n = (n << 8) | o;
}
return n >>> 0;
}
function inCidr4(ipInt: number, baseIp: string, prefix: number): boolean {
const base = ipv4ToInt(baseIp);
if (base === null) return false;
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
return (ipInt & mask) === (base & mask);
}
// Shared / non-routable IPv4 ranges that can't identify a single device:
// carrier-grade NAT, RFC1918 private, loopback, link-local, etc. Two different
// users routinely share these (mobile carrier NAT, office Wi-Fi, VPN egress).
const NON_ATTRIBUTABLE_V4: ReadonlyArray<readonly [string, number]> = [
['0.0.0.0', 8],
['10.0.0.0', 8],
['100.64.0.0', 10], // CGNAT (RFC 6598)
['127.0.0.0', 8], // loopback
['169.254.0.0', 16], // link-local
['172.16.0.0', 12], // private
['192.0.0.0', 24], // IETF protocol assignments
['192.168.0.0', 16], // private
['198.18.0.0', 15], // benchmarking
];
/**
* Whether an IP is specific enough to use as an attribution signal. Returns
* false for shared/non-routable ranges (CGNAT, RFC1918, loopback, link-local,
* IPv6 ULA/link-local) where the IP does NOT identify a single device, so it
* must not contribute to a fingerprint match. Public IPs return true.
*/
export function isAttributableIp(ip: string): boolean {
if (!ip) return false;
let addr = ip.trim();
if (addr.startsWith('::ffff:')) addr = addr.slice(7); // IPv4-mapped IPv6
// IPv4
if (addr.includes('.') && !addr.includes(':')) {
const n = ipv4ToInt(addr);
if (n === null) return false;
return !NON_ATTRIBUTABLE_V4.some(([base, prefix]) => inCidr4(n, base, prefix));
}
// IPv6
if (addr.includes(':')) {
const low = addr.toLowerCase();
if (low === '::' || low === '::1') return false; // unspecified / loopback
if (low.startsWith('fc') || low.startsWith('fd')) return false; // fc00::/7 ULA
if (low.startsWith('fe8') || low.startsWith('fe9') || low.startsWith('fea') || low.startsWith('feb')) {
return false; // fe80::/10 link-local
}
return true;
}
// Not a recognizable IPv4 or IPv6 address.
return false;
}
function normalizeIP(ip: string): string {
if (!ip) return '';
// For IPv4, use first 3 octets (e.g., 192.168.1.x)
if (ip.includes('.')) {
const parts = ip.split('.');
return parts.slice(0, 3).join('.');
}
// For IPv6, use first 4 groups (e.g., 2001:0db8:85a3:0000:xxxx)
if (ip.includes(':')) {
const parts = ip.split(':');
return parts.slice(0, 4).join(':');
}
return ip;
}
/**
* Normalize user agent for comparison
* Extracts key identifiers and removes version numbers
*/
function normalizeUserAgent(ua: string): string {
if (!ua) return '';
// Extract platform (iOS, Android, Windows, Mac, Linux)
const platformMatch = ua.match(/(iPhone|iPad|Android|Windows|Macintosh|Linux)/i);
const platform = platformMatch ? platformMatch[1] : '';
// Extract browser (Chrome, Safari, Firefox, Edge)
const browserMatch = ua.match(/(Chrome|Safari|Firefox|Edge|Opera)/i);
const browser = browserMatch ? browserMatch[1] : '';
return `${platform}|${browser}`.toLowerCase();
}
/**
* Calculate confidence score by comparing two fingerprints
* Returns a score from 0-100 based on matched components
*/
export function calculateConfidenceScore(
fingerprint1: FingerprintData,
fingerprint2: FingerprintData
): { score: number; matchedFactors: string[] } {
let score = 0;
const matchedFactors: string[] = [];
// Compare IP addresses (normalized to /24 subnet for IPv4). Only count IPs
// that actually identify a device — shared/NAT ranges (CGNAT, RFC1918, etc.)
// are skipped so unrelated users behind the same NAT can't match on IP.
if (
fingerprint1.ipAddress &&
fingerprint2.ipAddress &&
isAttributableIp(fingerprint1.ipAddress) &&
isAttributableIp(fingerprint2.ipAddress)
) {
const ip1 = normalizeIP(fingerprint1.ipAddress);
const ip2 = normalizeIP(fingerprint2.ipAddress);
if (ip1 === ip2) {
score += FINGERPRINT_WEIGHTS.IP_ADDRESS;
matchedFactors.push('ip');
}
}
// Compare user agents (normalized to platform + browser)
if (fingerprint1.userAgent && fingerprint2.userAgent) {
const ua1 = normalizeUserAgent(fingerprint1.userAgent);
const ua2 = normalizeUserAgent(fingerprint2.userAgent);
if (ua1 === ua2) {
score += FINGERPRINT_WEIGHTS.USER_AGENT;
matchedFactors.push('user_agent');
}
}
// Compare timezone
if (fingerprint1.timezone && fingerprint2.timezone) {
if (fingerprint1.timezone === fingerprint2.timezone) {
score += FINGERPRINT_WEIGHTS.TIMEZONE;
matchedFactors.push('timezone');
}
}
// Compare language
if (fingerprint1.language && fingerprint2.language) {
// Match first 2 characters (e.g., "en-US" matches "en-GB")
const lang1 = fingerprint1.language.substring(0, 2).toLowerCase();
const lang2 = fingerprint2.language.substring(0, 2).toLowerCase();
if (lang1 === lang2) {
score += FINGERPRINT_WEIGHTS.LANGUAGE;
matchedFactors.push('language');
}
}
// Compare screen resolution
if (
fingerprint1.screenWidth &&
fingerprint1.screenHeight &&
fingerprint2.screenWidth &&
fingerprint2.screenHeight
) {
if (
fingerprint1.screenWidth === fingerprint2.screenWidth &&
fingerprint1.screenHeight === fingerprint2.screenHeight
) {
score += FINGERPRINT_WEIGHTS.SCREEN_RESOLUTION;
matchedFactors.push('screen');
}
}
return { score, matchedFactors };
}
/**
* Match an install event to potential click events via probabilistic fingerprinting
* Returns the best match above confidence threshold within attribution window
*
* Note: Uses link-specific attribution windows - each link can have its own window
*/
export async function matchInstallToClick(
installFingerprint: FingerprintData,
attributionWindowHours: number = DEFAULT_ATTRIBUTION_WINDOW_HOURS
): Promise<FingerprintMatch | null> {
// Query recent click events within maximum possible attribution window (90 days)
// We'll validate against each link's specific window during matching
const maxWindowHours = 2160; // 90 days
const cutoffTime = new Date(Date.now() - maxWindowHours * 60 * 60 * 1000);
const clicksResult = await db.query(
`SELECT
ce.id as click_id,
ce.link_id,
ce.clicked_at,
l.attribution_window_hours,
df.ip_address,
df.user_agent,
df.timezone,
df.language,
df.screen_width,
df.screen_height,
df.platform,
df.platform_version
FROM click_events ce
INNER JOIN device_fingerprints df ON df.click_id = ce.id
INNER JOIN links l ON ce.link_id = l.id
WHERE ce.clicked_at >= $1
ORDER BY ce.clicked_at DESC
LIMIT 1000`,
[cutoffTime]
);
if (clicksResult.rows.length === 0) {
return null;
}
const installTime = new Date();
// Calculate confidence score for each potential match
let bestMatch: FingerprintMatch | null = null;
let highestScore = 0;
for (const row of clicksResult.rows) {
// Check if click is within the link's specific attribution window
const linkWindowHours = row.attribution_window_hours || DEFAULT_ATTRIBUTION_WINDOW_HOURS;
const clickTime = new Date(row.clicked_at);
const timeDiffHours = (installTime.getTime() - clickTime.getTime()) / (1000 * 60 * 60);
if (timeDiffHours > linkWindowHours) {
// Click is too old for this link's attribution window, skip it
continue;
}
const clickFingerprint: FingerprintData = {
ipAddress: row.ip_address,
userAgent: row.user_agent,
timezone: row.timezone,
language: row.language,
screenWidth: row.screen_width,
screenHeight: row.screen_height,
platform: row.platform,
platformVersion: row.platform_version,
};
const { score, matchedFactors } = calculateConfidenceScore(
installFingerprint,
clickFingerprint
);
// Track the best match
if (score > highestScore && score >= CONFIDENCE_THRESHOLD) {
highestScore = score;
bestMatch = {
clickId: row.click_id,
linkId: row.link_id,
confidenceScore: score,
matchedFactors,
clickedAt: new Date(row.clicked_at),
};
}
}
return bestMatch;
}
/**
* Store device fingerprint for a click event
*/
export async function storeFingerprintForClick(
clickId: string,
fingerprintData: FingerprintData
): Promise<void> {
const fingerprintHash = generateFingerprintHash(fingerprintData);
await db.query(
`INSERT INTO device_fingerprints (
click_id,
fingerprint_hash,
ip_address,
user_agent,
timezone,
language,
screen_width,
screen_height,
platform,
platform_version
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
clickId,
fingerprintHash,
fingerprintData.ipAddress,
fingerprintData.userAgent,
fingerprintData.timezone || null,
fingerprintData.language || null,
fingerprintData.screenWidth || null,
fingerprintData.screenHeight || null,
fingerprintData.platform || null,
fingerprintData.platformVersion || null,
]
);
}
/**
* Record an install event and attempt to match it to a click
*/
export async function recordInstallEvent(
fingerprintData: FingerprintData,
deviceId?: string,
attributionWindowHours: number = DEFAULT_ATTRIBUTION_WINDOW_HOURS,
sdk?: { name?: string | null; version?: string | null }
): Promise<{
installId: string;
match: FingerprintMatch | null;
deepLinkData: any;
}> {
const fingerprintHash = generateFingerprintHash(fingerprintData);
// Attempt to match install to a click
const match = await matchInstallToClick(fingerprintData, attributionWindowHours);
// Attribution metadata for measurement (SIT-296): install attribution is
// fingerprint-based, so the method is 'fingerprint' on a match and 'none'
// (organic) otherwise; matched_factors records which signals matched.
const attributionMethod = match ? 'fingerprint' : 'none';
const matchedFactors = match?.matchedFactors ?? null;
// Insert install event
const installResult = await db.query(
`INSERT INTO install_events (
link_id,
click_id,
fingerprint_hash,
confidence_score,
installed_at,
first_open_at,
attribution_window_hours,
ip_address,
user_agent,
timezone,
language,
screen_width,
screen_height,
platform,
platform_version,
device_id,
deep_link_data,
sdk_name,
sdk_version,
attribution_method,
matched_factors
) VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
RETURNING id, deep_link_data`,
[
match?.linkId || null,
match?.clickId || null,
fingerprintHash,
match?.confidenceScore || null,
attributionWindowHours,
fingerprintData.ipAddress,
fingerprintData.userAgent,
fingerprintData.timezone || null,
fingerprintData.language || null,
fingerprintData.screenWidth || null,
fingerprintData.screenHeight || null,
fingerprintData.platform || null,
fingerprintData.platformVersion || null,
deviceId || null,
match ? JSON.stringify({}) : JSON.stringify({}), // Will be populated from link data
sdk?.name || null,
sdk?.version || null,
attributionMethod,
matchedFactors,
]
);
const installId = installResult.rows[0].id;
let deepLinkData = {};
// If we have a match, retrieve the deep link data from the original link
if (match) {
const linkResult = await db.query(
`SELECT
short_code,
original_url,
ios_app_store_url,
android_app_store_url,
web_fallback_url,
utm_parameters,
targeting_rules,
deep_link_parameters
FROM links
WHERE id = $1`,
[match.linkId]
);
if (linkResult.rows.length > 0) {
const link = linkResult.rows[0];
deepLinkData = {
shortCode: link.short_code,
originalUrl: link.original_url,
iosUrl: link.ios_app_store_url,
androidUrl: link.android_app_store_url,
webFallbackUrl: link.web_fallback_url,
utmParameters: link.utm_parameters,
targetingRules: link.targeting_rules,
deepLinkParameters: link.deep_link_parameters,
clickedAt: match.clickedAt,
confidenceScore: match.confidenceScore,
matchedFactors: match.matchedFactors,
};
// Update the install event with deep link data
await db.query(
`UPDATE install_events
SET deep_link_data = $1,
deep_link_retrieved = true
WHERE id = $2`,
[JSON.stringify(deepLinkData), installId]
);
// Trigger webhooks for install_event (only if attributed)
try {
// Get the link's user_id for webhook lookup
const linkUserResult = await db.query(
'SELECT user_id FROM links WHERE id = $1',
[match.linkId]
);
if (linkUserResult.rows.length > 0) {
const userId = linkUserResult.rows[0].user_id;
const webhooksResult = await db.query(
'SELECT * FROM webhooks WHERE user_id = $1 AND is_active = true',
[userId]
);
if (webhooksResult.rows.length > 0) {
const { triggerWebhooks } = await import('./webhook.js');
const installEventData = {
id: installId,
linkId: match.linkId,
fingerprintHash,
confidenceScore: match.confidenceScore,
installedAt: new Date().toISOString(),
deepLinkData,
ipAddress: fingerprintData.ipAddress,
userAgent: fingerprintData.userAgent,
platform: fingerprintData.platform,
};
// Trigger webhooks without delivery logging (basic version)
// For delivery logging, use @linkforty/cloud premium features
await triggerWebhooks(
webhooksResult.rows,
'install_event',
installId,
installEventData
);
}
}
} catch (webhookError) {
console.error(`Error triggering install webhooks: ${webhookError}`);
}
}
}
return {
installId,
match,
deepLinkData,
};
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest';
import { invalidateLinkResolutionCache } from './link-resolution-cache';
describe('invalidateLinkResolutionCache', () => {
it('no-ops when redis is null', async () => {
await expect(invalidateLinkResolutionCache(null, 'abc')).resolves.toBeUndefined();
});
it('no-ops when redis is undefined', async () => {
await expect(invalidateLinkResolutionCache(undefined, 'abc')).resolves.toBeUndefined();
});
it('deletes legacy key when no templateSlug', async () => {
const del = vi.fn().mockResolvedValue(1);
await invalidateLinkResolutionCache({ del } as any, 'abc');
expect(del).toHaveBeenCalledTimes(1);
expect(del).toHaveBeenCalledWith('link:abc');
});
it('deletes both legacy and template keys when templateSlug is provided', async () => {
const del = vi.fn().mockResolvedValue(2);
await invalidateLinkResolutionCache({ del } as any, 'abc', 'mytemplate');
expect(del).toHaveBeenCalledTimes(1);
expect(del).toHaveBeenCalledWith('link:abc', 'link:mytemplate:abc');
});
it('skips template key when templateSlug is null', async () => {
const del = vi.fn().mockResolvedValue(1);
await invalidateLinkResolutionCache({ del } as any, 'abc', null);
expect(del).toHaveBeenCalledTimes(1);
expect(del).toHaveBeenCalledWith('link:abc');
});
it('skips template key when templateSlug is empty string', async () => {
const del = vi.fn().mockResolvedValue(1);
await invalidateLinkResolutionCache({ del } as any, 'abc', '');
expect(del).toHaveBeenCalledTimes(1);
expect(del).toHaveBeenCalledWith('link:abc');
});
it('swallows errors from redis.del without throwing', async () => {
const del = vi.fn().mockRejectedValue(new Error('connection lost'));
await expect(invalidateLinkResolutionCache({ del } as any, 'abc')).resolves.toBeUndefined();
expect(del).toHaveBeenCalledTimes(1);
});
});
+22
View File
@@ -0,0 +1,22 @@
/**
* Invalidate the Redis cache entries used by the redirect and SDK resolve
* read paths for a given link. Safe to call when Redis is not configured.
*
* Cache key patterns (set in redirect.ts / sdk.ts):
* link:${shortCode}
* link:${templateSlug}:${shortCode}
*/
export async function invalidateLinkResolutionCache(
redis: { del(...keys: string[]): Promise<number> } | null | undefined,
shortCode: string,
templateSlug?: string | null,
): Promise<void> {
if (!redis) return;
try {
const keys = [`link:${shortCode}`];
if (templateSlug) keys.push(`link:${templateSlug}:${shortCode}`);
await redis.del(...keys);
} catch {
// Swallow — a cache miss on the next read is self-healing.
}
}
+187
View File
@@ -0,0 +1,187 @@
import { describe, it, expect } from 'vitest';
import {
generateShortCode,
parseUserAgent,
buildRedirectUrl,
detectDevice,
getLocationFromIP,
} from './utils';
describe('generateShortCode', () => {
it('should generate a short code of default length 8', () => {
const code = generateShortCode();
expect(code).toHaveLength(8);
expect(typeof code).toBe('string');
});
it('should generate a short code of custom length', () => {
const code = generateShortCode(12);
expect(code).toHaveLength(12);
});
it('should generate unique codes', () => {
const code1 = generateShortCode();
const code2 = generateShortCode();
expect(code1).not.toBe(code2);
});
});
describe('parseUserAgent', () => {
it('should parse Chrome on Windows user agent', () => {
const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36';
const result = parseUserAgent(ua);
expect(result.deviceType).toBe('desktop');
expect(result.platform).toBe('Windows');
expect(result.browser).toBe('Chrome');
});
it('should parse iPhone user agent', () => {
const ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1';
const result = parseUserAgent(ua);
expect(result.deviceType).toBe('mobile');
expect(result.platform).toBe('iOS');
expect(result.browser).toBe('Mobile Safari');
});
it('should parse Android user agent', () => {
const ua = 'Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36';
const result = parseUserAgent(ua);
expect(result.deviceType).toBe('mobile');
expect(result.platform).toBe('Android');
expect(result.browser).toBe('Chrome');
});
it('should handle empty user agent', () => {
const result = parseUserAgent('');
expect(result.deviceType).toBe('desktop');
expect(result.platform).toBe('unknown');
expect(result.browser).toBe('unknown');
});
});
describe('detectDevice', () => {
it('should detect iOS devices', () => {
expect(detectDevice('Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X)')).toBe('ios');
expect(detectDevice('Mozilla/5.0 (iPad; CPU OS 14_6 like Mac OS X)')).toBe('ios');
expect(detectDevice('Mozilla/5.0 (iPod touch; CPU iPhone OS 14_6 like Mac OS X)')).toBe('ios');
});
it('should detect Android devices', () => {
expect(detectDevice('Mozilla/5.0 (Linux; Android 11; Pixel 5)')).toBe('android');
expect(detectDevice('Mozilla/5.0 (Linux; Android 10; SM-G973F)')).toBe('android');
});
it('should default to web for desktop browsers', () => {
expect(detectDevice('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/91.0')).toBe('web');
expect(detectDevice('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('web');
});
it('should be case insensitive', () => {
expect(detectDevice('MOZILLA/5.0 (IPHONE; CPU IPHONE OS 14_6)')).toBe('ios');
expect(detectDevice('MOZILLA/5.0 (LINUX; ANDROID 11)')).toBe('android');
});
});
describe('buildRedirectUrl', () => {
it('should return original URL when no UTM parameters provided', () => {
const url = 'https://example.com/page';
const result = buildRedirectUrl(url);
expect(result).toBe(url);
});
it('should append UTM parameters to URL', () => {
const url = 'https://example.com/page';
const utmParams = {
source: 'newsletter',
medium: 'email',
campaign: 'summer-sale',
};
const result = buildRedirectUrl(url, utmParams);
expect(result).toContain('utm_source=newsletter');
expect(result).toContain('utm_medium=email');
expect(result).toContain('utm_campaign=summer-sale');
});
it('should preserve existing query parameters', () => {
const url = 'https://example.com/page?foo=bar';
const utmParams = { source: 'google' };
const result = buildRedirectUrl(url, utmParams);
expect(result).toContain('foo=bar');
expect(result).toContain('utm_source=google');
});
it('should skip UTM parameters with empty values', () => {
const url = 'https://example.com/page';
const utmParams = {
source: 'twitter',
medium: '',
campaign: 'promo',
};
const result = buildRedirectUrl(url, utmParams);
expect(result).toContain('utm_source=twitter');
expect(result).not.toContain('utm_medium');
expect(result).toContain('utm_campaign=promo');
});
it('should URL encode UTM parameter values', () => {
const url = 'https://example.com/page';
const utmParams = {
source: 'email newsletter',
campaign: 'summer sale 2024',
};
const result = buildRedirectUrl(url, utmParams);
expect(result).toContain('utm_source=email+newsletter');
expect(result).toContain('utm_campaign=summer+sale+2024');
});
});
describe('getLocationFromIP', () => {
it('should return null values for invalid IP', () => {
const result = getLocationFromIP('invalid-ip');
expect(result.countryCode).toBeNull();
expect(result.countryName).toBeNull();
expect(result.city).toBeNull();
expect(result.latitude).toBeNull();
expect(result.longitude).toBeNull();
});
it('should return null values for private IP addresses', () => {
const result = getLocationFromIP('192.168.1.1');
expect(result.countryCode).toBeNull();
expect(result.countryName).toBeNull();
});
it('should parse public IP addresses', () => {
// Google DNS IP (known to be US-based)
const result = getLocationFromIP('8.8.8.8');
expect(result.countryCode).toBe('US');
expect(result.countryName).toBe('United States');
expect(result.latitude).toBeDefined();
expect(result.longitude).toBeDefined();
});
it('should handle countries not in the mapping', () => {
// This test might need adjustment based on actual geoip-lite behavior
// but demonstrates the fallback logic
const result = getLocationFromIP('8.8.8.8');
if (result.countryCode && !result.countryName) {
// Fallback to country code
expect(result.countryName).toBe(result.countryCode);
} else {
// Has a mapped name
expect(typeof result.countryName).toBe('string');
}
});
});
+173
View File
@@ -0,0 +1,173 @@
import { nanoid } from 'nanoid';
import geoip from 'geoip-lite';
import UAParser from 'ua-parser-js';
/**
* Generate a URL-safe random short code using nanoid.
*
* @param length - Number of characters in the generated code. Defaults to 8.
* @returns A random URL-safe string of the specified length.
*/
export function generateShortCode(length: number = 8): string {
return nanoid(length);
}
/**
* Parse a User-Agent string into structured device and browser information.
*
* @param userAgent - Raw User-Agent header value from an HTTP request.
* @returns An object containing `deviceType`, `platform`, `platformVersion`, and `browser`.
*/
export function parseUserAgent(userAgent: string) {
const parser = new UAParser(userAgent);
const result = parser.getResult();
return {
deviceType: result.device.type || 'desktop',
platform: result.os.name || 'unknown',
platformVersion: result.os.version || undefined,
browser: result.browser.name || 'unknown',
};
}
// Country code to name mapping (common countries)
const COUNTRY_NAMES: Record<string, string> = {
US: 'United States',
GB: 'United Kingdom',
CA: 'Canada',
AU: 'Australia',
DE: 'Germany',
FR: 'France',
IT: 'Italy',
ES: 'Spain',
NL: 'Netherlands',
SE: 'Sweden',
NO: 'Norway',
DK: 'Denmark',
FI: 'Finland',
PL: 'Poland',
BR: 'Brazil',
MX: 'Mexico',
AR: 'Argentina',
IN: 'India',
CN: 'China',
JP: 'Japan',
KR: 'South Korea',
SG: 'Singapore',
MY: 'Malaysia',
TH: 'Thailand',
ID: 'Indonesia',
PH: 'Philippines',
VN: 'Vietnam',
ZA: 'South Africa',
EG: 'Egypt',
NG: 'Nigeria',
KE: 'Kenya',
RU: 'Russia',
TR: 'Turkey',
AE: 'United Arab Emirates',
SA: 'Saudi Arabia',
IL: 'Israel',
NZ: 'New Zealand',
IE: 'Ireland',
CH: 'Switzerland',
AT: 'Austria',
BE: 'Belgium',
PT: 'Portugal',
GR: 'Greece',
CZ: 'Czech Republic',
HU: 'Hungary',
RO: 'Romania',
};
/**
* Look up geographic location data for an IP address using geoip-lite.
*
* @param ip - IPv4 or IPv6 address to look up.
* @returns An object with `countryCode`, `countryName`, `region`, `city`,
* `latitude`, `longitude`, and `timezone`. All fields are `null` when the
* IP address is not found in the GeoIP database.
*/
export function getLocationFromIP(ip: string) {
const geo = geoip.lookup(ip);
if (!geo) {
return {
countryCode: null,
countryName: null,
region: null,
city: null,
latitude: null,
longitude: null,
timezone: null,
};
}
return {
countryCode: geo.country,
countryName: COUNTRY_NAMES[geo.country] || geo.country,
region: geo.region,
city: geo.city,
latitude: geo.ll?.[0] || null,
longitude: geo.ll?.[1] || null,
timezone: geo.timezone,
};
}
/**
* Append UTM tracking parameters to a URL.
*
* Each key in `utmParameters` is prefixed with `utm_` before being added as a
* query parameter (e.g., `{ source: 'email' }` → `?utm_source=email`).
* Empty values are skipped.
*
* @param originalUrl - The destination URL to append parameters to.
* @param utmParameters - Optional map of UTM parameter names (without the `utm_` prefix) to values.
* @returns The URL string with UTM parameters appended.
*/
export function buildRedirectUrl(
originalUrl: string | null | undefined,
utmParameters?: Record<string, string>
): string | null {
if (!originalUrl) return null;
try {
const url = new URL(originalUrl);
if (utmParameters) {
Object.entries(utmParameters).forEach(([key, value]) => {
if (value) {
url.searchParams.set(`utm_${key}`, value);
}
});
}
return url.toString();
} catch {
// If URL is invalid, return it as-is rather than crashing
return originalUrl;
}
}
/**
* Detect the device platform from a User-Agent string.
*
* Uses simple substring matching to identify iOS and Android devices.
* Anything that does not match is classified as `'web'`.
*
* @param userAgent - Raw User-Agent header value from an HTTP request.
* @returns `'ios'`, `'android'`, or `'web'`.
*/
export function detectDevice(userAgent: string): 'ios' | 'android' | 'web' {
const ua = userAgent.toLowerCase();
if (ua.includes('iphone') || ua.includes('ipad') || ua.includes('ipod')) {
return 'ios';
}
if (ua.includes('android')) {
return 'android';
}
return 'web';
}
+380
View File
@@ -0,0 +1,380 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
generateWebhookSignature,
generateWebhookSecret,
deliverWebhook,
triggerWebhooks,
} from './webhook.js';
import type { Webhook, WebhookPayload } from '../types/index.js';
// Helper to build a Webhook fixture
function makeWebhook(overrides: Partial<Webhook> = {}): Webhook {
return {
id: 'wh-1',
user_id: 'user-1',
name: 'Test Webhook',
url: 'https://example.com/hook',
secret: 'test-secret',
events: ['click_event'],
is_active: true,
retry_count: 1,
timeout_ms: 5000,
headers: {},
created_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-01T00:00:00.000Z',
...overrides,
};
}
// Helper to build a WebhookPayload fixture
function makePayload(overrides: Partial<WebhookPayload> = {}): WebhookPayload {
return {
event: 'click_event',
event_id: 'evt-1',
timestamp: '2024-01-01T00:00:00.000Z',
data: { id: 'data-1' } as any,
...overrides,
};
}
// Helper to make a minimal Response-like mock
function mockResponse(
ok: boolean,
status: number,
statusText: string,
body = ''
): Response {
return {
ok,
status,
statusText,
text: () => Promise.resolve(body),
} as unknown as Response;
}
describe('generateWebhookSignature', () => {
it('returns a hex string', () => {
const sig = generateWebhookSignature('payload', 'secret');
expect(typeof sig).toBe('string');
expect(sig).toMatch(/^[0-9a-f]{64}$/);
});
it('is deterministic for the same inputs', () => {
const sig1 = generateWebhookSignature('hello', 'key');
const sig2 = generateWebhookSignature('hello', 'key');
expect(sig1).toBe(sig2);
});
it('produces different signatures for different payloads', () => {
const sig1 = generateWebhookSignature('payload-a', 'secret');
const sig2 = generateWebhookSignature('payload-b', 'secret');
expect(sig1).not.toBe(sig2);
});
it('produces different signatures for different secrets', () => {
const sig1 = generateWebhookSignature('payload', 'secret-a');
const sig2 = generateWebhookSignature('payload', 'secret-b');
expect(sig1).not.toBe(sig2);
});
});
describe('generateWebhookSecret', () => {
it('returns a 64-character hex string (32 random bytes)', () => {
const secret = generateWebhookSecret();
expect(typeof secret).toBe('string');
expect(secret).toMatch(/^[0-9a-f]{64}$/);
});
it('returns unique values on each call', () => {
const s1 = generateWebhookSecret();
const s2 = generateWebhookSecret();
expect(s1).not.toBe(s2);
});
});
describe('deliverWebhook', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('returns a success result on HTTP 200', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK', 'received')));
const webhook = makeWebhook();
const payload = makePayload();
const result = await deliverWebhook(webhook, payload);
expect(result.success).toBe(true);
expect(result.webhookId).toBe(webhook.id);
expect(result.eventType).toBe(payload.event);
expect(result.eventId).toBe(payload.event_id);
expect(result.responseStatus).toBe(200);
expect(result.responseBody).toBe('received');
expect(result.attemptNumber).toBe(1);
expect(result.deliveredAt).toBeDefined();
expect(result.errorMessage).toBeUndefined();
});
it('returns a failure result on HTTP 500', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(false, 500, 'Internal Server Error', 'error body')));
// Use logDelivery to capture per-attempt details (the final return is a
// generic "Failed after N attempts" object once retries are exhausted).
const logDelivery = vi.fn().mockResolvedValue(undefined);
const webhook = makeWebhook({ retry_count: 1 });
const payload = makePayload();
const result = await deliverWebhook(webhook, payload, logDelivery);
// Per-attempt result captured via logDelivery
const attemptResult = logDelivery.mock.calls[0][0];
expect(attemptResult.responseStatus).toBe(500);
expect(attemptResult.errorMessage).toBe('HTTP 500: Internal Server Error');
expect(attemptResult.responseBody).toBe('error body');
// Final returned result after retries exhausted
expect(result.success).toBe(false);
expect(result.deliveredAt).toBeUndefined();
});
it('sets correct request headers', async () => {
const mockFetch = vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK'));
vi.stubGlobal('fetch', mockFetch);
const webhook = makeWebhook({ headers: { 'X-Custom': 'value' } });
const payload = makePayload();
await deliverWebhook(webhook, payload);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe(webhook.url);
expect(init.method).toBe('POST');
const headers = init.headers as Record<string, string>;
expect(headers['Content-Type']).toBe('application/json');
expect(headers['X-LinkForty-Event']).toBe(payload.event);
expect(headers['X-LinkForty-Event-ID']).toBe(payload.event_id);
expect(headers['X-LinkForty-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/);
expect(headers['User-Agent']).toBe('LinkForty-Webhook/1.0');
expect(headers['X-Custom']).toBe('value');
});
it('sends the correct JSON body', async () => {
const mockFetch = vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK'));
vi.stubGlobal('fetch', mockFetch);
const webhook = makeWebhook();
const payload = makePayload();
await deliverWebhook(webhook, payload);
const [, init] = mockFetch.mock.calls[0];
expect(JSON.parse(init.body as string)).toEqual(payload);
});
it('truncates response body to 1000 characters', async () => {
const longBody = 'x'.repeat(2000);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK', longBody)));
const result = await deliverWebhook(makeWebhook(), makePayload());
expect(result.responseBody).toHaveLength(1000);
});
it('retries on failure and succeeds on second attempt', async () => {
const mockFetch = vi.fn()
.mockResolvedValueOnce(mockResponse(false, 503, 'Service Unavailable'))
.mockResolvedValueOnce(mockResponse(true, 200, 'OK'));
vi.stubGlobal('fetch', mockFetch);
const webhook = makeWebhook({ retry_count: 2 });
const payload = makePayload();
const promise = deliverWebhook(webhook, payload);
await vi.runAllTimersAsync();
const result = await promise;
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(result.success).toBe(true);
expect(result.attemptNumber).toBe(2);
});
it('exhausts all retries and returns final failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(false, 500, 'Error')));
const webhook = makeWebhook({ retry_count: 3 });
const payload = makePayload();
const promise = deliverWebhook(webhook, payload);
await vi.runAllTimersAsync();
const result = await promise;
expect(result.success).toBe(false);
expect(result.errorMessage).toBe('Failed after 3 attempts');
expect(result.attemptNumber).toBe(3);
});
it('calls logDelivery for each attempt', async () => {
const mockFetch = vi.fn()
.mockResolvedValueOnce(mockResponse(false, 500, 'Error'))
.mockResolvedValueOnce(mockResponse(true, 200, 'OK'));
vi.stubGlobal('fetch', mockFetch);
const logDelivery = vi.fn().mockResolvedValue(undefined);
const webhook = makeWebhook({ retry_count: 2 });
const payload = makePayload();
const promise = deliverWebhook(webhook, payload, logDelivery);
await vi.runAllTimersAsync();
await promise;
expect(logDelivery).toHaveBeenCalledTimes(2);
expect(logDelivery.mock.calls[0][0].success).toBe(false);
expect(logDelivery.mock.calls[1][0].success).toBe(true);
});
it('continues even if logDelivery throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK')));
const logDelivery = vi.fn().mockRejectedValue(new Error('log failed'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = await deliverWebhook(makeWebhook(), makePayload(), logDelivery);
expect(result.success).toBe(true);
consoleSpy.mockRestore();
});
it('captures timeout error in logDelivery when fetch exceeds timeout_ms', async () => {
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError));
const logDelivery = vi.fn().mockResolvedValue(undefined);
const webhook = makeWebhook({ timeout_ms: 1000, retry_count: 1 });
const result = await deliverWebhook(webhook, makePayload(), logDelivery);
// Per-attempt error is exposed via logDelivery
const attemptResult = logDelivery.mock.calls[0][0];
expect(attemptResult.success).toBe(false);
expect(attemptResult.errorMessage).toBe('Timeout after 1000ms');
// Final result reflects exhausted retries
expect(result.success).toBe(false);
});
it('captures network error message in logDelivery', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
const logDelivery = vi.fn().mockResolvedValue(undefined);
const webhook = makeWebhook({ retry_count: 1 });
const result = await deliverWebhook(webhook, makePayload(), logDelivery);
const attemptResult = logDelivery.mock.calls[0][0];
expect(attemptResult.success).toBe(false);
expect(attemptResult.errorMessage).toBe('ECONNREFUSED');
expect(result.success).toBe(false);
});
});
describe('triggerWebhooks', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('does nothing when no webhooks provided', async () => {
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
await triggerWebhooks([], 'click_event', 'evt-1', {});
expect(mockFetch).not.toHaveBeenCalled();
});
it('skips inactive webhooks', async () => {
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
const inactive = makeWebhook({ is_active: false });
await triggerWebhooks([inactive], 'click_event', 'evt-1', {});
expect(mockFetch).not.toHaveBeenCalled();
});
it('skips webhooks not subscribed to the event', async () => {
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
const webhook = makeWebhook({ events: ['install_event'] });
await triggerWebhooks([webhook], 'click_event', 'evt-1', {});
expect(mockFetch).not.toHaveBeenCalled();
});
it('delivers to matching active webhooks', async () => {
const mockFetch = vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK'));
vi.stubGlobal('fetch', mockFetch);
const webhook = makeWebhook({ events: ['click_event', 'install_event'] });
triggerWebhooks([webhook], 'click_event', 'evt-1', { foo: 'bar' });
await vi.runAllTimersAsync();
expect(mockFetch).toHaveBeenCalledOnce();
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.event).toBe('click_event');
expect(body.event_id).toBe('evt-1');
expect(body.data).toEqual({ foo: 'bar' });
});
it('delivers to multiple matching webhooks', async () => {
const mockFetch = vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK'));
vi.stubGlobal('fetch', mockFetch);
const wh1 = makeWebhook({ id: 'wh-1', url: 'https://a.com/hook' });
const wh2 = makeWebhook({ id: 'wh-2', url: 'https://b.com/hook' });
triggerWebhooks([wh1, wh2], 'click_event', 'evt-2', {});
await vi.runAllTimersAsync();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('calls logDelivery with webhookId and result', async () => {
const mockFetch = vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK'));
vi.stubGlobal('fetch', mockFetch);
const logDelivery = vi.fn().mockResolvedValue(undefined);
const webhook = makeWebhook({ id: 'wh-log' });
triggerWebhooks([webhook], 'click_event', 'evt-3', {}, logDelivery);
await vi.runAllTimersAsync();
expect(logDelivery).toHaveBeenCalledWith('wh-log', expect.objectContaining({ success: true }));
});
it('includes the timestamp in the payload', async () => {
const mockFetch = vi.fn().mockResolvedValue(mockResponse(true, 200, 'OK'));
vi.stubGlobal('fetch', mockFetch);
triggerWebhooks([makeWebhook()], 'click_event', 'evt-4', {});
await vi.runAllTimersAsync();
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.timestamp).toBeDefined();
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
});
});
+184
View File
@@ -0,0 +1,184 @@
import crypto from 'crypto';
import type { Webhook, WebhookPayload, WebhookDeliveryResult, WebhookEvent } from '../types/index.js';
/**
* Generate HMAC SHA-256 signature for webhook payload
*/
export function generateWebhookSignature(payload: string, secret: string): string {
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}
/**
* Generate a secure random secret for webhook signing
*/
export function generateWebhookSecret(): string {
return crypto.randomBytes(32).toString('hex');
}
/**
* Sleep utility for retry delays
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Attempt a single webhook delivery
*/
async function attemptWebhookDelivery(
webhook: Webhook,
payload: WebhookPayload,
attemptNumber: number
): Promise<WebhookDeliveryResult> {
const payloadString = JSON.stringify(payload);
const signature = generateWebhookSignature(payloadString, webhook.secret);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-LinkForty-Signature': `sha256=${signature}`,
'X-LinkForty-Event': payload.event,
'X-LinkForty-Event-ID': payload.event_id,
'User-Agent': 'LinkForty-Webhook/1.0',
...webhook.headers,
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), webhook.timeout_ms);
try {
const response = await fetch(webhook.url, {
method: 'POST',
headers,
body: payloadString,
signal: controller.signal,
});
clearTimeout(timeoutId);
const responseBody = await response.text().catch(() => '');
const result: WebhookDeliveryResult = {
success: response.ok,
webhookId: webhook.id,
eventType: payload.event,
eventId: payload.event_id,
responseStatus: response.status,
responseBody: responseBody.substring(0, 1000), // Limit response body size
attemptNumber,
deliveredAt: response.ok ? new Date().toISOString() : undefined,
};
if (!response.ok) {
result.errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
return result;
} catch (error: any) {
clearTimeout(timeoutId);
return {
success: false,
webhookId: webhook.id,
eventType: payload.event,
eventId: payload.event_id,
errorMessage: error.name === 'AbortError'
? `Timeout after ${webhook.timeout_ms}ms`
: error.message || 'Unknown error',
attemptNumber,
};
}
}
/**
* Deliver webhook with retry logic and exponential backoff
*/
export async function deliverWebhook(
webhook: Webhook,
payload: WebhookPayload,
logDelivery?: (result: WebhookDeliveryResult) => Promise<void>
): Promise<WebhookDeliveryResult> {
const maxRetries = webhook.retry_count ?? 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const result = await attemptWebhookDelivery(webhook, payload, attempt);
// Log delivery attempt if logging function provided
if (logDelivery) {
await logDelivery(result).catch(err => {
console.error('Failed to log webhook delivery:', err);
});
}
// If successful, return immediately
if (result.success) {
return result;
}
// If not the last attempt, wait before retrying with exponential backoff
if (attempt < maxRetries) {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (capped at 30s)
const delayMs = Math.min(1000 * Math.pow(2, attempt - 1), 30000);
await sleep(delayMs);
}
}
// Return the last failed result
return {
success: false,
webhookId: webhook.id,
eventType: payload.event,
eventId: payload.event_id,
errorMessage: `Failed after ${maxRetries} attempts`,
attemptNumber: maxRetries,
};
}
/**
* Trigger webhooks for a specific event (fire and forget)
*/
export async function triggerWebhooks(
webhooks: Webhook[],
event: WebhookEvent,
eventId: string,
data: any,
logDelivery?: (webhookId: string, result: WebhookDeliveryResult) => Promise<void>
): Promise<void> {
// Create webhook payload
const payload: WebhookPayload = {
event,
event_id: eventId,
timestamp: new Date().toISOString(),
data,
};
// Filter webhooks that should receive this event
const relevantWebhooks = webhooks.filter(
webhook => webhook.is_active && webhook.events.includes(event)
);
if (relevantWebhooks.length === 0) {
return; // No webhooks to trigger
}
// Fire and forget - don't await these deliveries
const deliveryPromises = relevantWebhooks.map(async (webhook) => {
try {
const result = await deliverWebhook(
webhook,
payload,
logDelivery ? (result) => logDelivery(webhook.id, result) : undefined
);
if (!result.success) {
console.error(`Webhook ${webhook.id} delivery failed:`, result.errorMessage);
}
} catch (error) {
console.error(`Webhook ${webhook.id} delivery error:`, error);
}
});
// Fire and forget - log errors but don't block
Promise.all(deliveryPromises).catch(err => {
console.error('Webhook delivery batch error:', err);
});
}
+233
View File
@@ -0,0 +1,233 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { db } from '../lib/database.js';
export async function analyticsRoutes(fastify: FastifyInstance) {
// Get overall analytics (optionally filtered by userId)
fastify.get('/api/analytics/overview', async (request: FastifyRequest<{
Querystring: { userId?: string; days?: number }
}>) => {
const { userId, days = 30 } = request.query;
const userFilter = userId ? 'AND l.user_id = $1' : '';
const userFilterWhere = userId ? 'WHERE l.user_id = $1' : '';
const params = userId ? [userId] : [];
// Get total and unique clicks
const clicksResult = await db.query(
`SELECT
COUNT(*) as total_clicks,
COUNT(DISTINCT ip_address) as unique_clicks
FROM click_events ce
JOIN links l ON ce.link_id = l.id
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false`,
params
);
// Get clicks by date
const dateResult = await db.query(
`SELECT
DATE(ce.clicked_at) as date,
COUNT(*) as clicks
FROM click_events ce
JOIN links l ON ce.link_id = l.id
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false
GROUP BY DATE(ce.clicked_at)
ORDER BY date`,
params
);
// Get clicks by country
const countryResult = await db.query(
`SELECT
COALESCE(ce.country_code, 'Unknown') as country_code,
COALESCE(ce.country_name, ce.country_code, 'Unknown') as country,
COUNT(*) as clicks
FROM click_events ce
JOIN links l ON ce.link_id = l.id
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false
GROUP BY ce.country_code, ce.country_name
ORDER BY clicks DESC`,
params
);
// Get clicks by device
const deviceResult = await db.query(
`SELECT
COALESCE(ce.device_type, 'Unknown') as device,
COUNT(*) as clicks
FROM click_events ce
JOIN links l ON ce.link_id = l.id
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false
GROUP BY ce.device_type
ORDER BY clicks DESC`,
params
);
// Get clicks by platform
const platformResult = await db.query(
`SELECT
COALESCE(ce.platform, 'Unknown') as platform,
COUNT(*) as clicks
FROM click_events ce
JOIN links l ON ce.link_id = l.id
WHERE ce.clicked_at >= NOW() - INTERVAL '${days} days' ${userFilter} AND ce.is_bot = false
GROUP BY ce.platform
ORDER BY clicks DESC`,
params
);
// Get top performing links
const topLinksResult = await db.query(
`SELECT
l.id,
l.short_code,
l.title,
l.original_url,
COUNT(ce.id) as total_clicks,
COUNT(DISTINCT ce.ip_address) as unique_clicks
FROM links l
LEFT JOIN click_events ce ON l.id = ce.link_id
AND ce.clicked_at >= NOW() - INTERVAL '${days} days'
AND ce.is_bot = false
${userFilterWhere}
GROUP BY l.id
ORDER BY total_clicks DESC
LIMIT 10`,
params
);
return {
totalClicks: parseInt(clicksResult.rows[0]?.total_clicks || '0'),
uniqueClicks: parseInt(clicksResult.rows[0]?.unique_clicks || '0'),
clicksByDate: dateResult.rows.map(row => ({
date: row.date,
clicks: parseInt(row.clicks),
})),
clicksByCountry: countryResult.rows.map(row => ({
countryCode: row.country_code,
country: row.country,
clicks: parseInt(row.clicks),
})),
clicksByDevice: deviceResult.rows.map(row => ({
device: row.device,
clicks: parseInt(row.clicks),
})),
clicksByPlatform: platformResult.rows.map(row => ({
platform: row.platform,
clicks: parseInt(row.clicks),
})),
topLinks: topLinksResult.rows.map(row => ({
id: row.id,
shortCode: row.short_code,
title: row.title,
originalUrl: row.original_url,
totalClicks: parseInt(row.total_clicks),
uniqueClicks: parseInt(row.unique_clicks),
})),
};
});
// Get link-specific analytics
fastify.get('/api/analytics/links/:linkId', async (request: FastifyRequest<{
Params: { linkId: string };
Querystring: { userId?: string; days?: number };
}>) => {
const { linkId } = request.params;
const { userId, days = 30 } = request.query;
// Verify link exists (and ownership if userId provided)
let linkResult;
if (userId) {
linkResult = await db.query(
'SELECT id FROM links WHERE id = $1 AND user_id = $2',
[linkId, userId]
);
} else {
linkResult = await db.query(
'SELECT id FROM links WHERE id = $1',
[linkId]
);
}
if (linkResult.rows.length === 0) {
throw new Error('Link not found');
}
// Get analytics for specific link
const clicksResult = await db.query(
`SELECT
COUNT(*) as total_clicks,
COUNT(DISTINCT ip_address) as unique_clicks
FROM click_events
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false`,
[linkId]
);
const dateResult = await db.query(
`SELECT
DATE(clicked_at) as date,
COUNT(*) as clicks
FROM click_events
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false
GROUP BY DATE(clicked_at)
ORDER BY date`,
[linkId]
);
const countryResult = await db.query(
`SELECT
COALESCE(country_code, 'Unknown') as country_code,
COALESCE(country_name, country_code, 'Unknown') as country,
COUNT(*) as clicks
FROM click_events
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false
GROUP BY country_code, country_name
ORDER BY clicks DESC`,
[linkId]
);
const deviceResult = await db.query(
`SELECT
COALESCE(device_type, 'Unknown') as device,
COUNT(*) as clicks
FROM click_events
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false
GROUP BY device_type
ORDER BY clicks DESC`,
[linkId]
);
const platformResult = await db.query(
`SELECT
COALESCE(platform, 'Unknown') as platform,
COUNT(*) as clicks
FROM click_events
WHERE link_id = $1 AND clicked_at >= NOW() - INTERVAL '${days} days' AND is_bot = false
GROUP BY platform
ORDER BY clicks DESC`,
[linkId]
);
return {
totalClicks: parseInt(clicksResult.rows[0]?.total_clicks || '0'),
uniqueClicks: parseInt(clicksResult.rows[0]?.unique_clicks || '0'),
clicksByDate: dateResult.rows.map(row => ({
date: row.date,
clicks: parseInt(row.clicks),
})),
clicksByCountry: countryResult.rows.map(row => ({
countryCode: row.country_code,
country: row.country,
clicks: parseInt(row.clicks),
})),
clicksByDevice: deviceResult.rows.map(row => ({
device: row.device,
clicks: parseInt(row.clicks),
})),
clicksByPlatform: platformResult.rows.map(row => ({
platform: row.platform,
clicks: parseInt(row.clicks),
})),
};
});
}
+363
View File
@@ -0,0 +1,363 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { db } from '../lib/database.js';
import { detectDevice } from '../lib/utils.js';
import { subscribeToClickEvents, ClickEventData } from '../lib/event-emitter.js';
/**
* Device simulation request schema
*/
const simulateRequestSchema = z.object({
linkId: z.string().uuid(),
userId: z.string().uuid().optional(),
deviceType: z.enum(['ios', 'android', 'web']).optional(),
userAgent: z.string().optional(),
country: z.string().length(2).optional(), // ISO country code
language: z.string().optional(), // e.g., "en", "es", "fr"
ipAddress: z.string().ip().optional(),
});
type SimulateRequest = z.infer<typeof simulateRequestSchema>;
/**
* Debugging routes for testing and validation
* Premium Cloud-only feature
*/
export async function debugRoutes(fastify: FastifyInstance) {
/**
* POST /api/debug/simulate
* Simulate a link click with custom device parameters
* Returns detailed information about redirect decision without logging
*/
fastify.post('/api/debug/simulate', async (request: FastifyRequest) => {
const data = simulateRequestSchema.parse(request.body);
// Fetch the link
let linkResult;
if (data.userId) {
linkResult = await db.query(
`SELECT * FROM links WHERE id = $1 AND user_id = $2`,
[data.linkId, data.userId]
);
} else {
linkResult = await db.query(
`SELECT * FROM links WHERE id = $1`,
[data.linkId]
);
}
if (linkResult.rows.length === 0) {
throw new Error('Link not found');
}
const link = linkResult.rows[0];
// Determine device type
let deviceType: 'ios' | 'android' | 'web';
if (data.deviceType) {
deviceType = data.deviceType;
} else if (data.userAgent) {
deviceType = detectDevice(data.userAgent);
} else {
deviceType = 'web'; // Default
}
// Simulate targeting rules evaluation
const targetingRules = link.targeting_rules || {};
const simulatedCountry = data.country || 'US';
const simulatedLanguage = data.language || 'en';
let targetingMatched = true;
const targetingDetails: {
countryMatch: boolean | null;
deviceMatch: boolean | null;
languageMatch: boolean | null;
} = {
countryMatch: null,
deviceMatch: null,
languageMatch: null,
};
// Check country targeting
if (targetingRules.countries && targetingRules.countries.length > 0) {
targetingDetails.countryMatch = targetingRules.countries.includes(simulatedCountry);
if (!targetingDetails.countryMatch) {
targetingMatched = false;
}
}
// Check device targeting
if (targetingRules.devices && targetingRules.devices.length > 0) {
targetingDetails.deviceMatch = targetingRules.devices.includes(deviceType);
if (!targetingDetails.deviceMatch) {
targetingMatched = false;
}
}
// Check language targeting
if (targetingRules.languages && targetingRules.languages.length > 0) {
const primaryLang = simulatedLanguage.split('-')[0];
targetingDetails.languageMatch = targetingRules.languages.some(
(lang: string) => lang.toLowerCase().startsWith(primaryLang.toLowerCase())
);
if (!targetingDetails.languageMatch) {
targetingMatched = false;
}
}
// Determine redirect URL based on device type
let redirectUrl = link.original_url;
let redirectReason = 'original_url (default)';
if (deviceType === 'ios' && link.ios_app_store_url) {
redirectUrl = link.ios_app_store_url;
redirectReason = 'ios_app_store_url (iOS device detected)';
} else if (deviceType === 'android' && link.android_app_store_url) {
redirectUrl = link.android_app_store_url;
redirectReason = 'android_app_store_url (Android device detected)';
} else if (deviceType === 'web' && link.web_fallback_url) {
redirectUrl = link.web_fallback_url;
redirectReason = 'web_fallback_url (Web device detected)';
}
// Add UTM parameters if present
let finalUrl = redirectUrl;
const utmParameters = link.utm_parameters || {};
if (Object.keys(utmParameters).length > 0) {
const url = new URL(redirectUrl);
if (utmParameters.source) url.searchParams.set('utm_source', utmParameters.source);
if (utmParameters.medium) url.searchParams.set('utm_medium', utmParameters.medium);
if (utmParameters.campaign) url.searchParams.set('utm_campaign', utmParameters.campaign);
if (utmParameters.term) url.searchParams.set('utm_term', utmParameters.term);
if (utmParameters.content) url.searchParams.set('utm_content', utmParameters.content);
finalUrl = url.toString();
}
// Return detailed simulation results
return {
simulation: {
linkId: link.id,
shortCode: link.short_code,
title: link.title,
isActive: link.is_active,
expiresAt: link.expires_at,
},
input: {
deviceType: data.deviceType || 'auto-detected',
userAgent: data.userAgent || 'Not provided',
country: simulatedCountry,
language: simulatedLanguage,
ipAddress: data.ipAddress || 'Not provided',
},
detection: {
detectedDevice: deviceType,
detectionMethod: data.deviceType
? 'manual (provided in request)'
: data.userAgent
? 'user-agent parsing'
: 'default (web)',
},
targeting: {
hasRules: Object.keys(targetingRules).length > 0,
rules: targetingRules,
matched: targetingMatched,
details: targetingDetails,
},
redirect: {
wouldRedirect: link.is_active && targetingMatched,
finalUrl: targetingMatched ? finalUrl : null,
redirectReason: targetingMatched ? redirectReason : 'Targeting rules not matched',
utmParametersAdded: Object.keys(utmParameters).length > 0,
utmParameters: utmParameters,
},
warnings: [
...(!link.is_active ? ['Link is inactive - would return 404'] : []),
...(link.expires_at && new Date(link.expires_at) < new Date()
? ['Link has expired - would return 404']
: []),
...(!targetingMatched ? ['Targeting rules not matched - would return 404'] : []),
],
};
});
/**
* GET /api/debug/user-agents
* Get a list of common User-Agent strings for testing
*/
fastify.get('/api/debug/user-agents', async () => {
return {
ios: [
{
name: 'iPhone 15 Pro - iOS 17 - Safari',
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
device: 'ios',
},
{
name: 'iPhone 14 - iOS 16 - Safari',
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
device: 'ios',
},
{
name: 'iPad Pro - iOS 17 - Safari',
userAgent:
'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
device: 'ios',
},
],
android: [
{
name: 'Samsung Galaxy S23 - Android 13 - Chrome',
userAgent:
'Mozilla/5.0 (Linux; Android 13; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36',
device: 'android',
},
{
name: 'Google Pixel 8 - Android 14 - Chrome',
userAgent:
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36',
device: 'android',
},
{
name: 'OnePlus 11 - Android 13 - Chrome',
userAgent:
'Mozilla/5.0 (Linux; Android 13; CPH2449) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36',
device: 'android',
},
],
web: [
{
name: 'Chrome on Windows',
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
device: 'web',
},
{
name: 'Safari on macOS',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
device: 'web',
},
{
name: 'Firefox on Linux',
userAgent:
'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0',
device: 'web',
},
],
};
});
/**
* GET /api/debug/countries
* Get a list of common countries for testing
*/
fastify.get('/api/debug/countries', async () => {
return {
countries: [
{ code: 'US', name: 'United States' },
{ code: 'GB', name: 'United Kingdom' },
{ code: 'CA', name: 'Canada' },
{ code: 'AU', name: 'Australia' },
{ code: 'DE', name: 'Germany' },
{ code: 'FR', name: 'France' },
{ code: 'ES', name: 'Spain' },
{ code: 'IT', name: 'Italy' },
{ code: 'JP', name: 'Japan' },
{ code: 'CN', name: 'China' },
{ code: 'IN', name: 'India' },
{ code: 'BR', name: 'Brazil' },
{ code: 'MX', name: 'Mexico' },
{ code: 'KR', name: 'South Korea' },
{ code: 'SG', name: 'Singapore' },
],
};
});
/**
* GET /api/debug/languages
* Get a list of common languages for testing
*/
fastify.get('/api/debug/languages', async () => {
return {
languages: [
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Spanish' },
{ code: 'fr', name: 'French' },
{ code: 'de', name: 'German' },
{ code: 'it', name: 'Italian' },
{ code: 'pt', name: 'Portuguese' },
{ code: 'ja', name: 'Japanese' },
{ code: 'zh', name: 'Chinese' },
{ code: 'ko', name: 'Korean' },
{ code: 'ar', name: 'Arabic' },
{ code: 'ru', name: 'Russian' },
{ code: 'hi', name: 'Hindi' },
],
};
});
/**
* WebSocket: /api/debug/live
* Real-time click event streaming
* Clients can subscribe to live click events filtered by userId and optionally linkId
*/
(fastify as any).get(
'/api/debug/live',
{ websocket: true },
(connection: any, request: FastifyRequest<{
Querystring: { userId?: string; linkId?: string };
}>) => {
const { userId, linkId } = request.query;
// Send welcome message
connection.socket.send(
JSON.stringify({
type: 'connected',
message: 'Connected to live request inspector',
filters: {
userId: userId || 'all',
linkId: linkId || 'all',
},
})
);
// Subscribe to click events
const unsubscribe = subscribeToClickEvents((eventData: ClickEventData) => {
// Filter by userId if provided
if (userId && eventData.userId !== userId) {
return;
}
// Filter by linkId if provided
if (linkId && eventData.linkId !== linkId) {
return;
}
// Send event to client
try {
connection.socket.send(
JSON.stringify({
type: 'click_event',
data: eventData,
})
);
} catch (error: any) {
console.error('Failed to send WebSocket message:', error);
}
});
// Handle client disconnect
connection.socket.on('close', () => {
unsubscribe();
});
// Handle errors
connection.socket.on('error', (error: any) => {
console.error('WebSocket error:', error);
unsubscribe();
});
}
);
}
+10
View File
@@ -0,0 +1,10 @@
export { redirectRoutes } from './redirect.js';
export { linkRoutes } from './links.js';
export { analyticsRoutes } from './analytics.js';
export { sdkRoutes } from './sdk.js';
export { qrRoutes } from './qr.js';
export { webhookRoutes } from './webhooks.js';
export { templateRoutes } from './templates.js';
export { previewRoutes } from './preview.js';
export { debugRoutes } from './debug.js';
export { wellKnownRoutes } from './well-known.js';
+31
View File
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const dbQueryMock = vi.fn();
vi.mock('../lib/database.js', () => ({
db: {
query: dbQueryMock,
},
}));
import { resolveLinkTemplateId } from './links.js';
describe('resolveLinkTemplateId', () => {
beforeEach(() => {
dbQueryMock.mockReset();
});
it('returns null when the provided template id does not exist', async () => {
dbQueryMock.mockResolvedValueOnce({ rows: [] });
await expect(resolveLinkTemplateId('00000000-0000-0000-0000-000000000000', null)).resolves.toBeNull();
});
it('falls back to template slug when the provided id is not usable', async () => {
dbQueryMock.mockResolvedValueOnce({ rows: [] });
dbQueryMock.mockResolvedValueOnce({ rows: [{ id: '11111111-1111-1111-1111-111111111111' }] });
await expect(resolveLinkTemplateId(null, 'default')).resolves.toBe('11111111-1111-1111-1111-111111111111');
expect(dbQueryMock).toHaveBeenNthCalledWith(2, 'SELECT id FROM link_templates WHERE slug = $1 LIMIT 1', ['default']);
});
});
+439
View File
@@ -0,0 +1,439 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { db } from '../lib/database.js';
import { generateShortCode } from '../lib/utils.js';
import { invalidateLinkResolutionCache } from '../lib/link-resolution-cache.js';
async function getTemplateSlug(templateId: string | null): Promise<string | null> {
if (!templateId) return null;
const result = await db.query(
'SELECT slug FROM link_templates WHERE id = $1',
[templateId]
);
return result.rows[0]?.slug ?? null;
}
const createLinkSchema = z.object({
userId: z.string().uuid().optional(),
templateId: z.string().uuid().optional(),
originalUrl: z.string().url().optional(),
title: z.string().optional(),
description: z.string().optional(),
// App store URLs (renamed for clarity)
iosAppStoreUrl: z.string().url().optional(),
androidAppStoreUrl: z.string().url().optional(),
webFallbackUrl: z.string().url().optional(),
// App deep linking configuration
appScheme: z.string()
.regex(/^[a-z][a-z0-9+.-]*$/, 'Invalid URI scheme format (must start with lowercase letter, contain only lowercase letters, numbers, +, ., or -)')
.optional(),
iosUniversalLink: z.string().url().optional(),
androidAppLink: z.string().url().optional(),
deepLinkPath: z.string().optional(),
deepLinkParameters: z.record(z.string(), z.any()).optional(),
// Existing fields
customCode: z.string().optional(),
utmParameters: z.object({
source: z.string().optional(),
medium: z.string().optional(),
campaign: z.string().optional(),
term: z.string().optional(),
content: z.string().optional(),
}).optional(),
targetingRules: z.object({
countries: z.array(z.string()).optional(),
devices: z.array(z.enum(['ios', 'android', 'web'])).optional(),
languages: z.array(z.string()).optional(),
}).optional(),
ogTitle: z.string().optional(),
ogDescription: z.string().optional(),
ogImageUrl: z.string().url().optional(),
ogType: z.string().optional(),
attributionWindowHours: z.number()
.int('Attribution window must be an integer')
.min(1, 'Attribution window must be at least 1 hour')
.max(2160, 'Attribution window must be at most 2160 hours (90 days)')
.optional(),
expiresAt: z.string().datetime().optional(),
});
const updateLinkSchema = createLinkSchema.partial().extend({
isActive: z.boolean().optional(),
}).omit({ userId: true });
export async function linkRoutes(fastify: FastifyInstance) {
// Get all links (optionally filtered by userId)
fastify.get('/api/links', async (request: FastifyRequest<{
Querystring: { userId?: string }
}>) => {
const { userId } = request.query;
let query: string;
let params: any[];
if (userId) {
query = `
SELECT l.*,
t.slug as template_slug,
COUNT(ce.id) as click_count
FROM links l
LEFT JOIN link_templates t ON l.template_id = t.id
LEFT JOIN click_events ce ON l.id = ce.link_id
WHERE l.user_id = $1
GROUP BY l.id, t.slug
ORDER BY l.created_at DESC
`;
params = [userId];
} else {
query = `
SELECT l.*,
t.slug as template_slug,
COUNT(ce.id) as click_count
FROM links l
LEFT JOIN link_templates t ON l.template_id = t.id
LEFT JOIN click_events ce ON l.id = ce.link_id
GROUP BY l.id, t.slug
ORDER BY l.created_at DESC
`;
params = [];
}
const result = await db.query(query, params);
return result.rows.map(row => ({
...row,
clickCount: parseInt(row.click_count),
utmParameters: row.utm_parameters,
targetingRules: row.targeting_rules,
deepLinkParameters: row.deep_link_parameters,
}));
});
// Get single link
fastify.get('/api/links/:id', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
let result;
if (userId) {
result = await db.query(
`SELECT l.*, t.slug as template_slug, COUNT(ce.id) as click_count
FROM links l
LEFT JOIN link_templates t ON l.template_id = t.id
LEFT JOIN click_events ce ON l.id = ce.link_id
WHERE l.id = $1 AND l.user_id = $2
GROUP BY l.id, t.slug`,
[id, userId]
);
} else {
result = await db.query(
`SELECT l.*, t.slug as template_slug, COUNT(ce.id) as click_count
FROM links l
LEFT JOIN link_templates t ON l.template_id = t.id
LEFT JOIN click_events ce ON l.id = ce.link_id
WHERE l.id = $1
GROUP BY l.id, t.slug`,
[id]
);
}
if (result.rows.length === 0) {
throw new Error('Link not found');
}
const link = result.rows[0];
return {
...link,
clickCount: parseInt(link.click_count),
utmParameters: link.utm_parameters,
targetingRules: link.targeting_rules,
deepLinkParameters: link.deep_link_parameters,
};
});
// Create link
fastify.post('/api/links', async (request) => {
const data = createLinkSchema.parse(request.body);
// Generate short code
let shortCode = data.customCode || generateShortCode();
// Ensure short code is unique
let attempts = 0;
while (attempts < 10) {
const existing = await db.query(
'SELECT id FROM links WHERE short_code = $1',
[shortCode]
);
if (existing.rows.length === 0) {
break;
}
shortCode = generateShortCode();
attempts++;
}
if (attempts >= 10) {
throw new Error('Unable to generate unique short code');
}
const originalUrl = data.originalUrl || 'https://angkorlifes.com';
const result = await db.query(
`INSERT INTO links (
user_id, template_id, short_code, original_url, title, description,
ios_app_store_url, android_app_store_url, web_fallback_url,
app_scheme, ios_universal_link, android_app_link, deep_link_path, deep_link_parameters,
utm_parameters, targeting_rules,
og_title, og_description, og_image_url, og_type,
attribution_window_hours, expires_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
RETURNING *`,
[
data.userId || null,
data.templateId || null,
shortCode,
originalUrl,
data.title || null,
data.description || null,
data.iosAppStoreUrl || null,
data.androidAppStoreUrl || null,
data.webFallbackUrl || null,
data.appScheme || null,
data.iosUniversalLink || null,
data.androidAppLink || null,
data.deepLinkPath || null,
JSON.stringify(data.deepLinkParameters || {}),
JSON.stringify(data.utmParameters || {}),
JSON.stringify(data.targetingRules || {}),
data.ogTitle || null,
data.ogDescription || null,
data.ogImageUrl || null,
data.ogType || 'website',
data.attributionWindowHours || 168, // Default 7 days
data.expiresAt || null,
]
);
const link = result.rows[0];
return {
...link,
clickCount: 0,
utmParameters: link.utm_parameters,
targetingRules: link.targeting_rules,
deepLinkParameters: link.deep_link_parameters,
};
});
// Update link
fastify.put('/api/links/:id', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
const data = updateLinkSchema.parse(request.body);
// Capture current identifiers for cache invalidation after the update
const oldLinkResult = await db.query(
'SELECT short_code, template_id FROM links WHERE id = $1',
[id]
);
// Build update query dynamically
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined) {
if (key === 'utmParameters' || key === 'targetingRules' || key === 'deepLinkParameters') {
updates.push(`${key.replace(/([A-Z])/g, '_$1').toLowerCase()} = $${paramIndex}`);
values.push(JSON.stringify(value));
} else {
const dbKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
updates.push(`${dbKey} = $${paramIndex}`);
values.push(value);
}
paramIndex++;
}
});
if (updates.length === 0) {
throw new Error('No updates provided');
}
updates.push('updated_at = NOW()');
values.push(id);
let whereClause = `WHERE id = $${paramIndex}`;
if (userId) {
values.push(userId);
whereClause += ` AND user_id = $${paramIndex + 1}`;
}
const result = await db.query(
`UPDATE links SET ${updates.join(', ')}
${whereClause}
RETURNING *`,
values
);
if (result.rows.length === 0) {
throw new Error('Link not found');
}
const link = result.rows[0];
// Invalidate cached link JSON for both old and new template associations
const oldRow = oldLinkResult.rows[0];
const oldTemplateSlug = await getTemplateSlug(oldRow?.template_id);
const newTemplateSlug = await getTemplateSlug(link.template_id);
await invalidateLinkResolutionCache(fastify.redis, link.short_code, oldTemplateSlug);
if (newTemplateSlug !== oldTemplateSlug) {
await invalidateLinkResolutionCache(fastify.redis, link.short_code, newTemplateSlug);
}
return {
...link,
utmParameters: link.utm_parameters,
targetingRules: link.targeting_rules,
deepLinkParameters: link.deep_link_parameters,
};
});
// Duplicate link
fastify.post('/api/links/:id/duplicate', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
// Get original link
let originalLink;
if (userId) {
originalLink = await db.query(
'SELECT * FROM links WHERE id = $1 AND user_id = $2',
[id, userId]
);
} else {
originalLink = await db.query(
'SELECT * FROM links WHERE id = $1',
[id]
);
}
if (originalLink.rows.length === 0) {
throw new Error('Link not found');
}
const link = originalLink.rows[0];
// Generate new short code
let shortCode = generateShortCode();
let attempts = 0;
while (attempts < 10) {
const existing = await db.query(
'SELECT id FROM links WHERE short_code = $1',
[shortCode]
);
if (existing.rows.length === 0) {
break;
}
shortCode = generateShortCode();
attempts++;
}
if (attempts >= 10) {
throw new Error('Unable to generate unique short code');
}
// Create duplicate with new short code and "(Copy)" suffix in title
const title = link.title ? `${link.title} (Copy)` : null;
const result = await db.query(
`INSERT INTO links (
user_id, template_id, short_code, original_url, title, description,
ios_app_store_url, android_app_store_url, web_fallback_url,
app_scheme, ios_universal_link, android_app_link, deep_link_path, deep_link_parameters,
utm_parameters, targeting_rules,
og_title, og_description, og_image_url, og_type,
attribution_window_hours, expires_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
RETURNING *`,
[
link.user_id,
link.template_id || null,
shortCode,
link.original_url,
title,
link.description,
link.ios_app_store_url,
link.android_app_store_url,
link.web_fallback_url,
link.app_scheme,
link.ios_universal_link,
link.android_app_link,
link.deep_link_path,
link.deep_link_parameters,
link.utm_parameters,
link.targeting_rules,
link.og_title,
link.og_description,
link.og_image_url,
link.og_type,
link.attribution_window_hours,
link.expires_at,
]
);
const newLink = result.rows[0];
return {
...newLink,
clickCount: 0,
utmParameters: newLink.utm_parameters,
targetingRules: newLink.targeting_rules,
deepLinkParameters: newLink.deep_link_parameters,
};
});
// Delete link
fastify.delete('/api/links/:id', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
let result;
if (userId) {
result = await db.query(
'DELETE FROM links WHERE id = $1 AND user_id = $2 RETURNING id, short_code, template_id',
[id, userId]
);
} else {
result = await db.query(
'DELETE FROM links WHERE id = $1 RETURNING id, short_code, template_id',
[id]
);
}
if (result.rows.length === 0) {
throw new Error('Link not found');
}
const deleted = result.rows[0];
const templateSlug = await getTemplateSlug(deleted.template_id);
await invalidateLinkResolutionCache(fastify.redis, deleted.short_code, templateSlug);
return { success: true };
});
}
+252
View File
@@ -0,0 +1,252 @@
import { FastifyInstance } from 'fastify';
import { db } from '../lib/database.js';
/**
* Detect if the request is from a social media scraper/bot
* These bots crawl links to generate previews when shared on social platforms
*/
function isSocialScraper(userAgent: string): boolean {
const scraperPatterns = [
/facebookexternalhit/i, // Facebook
/Facebot/i, // Facebook
/Twitterbot/i, // Twitter
/LinkedInBot/i, // LinkedIn
/Slackbot/i, // Slack
/Discordbot/i, // Discord
/TelegramBot/i, // Telegram
/WhatsApp/i, // WhatsApp
/PinterestBot/i, // Pinterest
/SkypeUriPreview/i, // Skype
/Googlebot/i, // Google (for search previews)
/bingbot/i, // Bing
/ia_archiver/i, // Alexa
];
return scraperPatterns.some(pattern => pattern.test(userAgent));
}
/**
* Generate HTML preview page with Open Graph meta tags
*/
function generatePreviewHTML(
link: any,
shortUrl: string,
autoRedirect: boolean = true
): string {
// Use OG-specific values if provided, otherwise fall back to regular title/description
const ogTitle = link.og_title || link.title || 'Shared Link';
const ogDescription = link.og_description || link.description || '';
const ogImage = link.og_image_url || '';
const ogType = link.og_type || 'website';
const ogUrl = shortUrl;
// Auto-redirect after 2 seconds for human visitors
const metaRefresh = autoRedirect
? `<meta http-equiv="refresh" content="2;url=${link.original_url}">`
: '';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(ogTitle)}</title>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="${escapeHtml(ogType)}">
<meta property="og:url" content="${escapeHtml(ogUrl)}">
<meta property="og:title" content="${escapeHtml(ogTitle)}">
<meta property="og:description" content="${escapeHtml(ogDescription)}">
${ogImage ? `<meta property="og:image" content="${escapeHtml(ogImage)}">` : ''}
${ogImage ? `<meta property="og:image:secure_url" content="${escapeHtml(ogImage)}">` : ''}
<!-- Twitter -->
<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">
<meta name="twitter:url" content="${escapeHtml(ogUrl)}">
<meta name="twitter:title" content="${escapeHtml(ogTitle)}">
<meta name="twitter:description" content="${escapeHtml(ogDescription)}">
${ogImage ? `<meta name="twitter:image" content="${escapeHtml(ogImage)}">` : ''}
<!-- LinkedIn -->
<meta property="og:site_name" content="LinkForty">
${metaRefresh}
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
padding: 20px;
}
.container {
max-width: 600px;
}
h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
p {
font-size: 1.25rem;
margin-bottom: 2rem;
opacity: 0.9;
}
.link {
display: inline-block;
padding: 12px 24px;
background: white;
color: #667eea;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
transition: transform 0.2s;
}
.link:hover {
transform: translateY(-2px);
}
.loader {
margin: 2rem auto;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
${ogImage ? `<img src="${escapeHtml(ogImage)}" alt="${escapeHtml(ogTitle)}" style="max-width: 100%; border-radius: 12px; margin-bottom: 2rem; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);">` : ''}
<h1>${escapeHtml(ogTitle)}</h1>
${ogDescription ? `<p>${escapeHtml(ogDescription)}</p>` : ''}
${autoRedirect ? '<div class="loader"></div><p>Redirecting you...</p>' : ''}
<a href="${escapeHtml(link.original_url)}" class="link">
${autoRedirect ? 'Click here if not redirected' : 'Continue to destination'}
</a>
</div>
</body>
</html>`;
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
export async function previewRoutes(fastify: FastifyInstance) {
/**
* GET /:shortCode/preview
* Always return HTML preview page with Open Graph tags
* Auto-redirects after 2 seconds for human visitors
*/
fastify.get('/:shortCode/preview', async (request, reply) => {
const { shortCode } = request.params as { shortCode: string };
const baseUrl = request.headers.host
? `${request.protocol}://${request.headers.host}`
: 'https://link.forty';
try {
// Lookup link by short code
const result = await db.query(
`SELECT * FROM links
WHERE short_code = $1 AND is_active = true
AND (expires_at IS NULL OR expires_at > NOW())`,
[shortCode]
);
if (result.rows.length === 0) {
return reply.status(404).send('Link not found');
}
const link = result.rows[0];
const shortUrl = `${baseUrl}/${shortCode}`;
// Return HTML with OG tags and auto-redirect
const html = generatePreviewHTML(link, shortUrl, true);
return reply
.header('Content-Type', 'text/html; charset=utf-8')
.send(html);
} catch (error: any) {
fastify.log.error(`Error generating preview: ${error}`);
return reply.status(500).send('Error generating preview');
}
});
/**
* Middleware for /:shortCode route to detect social scrapers
* If scraper detected, return OG preview instead of redirecting
*
* Note: This should be registered BEFORE the main redirect route
*/
fastify.addHook('preHandler', async (request, reply) => {
// Only apply to short code routes (not API routes, not preview routes)
const path = request.url.split('?')[0]; // Remove query string
if (
path.startsWith('/api/') ||
path.endsWith('/preview') ||
path === '/' ||
path.includes('.')
) {
return; // Skip this hook
}
const userAgent = request.headers['user-agent'] || '';
const shortCode = path.split('/').pop() || '';
const baseUrl = request.headers.host
? `${request.protocol}://${request.headers.host}`
: 'https://link.forty';
// If it's a social scraper, return OG preview HTML
if (isSocialScraper(userAgent)) {
try {
const result = await db.query(
`SELECT * FROM links
WHERE short_code = $1 AND is_active = true
AND (expires_at IS NULL OR expires_at > NOW())`,
[shortCode]
);
if (result.rows.length > 0) {
const link = result.rows[0];
const shortUrl = `${baseUrl}/${shortCode}`;
// Return HTML with OG tags, no auto-redirect for bots
const html = generatePreviewHTML(link, shortUrl, false);
reply
.header('Content-Type', 'text/html; charset=utf-8')
.send(html);
// Stop the request here, don't continue to redirect route
return reply;
}
} catch (error: any) {
fastify.log.error(`Error in social scraper detection: ${error}`);
// Continue to normal redirect route on error
}
}
// Not a social scraper, continue to normal redirect logic
});
}
+144
View File
@@ -0,0 +1,144 @@
import { FastifyInstance } from 'fastify';
import QRCode from 'qrcode';
import { db } from '../lib/database.js';
/**
* QR Code Routes - Generate QR codes for links
*/
export async function qrRoutes(fastify: FastifyInstance) {
/**
* GET /api/links/:id/qr
* Generate QR code for a link
*
* Query parameters:
* - format: 'png' | 'svg' (default: 'png')
* - size: number 128-2048 (default: 512)
* - color: hex color for foreground (default: '#000000')
* - bgcolor: hex color for background (default: '#ffffff')
*
* Returns: QR code image (PNG or SVG)
*/
fastify.get('/api/links/:id/qr', async (request, reply) => {
const { id } = request.params as { id: string };
const query = request.query as Record<string, string | undefined>;
const format = (query.format || 'png') as 'png' | 'svg';
const size = Math.min(Math.max(parseInt(query.size || '512', 10), 128), 2048);
const color = query.color || '#000000';
const bgcolor = query.bgcolor || '#ffffff';
// Validate format
if (!['png', 'svg'].includes(format)) {
return reply.status(400).send({ error: 'Invalid format. Use "png" or "svg".' });
}
// Build cache key
const cacheKey = `qr:${id}:${format}:${size}:${color}:${bgcolor}`;
// Try to get from cache
if (fastify.redis) {
try {
const cached = await fastify.redis.get(cacheKey);
if (cached) {
fastify.log.info(`QR code cache hit: ${cacheKey}`);
if (format === 'png') {
// Cached PNG is base64
const buffer = Buffer.from(cached, 'base64');
return reply
.type('image/png')
.header('Cache-Control', 'public, max-age=86400') // 24 hours
.send(buffer);
} else {
// Cached SVG is text
return reply
.type('image/svg+xml')
.header('Cache-Control', 'public, max-age=86400')
.send(cached);
}
}
} catch (error) {
fastify.log.warn('Redis QR cache lookup failed');
}
}
// Get link from database
const result = await db.query(
'SELECT short_code, original_url FROM links WHERE id = $1 AND is_active = true',
[id]
);
if (result.rows.length === 0) {
return reply.status(404).send({ error: 'Link not found' });
}
const link = result.rows[0];
// Build short URL using configured domain or request hostname
// Use SHORTLINK_DOMAIN env var for production deployments
const shortLinkDomain = process.env.SHORTLINK_DOMAIN || `${request.protocol}://${request.hostname}`;
const shortUrl = link.short_code
? `${shortLinkDomain}/${link.short_code}`
: link.original_url;
try {
// QR code options
const options = {
errorCorrectionLevel: 'M' as const, // Medium error correction
margin: 1, // Quiet zone margin
width: size,
color: {
dark: color,
light: bgcolor,
},
};
if (format === 'png') {
// Generate PNG as buffer
const buffer = await QRCode.toBuffer(shortUrl, options);
// Cache as base64
if (fastify.redis) {
try {
await fastify.redis.setex(cacheKey, 86400, buffer.toString('base64')); // 24 hour TTL
} catch (error) {
fastify.log.warn('Failed to cache QR code');
}
}
return reply
.type('image/png')
.header('Cache-Control', 'public, max-age=86400')
.header('Content-Disposition', `inline; filename="qr-${link.short_code || 'code'}.png"`)
.send(buffer);
} else {
// Generate SVG as string
const svg = await QRCode.toString(shortUrl, {
...options,
type: 'svg',
});
// Cache SVG text
if (fastify.redis) {
try {
await fastify.redis.setex(cacheKey, 86400, svg);
} catch (error) {
fastify.log.warn('Failed to cache QR code');
}
}
return reply
.type('image/svg+xml')
.header('Cache-Control', 'public, max-age=86400')
.header('Content-Disposition', `inline; filename="qr-${link.short_code || 'code'}.svg"`)
.send(svg);
}
} catch (error: any) {
fastify.log.error(`QR code generation failed: ${error.message}`);
return reply.status(500).send({
error: 'Failed to generate QR code',
message: error.message
});
}
});
}
+186
View File
@@ -0,0 +1,186 @@
import { describe, it, expect } from 'vitest';
import {
isIOSInAppBrowser,
isAndroidInAppBrowser,
pickMobileFallbackUrl,
} from './redirect.js';
// Real-world UA strings (truncated where helpful) for use across test cases.
const UA = {
// Regular browsers — should NOT be detected as in-app
iosSafari:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
iosChrome:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/123.0.6312.52 Mobile/15E148 Safari/604.1',
androidChrome:
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
androidFirefox:
'Mozilla/5.0 (Android 14; Mobile; rv:125.0) Gecko/125.0 Firefox/125.0',
// iOS in-app browsers
iosGmail:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 GSA/322.0.616052181 Safari/604.1',
iosFacebook:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/450.0.0.0]',
iosInstagram:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 320.0.0.0',
iosOutlook:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Outlook-iOS/2.0',
iosTwitter:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Twitter for iPhone/10.0',
// Android in-app browsers
androidWebview:
'Mozilla/5.0 (Linux; Android 14; Pixel 8; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36',
androidFacebook:
'Mozilla/5.0 (Linux; Android 14; Pixel 8; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/450.0.0.0]',
androidInstagram:
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Instagram 320.0.0.0',
androidLine:
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Line/13.0.0',
androidWhatsapp:
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 WhatsApp/2.24.0',
};
const URLS = {
iosStore: 'https://apps.apple.com/app/example/id123',
androidStore: 'https://play.google.com/store/apps/details?id=com.example',
webFallback: 'https://example.com/landing',
};
describe('isIOSInAppBrowser', () => {
it('detects Gmail (GSA token)', () => {
expect(isIOSInAppBrowser(UA.iosGmail)).toBe(true);
});
it('detects Facebook (FBAN/FBAV)', () => {
expect(isIOSInAppBrowser(UA.iosFacebook)).toBe(true);
});
it('detects Instagram', () => {
expect(isIOSInAppBrowser(UA.iosInstagram)).toBe(true);
});
it('detects Outlook', () => {
expect(isIOSInAppBrowser(UA.iosOutlook)).toBe(true);
});
it('detects Twitter', () => {
expect(isIOSInAppBrowser(UA.iosTwitter)).toBe(true);
});
it('does not flag Safari as in-app', () => {
expect(isIOSInAppBrowser(UA.iosSafari)).toBe(false);
});
it('does not flag Chrome on iOS as in-app', () => {
expect(isIOSInAppBrowser(UA.iosChrome)).toBe(false);
});
});
describe('isAndroidInAppBrowser', () => {
it('detects generic Android WebView via wv) marker', () => {
expect(isAndroidInAppBrowser(UA.androidWebview)).toBe(true);
});
it('detects Facebook (FB_IAB/FBAN/FBAV)', () => {
expect(isAndroidInAppBrowser(UA.androidFacebook)).toBe(true);
});
it('detects Instagram', () => {
expect(isAndroidInAppBrowser(UA.androidInstagram)).toBe(true);
});
it('detects Line', () => {
expect(isAndroidInAppBrowser(UA.androidLine)).toBe(true);
});
it('detects WhatsApp', () => {
expect(isAndroidInAppBrowser(UA.androidWhatsapp)).toBe(true);
});
it('does not flag Chrome as in-app', () => {
expect(isAndroidInAppBrowser(UA.androidChrome)).toBe(false);
});
it('does not flag Firefox as in-app', () => {
expect(isAndroidInAppBrowser(UA.androidFirefox)).toBe(false);
});
});
describe('pickMobileFallbackUrl — iOS regular browser (Safari/Chrome)', () => {
it('prefers iOS store URL over web fallback', () => {
const r = pickMobileFallbackUrl('ios', UA.iosSafari, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r).toEqual({ url: URLS.iosStore, reason: 'ios_app_store_url' });
});
it('falls back to web fallback when iOS store URL is absent', () => {
const r = pickMobileFallbackUrl('ios', UA.iosSafari, null, URLS.androidStore, URLS.webFallback);
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
});
it('uses iOS store URL when web fallback is absent', () => {
const r = pickMobileFallbackUrl('ios', UA.iosSafari, URLS.iosStore, URLS.androidStore, null);
expect(r).toEqual({ url: URLS.iosStore, reason: 'ios_app_store_url' });
});
it('returns null when both iOS store URL and web fallback are absent', () => {
expect(pickMobileFallbackUrl('ios', UA.iosSafari, null, URLS.androidStore, null)).toBeNull();
});
});
describe('pickMobileFallbackUrl — iOS in-app browser (Gmail/FB/Instagram/etc.)', () => {
it('prefers web fallback over iOS store URL', () => {
const r = pickMobileFallbackUrl('ios', UA.iosGmail, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
});
it('falls back to iOS store URL when web fallback is absent', () => {
const r = pickMobileFallbackUrl('ios', UA.iosGmail, URLS.iosStore, URLS.androidStore, null);
expect(r).toEqual({ url: URLS.iosStore, reason: 'ios_app_store_url' });
});
it('returns null when both iOS store URL and web fallback are absent', () => {
expect(pickMobileFallbackUrl('ios', UA.iosGmail, null, URLS.androidStore, null)).toBeNull();
});
});
describe('pickMobileFallbackUrl — Android regular browser (Chrome/Firefox)', () => {
it('prefers Android store URL over web fallback', () => {
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r).toEqual({ url: URLS.androidStore, reason: 'android_app_store_url' });
});
it('falls back to web fallback when Android store URL is absent', () => {
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, null, URLS.webFallback);
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
});
it('uses Android store URL when web fallback is absent', () => {
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, URLS.androidStore, null);
expect(r).toEqual({ url: URLS.androidStore, reason: 'android_app_store_url' });
});
it('returns null when both Android store URL and web fallback are absent', () => {
expect(pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, null, null)).toBeNull();
});
});
describe('pickMobileFallbackUrl — Android in-app browser (FB/Instagram/Line/WebView)', () => {
it('prefers web fallback over Android store URL', () => {
const r = pickMobileFallbackUrl('android', UA.androidFacebook, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
});
it('detects WebView via wv) marker and prefers web fallback', () => {
const r = pickMobileFallbackUrl('android', UA.androidWebview, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r).toEqual({ url: URLS.webFallback, reason: 'web_fallback_url' });
});
it('falls back to Android store URL when web fallback is absent', () => {
const r = pickMobileFallbackUrl('android', UA.androidFacebook, URLS.iosStore, URLS.androidStore, null);
expect(r).toEqual({ url: URLS.androidStore, reason: 'android_app_store_url' });
});
});
describe('pickMobileFallbackUrl — reporter scenario regression test', () => {
// Reproduces SIT-163: user creates a link with iOS, Android, and web fallback URLs;
// mobile visitor expects the App/Play Store; previously got the web fallback.
it('iOS Safari → iOS App Store (was: web fallback)', () => {
const r = pickMobileFallbackUrl('ios', UA.iosSafari, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r?.url).toBe(URLS.iosStore);
expect(r?.reason).toBe('ios_app_store_url');
});
it('Android Chrome → Play Store (was: web fallback)', () => {
const r = pickMobileFallbackUrl('android', UA.androidChrome, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r?.url).toBe(URLS.androidStore);
expect(r?.reason).toBe('android_app_store_url');
});
// And the email-marketing flow that the prior fix protected stays correct:
it('iOS Gmail in-app → web fallback (preserves UL second-chance)', () => {
const r = pickMobileFallbackUrl('ios', UA.iosGmail, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r?.url).toBe(URLS.webFallback);
});
it('Android Facebook in-app → web fallback (preserves UL second-chance)', () => {
const r = pickMobileFallbackUrl('android', UA.androidFacebook, URLS.iosStore, URLS.androidStore, URLS.webFallback);
expect(r?.url).toBe(URLS.webFallback);
});
});
+606
View File
@@ -0,0 +1,606 @@
import { randomUUID } from 'crypto';
import { FastifyInstance } from 'fastify';
import { db } from '../lib/database.js';
import { getClientIp } from '../lib/client-ip.js';
import { parseUserAgent, getLocationFromIP, buildRedirectUrl, detectDevice } from '../lib/utils.js';
import { storeFingerprintForClick, type FingerprintData } from '../lib/fingerprint.js';
import { emitClickEvent } from '../lib/event-emitter.js';
import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js';
/**
* Detect iOS in-app browsers where Universal Links don't fire.
* These browsers use WKWebView which bypasses the Universal Links mechanism.
*/
export function isIOSInAppBrowser(userAgent: string): boolean {
const inAppPatterns = [
/GSA\//i, // Google Search App (Gmail in-app browser)
/Gmail\//i, // Gmail
/FBAN|FBAV/i, // Facebook
/Instagram/i, // Instagram
/Twitter/i, // Twitter/X
/LinkedIn/i, // LinkedIn
/MicroMessenger/i, // WeChat
/Outlook/i, // Outlook
/YahooMobile/i, // Yahoo Mail
];
return inAppPatterns.some(pattern => pattern.test(userAgent));
}
/**
* Detect Android in-app browsers where App Links don't fire.
* These browsers use Android WebView (or app-specific webviews) that bypass
* the App Link / Digital Asset Link mechanism.
*/
export function isAndroidInAppBrowser(userAgent: string): boolean {
const inAppPatterns = [
/FB_IAB|FBAN|FBAV/i, // Facebook in-app browser
/Instagram/i,
/Line\//i,
/KAKAOTALK/i,
/Twitter/i,
/LinkedIn/i,
/MicroMessenger/i, // WeChat
/Outlook-Android/i,
/WhatsApp/i,
/Pinterest/i,
/Telegram/i,
/Snapchat/i,
/\swv\)/, // Generic Android WebView marker (e.g. "Mobile Safari/537.36; wv)")
];
return inAppPatterns.some(pattern => pattern.test(userAgent));
}
/**
* Pick the destination URL for a mobile click that has fallen through the
* Universal Link / App Link / app_scheme priority steps. The choice depends on
* whether the click is from an in-app browser:
*
* - Regular browser (Safari, Chrome): the OS-level UL/App Link check ran and
* didn't fire, so the app must not be installed → prefer the App/Play Store URL.
*
* - In-app browser (Gmail, GSA, FB, Instagram, Outlook, etc.): UL is bypassed
* regardless of install state, so we don't know if the app is installed →
* prefer the web fallback URL, which gives the OS another chance to fire UL
* if the fallback is on the app's UL/App-Link domain.
*
* Returns null if no URL is available (caller should fall back to original_url).
*/
export function pickMobileFallbackUrl(
device: 'ios' | 'android',
userAgent: string,
iosUrl: string | null,
androidUrl: string | null,
webFallbackUrl: string | null,
): { url: string; reason: string } | null {
const inApp = device === 'ios'
? isIOSInAppBrowser(userAgent)
: isAndroidInAppBrowser(userAgent);
const storeUrl = device === 'ios' ? iosUrl : androidUrl;
const storeReason = device === 'ios' ? 'ios_app_store_url' : 'android_app_store_url';
if (inApp) {
if (webFallbackUrl) return { url: webFallbackUrl, reason: 'web_fallback_url' };
if (storeUrl) return { url: storeUrl, reason: storeReason };
} else {
if (storeUrl) return { url: storeUrl, reason: storeReason };
if (webFallbackUrl) return { url: webFallbackUrl, reason: 'web_fallback_url' };
}
return null;
}
/**
* Generate an interstitial HTML page that tries to open the app via custom scheme,
* then falls back to the App Store / Play Store.
*
* The JavaScript reads the URL fragment (window.location.hash) and appends it
* to the scheme URL. This preserves the E2E encryption key, which lives only
* in the fragment and is never sent to the server.
*/
function generateInterstitialHTML(schemeUrl: string, fallbackUrl: string, title?: string): string {
const safeSchemeUrl = schemeUrl.replace(/"/g, '&quot;').replace(/</g, '&lt;');
const safeFallbackUrl = fallbackUrl.replace(/"/g, '&quot;').replace(/</g, '&lt;');
const safeTitle = (title || 'the app').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Opening ${safeTitle}...</title>
<style>
body { font-family: -apple-system, system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f9fafb; color: #111827; text-align: center; }
.container { padding: 2rem; }
.spinner { width: 40px; height: 40px; border: 3px solid #e5e7eb; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 1.5rem; }
@keyframes spin { to { transform: rotate(360deg); } }
h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 0.5rem; }
p { font-size: 0.875rem; color: #6b7280; margin: 0 0 2rem; }
.btn { display: inline-block; padding: 0.75rem 1.5rem; border-radius: 0.5rem; font-size: 0.875rem; font-weight: 500; text-decoration: none; margin: 0.25rem; }
.btn-primary { background: #3b82f6; color: #fff; }
.btn-secondary { background: #e5e7eb; color: #374151; }
</style>
</head><body>
<div class="container">
<div class="spinner"></div>
<h1>Opening ${safeTitle}...</h1>
<p>If the app doesn't open automatically:</p>
<a class="btn btn-primary" id="open-btn" href="${safeSchemeUrl}">Open App</a>
<a class="btn btn-secondary" id="store-btn" href="${safeFallbackUrl}">Download App</a>
</div>
<script>
// Preserve URL fragment (E2E encryption key) through the scheme redirect
var hash = window.location.hash || '';
var schemeUrl = "${safeSchemeUrl}" + hash;
document.getElementById('open-btn').href = schemeUrl;
window.location = schemeUrl;
setTimeout(function() { window.location.replace("${safeFallbackUrl}"); }, 1500);
</script>
</body></html>`;
}
export async function redirectRoutes(fastify: FastifyInstance) {
// Helper function to handle the actual redirect logic
async function handleRedirect(request: any, reply: any, shortCode: string, templateSlug?: string) {
let linkData: string | null = null;
// Build cache key (include template if present)
const cacheKey = templateSlug ? `link:${templateSlug}:${shortCode}` : `link:${shortCode}`;
// Try to get link from cache if Redis is available
if (fastify.redis) {
try {
linkData = await fastify.redis.get(cacheKey);
} catch (error) {
fastify.log.warn('Redis cache lookup failed, falling back to database');
}
}
if (!linkData) {
// Build query based on whether template slug is provided
let query: string;
let params: any[];
if (templateSlug) {
// Template-based URL: verify both template and link match
// Also fetch template settings and org settings for URL fallback chain
query = `
SELECT l.*, t.settings AS template_settings, o.settings AS org_settings
FROM links l
LEFT JOIN link_templates t ON l.template_id = t.id
LEFT JOIN organizations o ON l.organization_id = o.id
WHERE l.short_code = $1 AND t.slug = $2
AND l.is_active = true
AND (l.expires_at IS NULL OR l.expires_at > NOW())
`;
params = [shortCode, templateSlug];
} else {
// Legacy URL: just lookup by short code
// Also fetch template settings and org settings for URL fallback chain
query = `
SELECT l.*, t.settings AS template_settings, o.settings AS org_settings
FROM links l
LEFT JOIN link_templates t ON l.template_id = t.id
LEFT JOIN organizations o ON l.organization_id = o.id
WHERE l.short_code = $1 AND l.is_active = true
AND (l.expires_at IS NULL OR l.expires_at > NOW())
`;
params = [shortCode];
}
const result = await db.query(query, params);
if (result.rows.length === 0) {
return reply.status(404).send({ error: 'Link not found' });
}
linkData = JSON.stringify(result.rows[0]);
// Cache for 5 minutes if Redis is available
if (fastify.redis) {
try {
await fastify.redis.setex(cacheKey, 300, linkData);
} catch (error) {
fastify.log.warn('Redis cache set failed');
}
}
}
const link = JSON.parse(linkData);
// Check targeting rules BEFORE redirecting
if (link.targeting_rules) {
const userAgent = request.headers['user-agent'] || '';
const ip = getClientIp(request);
const acceptLanguage = request.headers['accept-language'] || '';
// Get user's actual data for targeting checks
const device = detectDevice(userAgent);
const { countryCode } = getLocationFromIP(ip);
// Extract primary language from accept-language header (e.g., "en-US,en;q=0.9" -> "en")
const primaryLanguage = acceptLanguage.split(',')[0]?.split('-')[0]?.toLowerCase();
const rules = link.targeting_rules;
let isTargeted = true;
// Check country targeting
if (rules.countries && rules.countries.length > 0) {
const targetCountries = rules.countries.map((c: string) => c.toUpperCase());
if (!countryCode || !targetCountries.includes(countryCode.toUpperCase())) {
isTargeted = false;
}
}
// Check device targeting
if (rules.devices && rules.devices.length > 0) {
if (!rules.devices.includes(device)) {
isTargeted = false;
}
}
// Check language targeting
if (rules.languages && rules.languages.length > 0) {
const targetLanguages = rules.languages.map((l: string) => l.toLowerCase());
if (!primaryLanguage || !targetLanguages.includes(primaryLanguage)) {
isTargeted = false;
}
}
// If targeting rules exist but user doesn't match, return 404
if (!isTargeted) {
return reply.status(404).send({ error: 'Link not found' });
}
}
// Generate the click id up front (rather than letting the DB default it on
// insert) so the synchronous redirect below can carry it on the destination
// URL while the click row is still written asynchronously with the same id.
const clickId = randomUUID();
// Track click asynchronously
setImmediate(async () => {
try {
const userAgent = request.headers['user-agent'] || '';
const ip = getClientIp(request);
const referrer = request.headers.referer || null;
const acceptLanguage = request.headers['accept-language'] || '';
const deviceType = detectDevice(userAgent);
const { platform, platformVersion } = parseUserAgent(userAgent);
const { countryCode, countryName, region, city, latitude, longitude, timezone } = getLocationFromIP(ip);
// Classify bots at ingestion (SIT-298) — persisted on the row so
// analytics reads a consistent flag instead of re-detecting from the
// stored user-agent.
const { isBot, reason: botReason } = classifyBot(
userAgent,
request.method,
edgeBotSignal(request.headers['x-lf-bot'])
);
// Extract UTM parameters from query string
const query = request.query as Record<string, string | undefined>;
const utmSource = query?.utm_source;
const utmMedium = query?.utm_medium;
const utmCampaign = query?.utm_campaign;
// Extract fingerprint data from query params (sent by SDK/client)
const fpTimezone = query?.fp_tz || timezone || undefined;
const fpLanguage = query?.fp_lang || acceptLanguage.split(',')[0]?.split(';')[0] || undefined;
const fpScreenWidth = query?.fp_sw ? parseInt(query.fp_sw, 10) : undefined;
const fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined;
// Insert click event with the pre-generated id (see above) so the row
// matches the lf_click value already placed on the redirect URL.
await db.query(
`INSERT INTO click_events (
id, link_id, ip_address, user_agent, device_type, platform,
country_code, country_name, region, city, latitude, longitude, timezone,
utm_source, utm_medium, utm_campaign, referrer, is_bot, bot_reason
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
[
clickId,
link.id,
ip,
userAgent,
deviceType,
platform,
countryCode,
countryName,
region,
city,
latitude,
longitude,
timezone,
utmSource,
utmMedium,
utmCampaign,
referrer,
isBot,
botReason,
]
);
// Store device fingerprint for deferred deep linking
const fingerprintData: FingerprintData = {
ipAddress: ip,
userAgent,
timezone: fpTimezone,
language: fpLanguage,
screenWidth: fpScreenWidth,
screenHeight: fpScreenHeight,
platform: deviceType,
platformVersion,
};
await storeFingerprintForClick(clickId, fingerprintData);
// Determine redirect URL for event emission (using same logic as main redirect)
// Use the same fallback chain: link → template → workspace
const tplSettings = link.template_settings || {};
const oSettings = link.org_settings || {};
const oAppConfig = oSettings.appConfig || {};
const iosStoreUrl = link.ios_app_store_url || tplSettings.defaultIosUrl || oAppConfig.iosAppStoreUrl || null;
const androidStoreUrl = link.android_app_store_url || tplSettings.defaultAndroidUrl || oAppConfig.androidAppStoreUrl || null;
const webFallback = link.web_fallback_url || tplSettings.defaultWebFallbackUrl || oAppConfig.webFallbackUrl || null;
let redirectUrl = link.original_url;
let redirectReason = 'original_url';
if (deviceType === 'ios') {
if (link.ios_universal_link) {
redirectUrl = link.ios_universal_link;
redirectReason = 'ios_universal_link';
} else if (link.app_scheme && link.deep_link_path) {
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
redirectReason = 'app_scheme';
} else {
const fb = pickMobileFallbackUrl('ios', userAgent, iosStoreUrl, androidStoreUrl, webFallback);
if (fb) {
redirectUrl = fb.url;
redirectReason = fb.reason;
}
}
} else if (deviceType === 'android') {
if (link.android_app_link) {
redirectUrl = link.android_app_link;
redirectReason = 'android_app_link';
} else if (link.app_scheme && link.deep_link_path) {
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
redirectReason = 'app_scheme';
} else {
const fb = pickMobileFallbackUrl('android', userAgent, iosStoreUrl, androidStoreUrl, webFallback);
if (fb) {
redirectUrl = fb.url;
redirectReason = fb.reason;
}
}
} else if (deviceType === 'web' && webFallback) {
redirectUrl = webFallback;
redirectReason = 'web_fallback_url';
}
const finalRedirectUrl = buildRedirectUrl(redirectUrl, link.utm_parameters) || redirectUrl;
// Emit click event for real-time streaming to WebSocket clients
emitClickEvent({
eventId: clickId,
timestamp: new Date().toISOString(),
linkId: link.id,
shortCode: link.short_code,
userId: link.user_id,
ipAddress: ip,
userAgent,
country: countryCode || undefined,
city: city || undefined,
deviceType,
platform: platform || undefined,
redirectUrl: finalRedirectUrl,
redirectReason,
targetingMatched: true, // If we got here, targeting matched
utmParameters: link.utm_parameters || undefined,
referer: referrer || undefined,
language: fpLanguage,
});
// Trigger webhooks for click_event
try {
const webhooksResult = await db.query(
'SELECT * FROM webhooks WHERE user_id = $1 AND is_active = true',
[link.user_id]
);
if (webhooksResult.rows.length > 0) {
const { triggerWebhooks } = await import('../lib/webhook.js');
const clickEventData = {
id: clickId,
linkId: link.id,
clickedAt: new Date().toISOString(),
ipAddress: ip,
userAgent,
deviceType,
platform,
countryCode,
countryName,
region,
city,
latitude,
longitude,
timezone,
utmSource,
utmMedium,
utmCampaign,
referrer,
};
// Trigger webhooks without delivery logging (basic version)
// For delivery logging, use @linkforty/cloud premium features
await triggerWebhooks(
webhooksResult.rows,
'click_event',
clickId,
clickEventData
);
}
} catch (webhookError) {
fastify.log.error(`Error triggering click webhooks: ${webhookError}`);
}
} catch (error) {
fastify.log.error(`Error tracking click: ${error}`);
}
});
// Determine redirect URL based on device with smart fallback chain
// Fallback chain: link URLs → template default URLs → workspace settings URLs
const userAgent = request.headers['user-agent'] || '';
const device = detectDevice(userAgent);
// Extract fallback URLs from template settings and org settings
const templateSettings = link.template_settings || {};
const orgSettings = link.org_settings || {};
const orgAppConfig = orgSettings.appConfig || {};
// Resolve platform URLs with fallback chain: link → template → workspace
const iosUrl = link.ios_app_store_url || templateSettings.defaultIosUrl || orgAppConfig.iosAppStoreUrl || null;
const androidUrl = link.android_app_store_url || templateSettings.defaultAndroidUrl || orgAppConfig.androidAppStoreUrl || null;
const webFallbackUrl = link.web_fallback_url || templateSettings.defaultWebFallbackUrl || orgAppConfig.webFallbackUrl || null;
let redirectUrl = link.original_url;
let useSchemeUrl = false; // Track if we're using a URI scheme URL
if (device === 'ios') {
// iOS Priority:
// 1. Universal Link (HTTPS URL with AASA file) — if app installed, OS opens app
// (this branch only runs when UL didn't fire upstream, e.g. in-app browser)
// 2. URI scheme (myapp://path) — explicit deep link
// 3. Mobile fallback (browser-aware):
// - regular browser: App Store URL > web fallback URL
// (UL would have fired if app installed, so app is not installed)
// - in-app browser: web fallback URL > App Store URL
// (UL was bypassed; web fallback gives UL a second chance to fire)
// 4. Original URL — ultimate fallback
if (link.ios_universal_link) {
redirectUrl = link.ios_universal_link;
} else if (link.app_scheme && link.deep_link_path) {
// Build URI scheme URL: myapp://product/123
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
useSchemeUrl = true;
} else {
const fb = pickMobileFallbackUrl('ios', userAgent, iosUrl, androidUrl, webFallbackUrl);
if (fb) redirectUrl = fb.url;
}
} else if (device === 'android') {
// Android Priority — same logic as iOS, with android_app_link in place of UL
if (link.android_app_link) {
redirectUrl = link.android_app_link;
} else if (link.app_scheme && link.deep_link_path) {
// Build URI scheme URL: myapp://product/123
redirectUrl = `${link.app_scheme}://${link.deep_link_path.replace(/^\//, '')}`;
useSchemeUrl = true;
} else {
const fb = pickMobileFallbackUrl('android', userAgent, iosUrl, androidUrl, webFallbackUrl);
if (fb) redirectUrl = fb.url;
}
} else if (device === 'web') {
// Web fallback
redirectUrl = webFallbackUrl || link.original_url;
}
// If no URL found at all, return a user-friendly error
if (!redirectUrl) {
return reply.status(404).send({ error: 'No destination URL configured for this link' });
}
// Build final URL with parameters
let finalUrl = redirectUrl;
if (!useSchemeUrl) {
// For HTTP(S) URLs, add UTM parameters
finalUrl = buildRedirectUrl(redirectUrl, link.utm_parameters) || redirectUrl;
// Add deep link parameters as query params
if (link.deep_link_parameters && Object.keys(link.deep_link_parameters).length > 0) {
try {
const url = new URL(finalUrl);
Object.entries(link.deep_link_parameters).forEach(([key, value]) => {
url.searchParams.set(key, String(value));
});
finalUrl = url.toString();
} catch (error) {
// If URL parsing fails, continue without deep link parameters
console.error('Failed to add deep link parameters:', error);
}
}
// When opted in per link (append_click_id), append the originating click id
// so a downstream analytics tool on the landing page can correlate the
// landing visit to this exact click. Opt-in (default off), web/HTTPS only —
// an absent/false flag (incl. stale cache) leaves the destination untouched.
if (link.append_click_id === true) {
try {
const url = new URL(finalUrl);
url.searchParams.set('lf_click', clickId);
finalUrl = url.toString();
} catch {
// Non-absolute / unparseable URL — skip the correlation param.
}
}
} else {
// For URI scheme URLs, append query params differently
if (link.deep_link_parameters && Object.keys(link.deep_link_parameters).length > 0) {
const params = new URLSearchParams(
Object.entries(link.deep_link_parameters).map(([k, v]) => [k, String(v)] as [string, string])
);
finalUrl += `?${params.toString()}`;
}
}
// Serve an interstitial page for mobile requests when a custom scheme is available.
// The interstitial tries to open the app via URI scheme, then falls back to the store.
// This works for both in-app browsers (where Universal Links don't fire) and regular
// browsers (where a 302 to a custom scheme fails silently if the app isn't installed).
// The interstitial JavaScript preserves the URL fragment (E2E encryption key).
if ((device === 'ios' || device === 'android') && link.app_scheme) {
const deepPath = link.deep_link_path ? link.deep_link_path.replace(/^\//, '') : '';
const schemeUrl = link.custom_scheme_url
|| `${link.app_scheme}://${deepPath}`;
// The interstitial JS tries the scheme first; storeFallback is what we
// navigate to if the scheme doesn't open the app within ~1.5s. Pick it
// browser-aware: regular browsers prefer the store URL, in-app browsers
// prefer the web fallback (gives UL a second chance to fire).
const fb = pickMobileFallbackUrl(device, userAgent, iosUrl, androidUrl, webFallbackUrl);
const storeFallback = fb?.url || link.original_url;
if (storeFallback) {
let fullSchemeUrl = schemeUrl;
if (link.deep_link_parameters && Object.keys(link.deep_link_parameters).length > 0) {
const params = new URLSearchParams(
Object.entries(link.deep_link_parameters).map(([k, v]: [string, any]) => [k, String(v)] as [string, string])
);
fullSchemeUrl += (fullSchemeUrl.includes('?') ? '&' : '?') + params.toString();
}
return reply
.header('Content-Type', 'text/html; charset=utf-8')
.send(generateInterstitialHTML(fullSchemeUrl, storeFallback, link.title || link.og_title));
}
}
// Redirect
return reply.redirect(302, finalUrl);
}
// Template-based shortlink route: /:templateSlug/:shortCode
fastify.get('/:templateSlug/:shortCode', async (request, reply) => {
const { templateSlug, shortCode } = request.params as { templateSlug: string; shortCode: string };
return handleRedirect(request, reply, shortCode, templateSlug);
});
// Legacy shortlink route (no template): /:shortCode
fastify.get('/:shortCode', async (request, reply) => {
const { shortCode } = request.params as { shortCode: string };
return handleRedirect(request, reply, shortCode);
});
}
+192
View File
@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
// Mock the database singleton so the route runs without a real Postgres.
vi.mock('../lib/database.js', () => ({
db: { query: vi.fn() },
}));
import Fastify, { type FastifyInstance } from 'fastify';
import { db } from '../lib/database.js';
import { sdkRoutes } from './sdk.js';
const mockQuery = db.query as unknown as Mock;
const INSTALL_ID = '11111111-1111-4111-8111-111111111111';
const LINK_ID = '22222222-2222-4222-8222-222222222222';
const SESSION_ID = '33333333-3333-4333-8333-333333333333';
const CLICK_ID = '44444444-4444-4444-8444-444444444444';
const EVENT_ID = '55555555-5555-4555-8555-555555555555';
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify();
await app.register(sdkRoutes);
await app.ready();
return app;
}
const EVENT_TS = '2026-06-05T10:05:00.000Z';
const LINK_OPENED_AT = '2026-06-05T10:00:00.000Z';
const stampedEvent = {
installId: INSTALL_ID,
eventName: 'add_to_cart',
eventData: { sku: 'abc' },
timestamp: EVENT_TS,
attributedLinkId: LINK_ID,
attributedClickId: CLICK_ID,
linkOpenedAt: LINK_OPENED_AT,
sessionId: SESSION_ID,
sdkName: 'react-native',
sdkVersion: '1.4.0',
};
describe('POST /api/sdk/v1/links', () => {
beforeEach(() => {
mockQuery.mockReset();
});
it('accepts payloads without originalUrl and derives a fallback destination', async () => {
mockQuery.mockResolvedValueOnce({
rows: [{
id: LINK_ID,
short_code: 'abc123',
original_url: 'https://example.com/emoji/detail',
}],
});
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/sdk/v1/links',
payload: {
deepLinkParameters: { emojiSetId: 'uex1k7qy', path: '/emoji/detail' },
utmParameters: { campaign: 'emoji_share' },
},
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({
shortCode: 'abc123',
originalUrl: 'https://example.com/emoji/detail',
});
const insertCall = mockQuery.mock.calls[0];
expect(insertCall[0]).toMatch(/INSERT INTO links/);
expect(insertCall[1][3]).toBe('https://example.com/emoji/detail');
await app.close();
});
});
describe('POST /api/sdk/v1/event — last-click attribution stamp', () => {
beforeEach(() => {
mockQuery.mockReset();
});
it('persists the attribution stamp on the in_app_events row', async () => {
// 1) install lookup (link_id null so the webhook block is skipped)
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
// 2) the event INSERT
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });
const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true });
const insertCall = mockQuery.mock.calls[1];
expect(insertCall[0]).toMatch(/INSERT INTO in_app_events/);
expect(insertCall[0]).toMatch(/attributed_link_id/);
// params: install, name, dataJson, ts, link, click, openedAt, session
expect(insertCall[1]).toEqual([
INSTALL_ID,
'add_to_cart',
JSON.stringify({ sku: 'abc' }),
EVENT_TS,
LINK_ID,
CLICK_ID,
LINK_OPENED_AT,
SESSION_ID,
'react-native',
'1.4.0',
]);
await app.close();
});
it('stays backward compatible: an event with no stamp stores null attribution + a sessionId-less row', async () => {
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/sdk/v1/event',
payload: { installId: INSTALL_ID, eventName: 'signup' },
});
expect(res.statusCode).toBe(200);
const params = mockQuery.mock.calls[1][1];
expect(params[4]).toBeNull(); // attributed_link_id
expect(params[5]).toBeNull(); // attributed_click_id
expect(params[6]).toBeNull(); // attributed_at
expect(params[7]).toBeNull(); // session_id
await app.close();
});
it('never loses an event when the attributed link is stale: falls back to no-link insert on FK violation', async () => {
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
// first INSERT rejects with the attributed_link_id FK violation
mockQuery.mockRejectedValueOnce(Object.assign(new Error('FK violation'), { code: '23503', constraint: 'in_app_events_attributed_link_id_fkey' }));
// fallback INSERT (without attributed_link_id) succeeds
mockQuery.mockResolvedValueOnce({ rows: [{ id: EVENT_ID }] });
const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ eventId: EVENT_ID, acknowledged: true });
// the fallback INSERT omits attributed_link_id but keeps click/session
const fallbackCall = mockQuery.mock.calls[2];
expect(fallbackCall[0]).not.toMatch(/attributed_link_id/);
expect(fallbackCall[1]).toEqual([
INSTALL_ID,
'add_to_cart',
JSON.stringify({ sku: 'abc' }),
EVENT_TS,
CLICK_ID,
LINK_OPENED_AT,
SESSION_ID,
'react-native',
'1.4.0',
]);
await app.close();
});
it('rethrows a non-link FK violation (e.g. install deleted mid-request) instead of mislabeling it as a link problem', async () => {
mockQuery.mockResolvedValueOnce({ rows: [{ id: INSTALL_ID, link_id: null }] });
// install_id's FK lost to a concurrent install delete — a 23503 that is NOT the link FK
mockQuery.mockRejectedValueOnce(Object.assign(new Error('FK violation'), { code: '23503', constraint: 'in_app_events_install_id_fkey' }));
const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
// Surfaces as a real error; no misleading no-link fallback insert is attempted.
expect(res.statusCode).toBe(500);
expect(mockQuery.mock.calls.length).toBe(2); // install lookup + the failed insert only
await app.close();
});
it('returns 404 when the install does not exist', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/api/sdk/v1/event', payload: stampedEvent });
expect(res.statusCode).toBe(404);
await app.close();
});
});
+754
View File
@@ -0,0 +1,754 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { db } from '../lib/database.js';
import { getClientIp } from '../lib/client-ip.js';
import {
recordInstallEvent,
generateFingerprintHash,
storeFingerprintForClick,
type FingerprintData,
} from '../lib/fingerprint.js';
import { triggerWebhooks } from '../lib/webhook.js';
import { parseUserAgent, getLocationFromIP, detectDevice } from '../lib/utils.js';
import { emitClickEvent } from '../lib/event-emitter.js';
import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js';
/**
* SDK Routes - Mobile SDK endpoints for deferred deep linking
* These endpoints are used by the mobile SDKs to report installs and retrieve attribution data
*/
export async function sdkRoutes(fastify: FastifyInstance) {
/**
* POST /api/sdk/v1/links
* Create a link from the simplified SDK payload.
*
* This endpoint accepts the subset of link creation fields that the SDK sends,
* including deepLinkParameters and utmParameters. When originalUrl is omitted,
* it falls back to the current page's URL or a placeholder destination.
*/
fastify.post('/api/sdk/v1/links', async (request, reply) => {
const schema = z.object({
originalUrl: z.string().url().optional(),
title: z.string().optional(),
description: z.string().optional(),
iosAppStoreUrl: z.string().url().optional(),
androidAppStoreUrl: z.string().url().optional(),
webFallbackUrl: z.string().url().optional(),
appScheme: z.string().optional(),
iosUniversalLink: z.string().url().optional(),
androidAppLink: z.string().url().optional(),
deepLinkPath: z.string().optional(),
deepLinkParameters: z.record(z.string(), z.any()).optional(),
customCode: z.string().optional(),
utmParameters: z.object({
source: z.string().optional(),
medium: z.string().optional(),
campaign: z.string().optional(),
term: z.string().optional(),
content: z.string().optional(),
}).optional(),
targetingRules: z.object({
countries: z.array(z.string()).optional(),
devices: z.array(z.enum(['ios', 'android', 'web'])).optional(),
languages: z.array(z.string()).optional(),
}).optional(),
ogTitle: z.string().optional(),
ogDescription: z.string().optional(),
ogImageUrl: z.string().url().optional(),
ogType: z.string().optional(),
attributionWindowHours: z.number().int().min(1).max(2160).optional(),
expiresAt: z.string().datetime().optional(),
templateId: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
});
const body = schema.parse(request.body);
const originalUrl = body.originalUrl || 'https://angkorlifes.com';
const shortCode = body.customCode || 'sdk-link';
const result = await db.query(
`INSERT INTO links (
user_id, template_id, short_code, original_url, title, description,
ios_app_store_url, android_app_store_url, web_fallback_url,
app_scheme, ios_universal_link, android_app_link, deep_link_path, deep_link_parameters,
utm_parameters, targeting_rules,
og_title, og_description, og_image_url, og_type,
attribution_window_hours, expires_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
RETURNING *`,
[
body.userId || null,
body.templateId || null,
shortCode,
originalUrl,
body.title || null,
body.description || null,
body.iosAppStoreUrl || null,
body.androidAppStoreUrl || null,
body.webFallbackUrl || null,
body.appScheme || null,
body.iosUniversalLink || null,
body.androidAppLink || null,
body.deepLinkPath || null,
JSON.stringify(body.deepLinkParameters || {}),
JSON.stringify(body.utmParameters || {}),
JSON.stringify(body.targetingRules || {}),
body.ogTitle || null,
body.ogDescription || null,
body.ogImageUrl || null,
body.ogType || 'website',
body.attributionWindowHours || 168,
body.expiresAt || null,
]
);
const link = result.rows[0];
return reply.status(200).send({
id: link.id,
shortCode: link.short_code,
originalUrl: link.original_url,
url: `${process.env.PUBLIC_BASE_URL || 'http://localhost:3000'}/${link.short_code}`,
});
});
/**
* POST /api/sdk/v1/install
* Report app installation and retrieve deferred deep link data
*
* Request body:
* - ipAddress: Optional, for debug only (untrusted; server uses connection/proxy headers for trusted IP)
* - userAgent: Client user agent
* - timezone: Device timezone (e.g., "America/New_York")
* - language: Device language (e.g., "en-US")
* - screenWidth: Screen width in pixels
* - screenHeight: Screen height in pixels
* - platform: Platform name (e.g., "iOS", "Android")
* - platformVersion: Platform version (e.g., "15.0")
* - deviceId: Optional device identifier (IDFA, GAID, etc.)
* - attributionWindowHours: Optional custom attribution window (default: 168 = 7 days)
*
* Response:
* - installId: UUID of the install event
* - attributed: Boolean indicating if install was matched to a click
* - confidenceScore: Confidence score (0-100) if matched
* - matchedFactors: Array of matched fingerprint factors
* - deepLinkData: Deep link data if matched (shortCode, URLs, UTM params, etc.)
*/
fastify.post('/api/sdk/v1/install', async (request, reply) => {
const schema = z.object({
ipAddress: z.string().optional(),
userAgent: z.string(),
timezone: z.string().optional(),
language: z.string().optional(),
screenWidth: z.number().optional(),
screenHeight: z.number().optional(),
platform: z.string().optional(),
platformVersion: z.string().optional(),
deviceId: z.string().optional(),
attributionWindowHours: z.number().optional(),
// SDK identity for health/version diagnostics (SIT-235). Free-form by
// design: a consumer tolerates/normalizes non-semver versions — we never
// reject a request over this metadata. Empty → null.
sdkName: z.string().max(50).optional(),
sdkVersion: z.string().max(50).optional(),
// Public app token shipped in SDK app bundles to scope organic
// installs to the right org in multi-tenant deployments. A multi-tenant
// host reads it to route the install to the correct tenant; self-hosted
// single-tenant deployments simply ignore it.
appToken: z.string().optional(),
});
const body = schema.parse(request.body);
// Trusted IP from connection/proxy headers only; never use body.ipAddress for attribution/fingerprint
const ipAddress = getClientIp(request);
const fingerprintData: FingerprintData = {
ipAddress,
userAgent: body.userAgent,
timezone: body.timezone,
language: body.language,
screenWidth: body.screenWidth,
screenHeight: body.screenHeight,
platform: body.platform,
platformVersion: body.platformVersion,
};
try {
const result = await recordInstallEvent(
fingerprintData,
body.deviceId,
body.attributionWindowHours,
{ name: body.sdkName, version: body.sdkVersion }
);
return reply.status(200).send({
installId: result.installId,
attributed: result.match !== null,
confidenceScore: result.match?.confidenceScore || 0,
matchedFactors: result.match?.matchedFactors || [],
deepLinkData: result.deepLinkData,
...(body.ipAddress != null && { clientReportedIp: body.ipAddress }),
});
} catch (error: any) {
fastify.log.error(`Error recording install event: ${error}`);
return reply.status(500).send({
error: 'Failed to record install event',
message: error.message,
});
}
});
/**
* GET /api/sdk/v1/attribution/:fingerprint
* Retrieve attribution data for a specific device fingerprint
* Used for debugging or delayed attribution lookups
*
* Response:
* - fingerprint: The fingerprint hash
* - attributed: Boolean indicating if attributed to a click
* - installEvent: Install event data if found
* - clickEvent: Matched click event data if attributed
* - linkData: Link data if attributed
*/
fastify.get('/api/sdk/v1/attribution/:fingerprint', async (request, reply) => {
const { fingerprint } = request.params as { fingerprint: string };
try {
// Look up install event by fingerprint
const installResult = await db.query(
`SELECT
ie.*,
l.short_code,
l.original_url,
l.ios_app_store_url,
l.android_app_store_url,
l.web_fallback_url,
l.utm_parameters,
l.deep_link_parameters
FROM install_events ie
LEFT JOIN links l ON ie.link_id = l.id
WHERE ie.fingerprint_hash = $1
ORDER BY ie.installed_at DESC
LIMIT 1`,
[fingerprint]
);
if (installResult.rows.length === 0) {
return reply.status(404).send({
error: 'No install event found for this fingerprint',
});
}
const install = installResult.rows[0];
const attributed = install.link_id !== null;
let clickData = null;
if (install.click_id) {
const clickResult = await db.query(
`SELECT * FROM click_events WHERE id = $1`,
[install.click_id]
);
if (clickResult.rows.length > 0) {
clickData = clickResult.rows[0];
}
}
return reply.status(200).send({
fingerprint,
attributed,
installEvent: {
id: install.id,
installedAt: install.installed_at,
firstOpenAt: install.first_open_at,
confidenceScore: parseFloat(install.confidence_score || '0'),
deepLinkRetrieved: install.deep_link_retrieved,
},
clickEvent: clickData
? {
id: clickData.id,
clickedAt: clickData.clicked_at,
deviceType: clickData.device_type,
platform: clickData.platform,
countryCode: clickData.country_code,
city: clickData.city,
}
: null,
linkData: attributed
? {
shortCode: install.short_code,
originalUrl: install.original_url,
iosUrl: install.ios_app_store_url,
androidUrl: install.android_app_store_url,
webFallbackUrl: install.web_fallback_url,
utmParameters: install.utm_parameters,
deepLinkParameters: install.deep_link_parameters,
}
: null,
});
} catch (error: any) {
fastify.log.error(`Error retrieving attribution: ${error}`);
return reply.status(500).send({
error: 'Failed to retrieve attribution data',
message: error.message,
});
}
});
/**
* POST /api/sdk/v1/event
* Track in-app events (purchases, signups, etc.)
* Used for conversion tracking and webhook triggers
*
* Revenue convention (all SDKs):
* eventName: "revenue"
* eventData: { revenue: number, currency: string, ...properties }
*
* Request body:
* - installId: UUID of the install event
* - eventName: Name of the event (e.g., "purchase", "signup", "level_complete")
* - eventData: Optional JSON data associated with the event
* - timestamp: Optional event timestamp (defaults to now)
*
* Last-click attribution stamp (SIT-237, all optional / backward compatible):
* - attributedLinkId: UUID of the deep link currently credited (last-click)
* - attributedClickId: UUID of the originating click, when known
* - linkOpenedAt: ISO timestamp of when that deep link opened the app
* - sessionId: UUID identifying the app-open session (for screen-flow grouping)
*
* Response:
* - eventId: UUID of the tracked event
* - acknowledged: Boolean confirmation
*/
fastify.post('/api/sdk/v1/event', async (request, reply) => {
const schema = z.object({
installId: z.string().uuid(),
eventName: z.string(),
eventData: z.record(z.any()).optional(),
timestamp: z.string().datetime().optional(),
attributedLinkId: z.string().uuid().optional(),
attributedClickId: z.string().uuid().optional(),
linkOpenedAt: z.string().datetime().optional(),
sessionId: z.string().uuid().optional(),
// SDK identity for version-health diagnostics (SIT-235). Free-form by
// design: a consumer tolerates/normalizes non-semver versions — we never
// reject a request over this metadata. Empty → null.
sdkName: z.string().max(50).optional(),
sdkVersion: z.string().max(50).optional(),
});
const body = schema.parse(request.body);
try {
// Verify install exists and get link_id for webhook lookup
const installCheck = await db.query(
`SELECT id, link_id FROM install_events WHERE id = $1`,
[body.installId]
);
if (installCheck.rows.length === 0) {
return reply.status(404).send({
error: 'Install event not found',
});
}
const install = installCheck.rows[0];
const eventTimestamp = body.timestamp || new Date().toISOString();
const eventDataJson = JSON.stringify(body.eventData || {});
// Insert event with the last-click attribution stamp. The attributing link
// may differ from the install link (re-engagement) or be absent (organic).
let eventResult;
try {
eventResult = await db.query(
`INSERT INTO in_app_events
(install_id, event_name, event_data, event_timestamp,
attributed_link_id, attributed_click_id, attributed_at, session_id,
sdk_name, sdk_version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
[
body.installId,
body.eventName,
eventDataJson,
eventTimestamp,
body.attributedLinkId ?? null,
body.attributedClickId ?? null,
body.linkOpenedAt ?? null,
body.sessionId ?? null,
body.sdkName || null,
body.sdkVersion || null,
]
);
} catch (insertError: any) {
// Only a stale/unknown *attributed link* FK is recoverable here: record
// the event without link attribution rather than losing it. Any other
// 23503 — e.g. install_id's FK lost to a concurrent install delete — is a
// real error that must surface, not be mislabeled as a link problem.
const isLinkFk =
insertError?.code === '23503' &&
String(insertError?.constraint ?? '').includes('attributed_link_id');
if (isLinkFk) {
fastify.log.warn(
`attributed_link_id ${body.attributedLinkId} not found; storing event without link attribution`
);
// Keep this column list in sync with the primary INSERT above (it just
// omits attributed_link_id). attributed_click_id is intentionally kept
// without a link: the orphaned-click case is expected, and a null
// attributed_link_id is the correct value for link-keyed aggregation
// (the SIT-261 consumer must not read it as a data bug).
eventResult = await db.query(
`INSERT INTO in_app_events
(install_id, event_name, event_data, event_timestamp,
attributed_click_id, attributed_at, session_id,
sdk_name, sdk_version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`,
[
body.installId,
body.eventName,
eventDataJson,
eventTimestamp,
body.attributedClickId ?? null,
body.linkOpenedAt ?? null,
body.sessionId ?? null,
body.sdkName || null,
body.sdkVersion || null,
]
);
} else {
throw insertError;
}
}
const eventId = eventResult.rows[0].id;
fastify.log.info({
eventId,
installId: body.installId,
linkId: install.link_id,
eventName: body.eventName,
eventData: body.eventData,
timestamp: eventTimestamp,
});
// Trigger webhooks if install was attributed to a link
if (install.link_id) {
// Query webhooks for the user who owns the link
const webhooksResult = await db.query(
`SELECT w.*
FROM webhooks w
INNER JOIN links l ON l.user_id = w.user_id
WHERE l.id = $1 AND w.is_active = true`,
[install.link_id]
);
if (webhooksResult.rows.length > 0) {
const eventData = {
eventId,
installId: body.installId,
linkId: install.link_id,
eventName: body.eventName,
eventData: body.eventData || {},
timestamp: eventTimestamp,
};
// Trigger webhooks asynchronously (fire and forget)
setImmediate(async () => {
// Trigger conversion_event webhooks (attributed installs only)
triggerWebhooks(
webhooksResult.rows,
'conversion_event',
eventId,
eventData
).catch((error) => {
fastify.log.error('Failed to trigger conversion webhooks:', error);
});
// Trigger sdk_event webhooks (all SDK-tracked events)
triggerWebhooks(
webhooksResult.rows,
'sdk_event',
eventId,
eventData
).catch((error) => {
fastify.log.error('Failed to trigger sdk_event webhooks:', error);
});
});
}
}
return reply.status(200).send({
eventId,
acknowledged: true,
});
} catch (error: any) {
fastify.log.error(`Error tracking event: ${error}`);
return reply.status(500).send({
error: 'Failed to track event',
message: error.message,
});
}
});
/**
* GET /api/sdk/v1/resolve/:shortCode
* GET /api/sdk/v1/resolve/:templateSlug/:shortCode
*
* Resolve a short link to its deep link data without triggering a redirect.
* Used by mobile SDKs when the OS intercepts a LinkForty URL via App Links
* or Universal Links before the server can process the redirect.
*
* Also records a click event and stores a device fingerprint for attribution,
* since the normal redirect flow was bypassed.
*
* Query params (optional fingerprint data for click attribution):
* - fp_tz: Device timezone
* - fp_lang: Device language
* - fp_sw: Screen width
* - fp_sh: Screen height
* - fp_platform: Platform (ios/android)
* - fp_pv: Platform version
*
* Response:
* - shortCode: The link's short code
* - linkId: UUID of the link
* - deepLinkPath: In-app destination path
* - appScheme: Custom URI scheme
* - iosUrl: iOS App Store URL
* - androidUrl: Android Play Store URL
* - webUrl: Web fallback URL
* - utmParameters: UTM tracking parameters
* - customParameters: Custom deep link parameters (key-value pairs)
* - clickedAt: Timestamp of this resolution
*/
async function handleResolve(request: any, reply: any, shortCode: string, templateSlug?: string) {
let linkData: string | null = null;
// Build cache key (same pattern as redirect.ts)
const cacheKey = templateSlug ? `link:${templateSlug}:${shortCode}` : `link:${shortCode}`;
// Try Redis cache first
if (fastify.redis) {
try {
linkData = await fastify.redis.get(cacheKey);
} catch (error) {
fastify.log.warn('Redis cache lookup failed, falling back to database');
}
}
if (!linkData) {
let query: string;
let params: any[];
if (templateSlug) {
query = `
SELECT l.* FROM links l
LEFT JOIN link_templates t ON l.template_id = t.id
WHERE l.short_code = $1 AND t.slug = $2
AND l.is_active = true
AND (l.expires_at IS NULL OR l.expires_at > NOW())
`;
params = [shortCode, templateSlug];
} else {
query = `
SELECT * FROM links
WHERE short_code = $1 AND is_active = true
AND (expires_at IS NULL OR expires_at > NOW())
`;
params = [shortCode];
}
const result = await db.query(query, params);
if (result.rows.length === 0) {
return reply.status(404).send({ error: 'Link not found' });
}
linkData = JSON.stringify(result.rows[0]);
// Cache for 5 minutes
if (fastify.redis) {
try {
await fastify.redis.setex(cacheKey, 300, linkData);
} catch (error) {
fastify.log.warn('Redis cache set failed');
}
}
}
const link = JSON.parse(linkData);
// Record click event + fingerprint asynchronously (mirrors redirect.ts pattern)
setImmediate(async () => {
try {
const userAgent = request.headers['user-agent'] || '';
const ip = getClientIp(request);
const referrer = request.headers.referer || null;
const acceptLanguage = request.headers['accept-language'] || '';
const deviceType = detectDevice(userAgent);
const { platform, platformVersion } = parseUserAgent(userAgent);
const { countryCode, countryName, region, city, latitude, longitude, timezone } = getLocationFromIP(ip);
// Classify bots at ingestion (SIT-298); persisted for consistent reads.
const { isBot, reason: botReason } = classifyBot(
userAgent,
request.method,
edgeBotSignal(request.headers['x-lf-bot'])
);
// Extract fingerprint data from query params (sent by SDK)
const query = request.query as Record<string, string | undefined>;
const fpTimezone = query?.fp_tz || timezone || undefined;
const fpLanguage = query?.fp_lang || acceptLanguage.split(',')[0]?.split(';')[0] || undefined;
const fpScreenWidth = query?.fp_sw ? parseInt(query.fp_sw, 10) : undefined;
const fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined;
// Insert click event
const clickResult = await db.query(
`INSERT INTO click_events (
link_id, ip_address, user_agent, device_type, platform,
country_code, country_name, region, city, latitude, longitude, timezone,
utm_source, utm_medium, utm_campaign, referrer, is_bot, bot_reason
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
RETURNING id`,
[
link.id,
ip,
userAgent,
deviceType,
platform,
countryCode,
countryName,
region,
city,
latitude,
longitude,
timezone,
query?.utm_source || null,
query?.utm_medium || null,
query?.utm_campaign || null,
referrer,
isBot,
botReason,
]
);
const clickId = clickResult.rows[0].id;
// Store device fingerprint for deferred deep linking
const fingerprintData: FingerprintData = {
ipAddress: ip,
userAgent,
timezone: fpTimezone,
language: fpLanguage,
screenWidth: fpScreenWidth,
screenHeight: fpScreenHeight,
platform: deviceType,
platformVersion,
};
await storeFingerprintForClick(clickId, fingerprintData);
// Emit click event for real-time streaming
emitClickEvent({
eventId: clickId,
timestamp: new Date().toISOString(),
linkId: link.id,
shortCode: link.short_code,
userId: link.user_id,
ipAddress: ip,
userAgent,
country: countryCode || undefined,
city: city || undefined,
deviceType,
platform: platform || undefined,
redirectUrl: '',
redirectReason: 'sdk_resolve',
targetingMatched: true,
utmParameters: link.utm_parameters || undefined,
referer: referrer || undefined,
language: fpLanguage,
});
// Trigger webhooks for click_event
try {
const webhooksResult = await db.query(
'SELECT * FROM webhooks WHERE user_id = $1 AND is_active = true',
[link.user_id]
);
if (webhooksResult.rows.length > 0) {
const clickEventData = {
id: clickId,
linkId: link.id,
clickedAt: new Date().toISOString(),
ipAddress: ip,
userAgent,
deviceType,
platform,
countryCode,
countryName,
region,
city,
latitude,
longitude,
timezone,
referrer,
};
await triggerWebhooks(
webhooksResult.rows,
'click_event',
clickId,
clickEventData
);
}
} catch (webhookError) {
fastify.log.error(`Error triggering click webhooks: ${webhookError}`);
}
} catch (error) {
fastify.log.error(`Error tracking click from resolve: ${error}`);
}
});
// Return JSON response with deep link data
return reply.status(200).send({
shortCode: link.short_code,
linkId: link.id,
deepLinkPath: link.deep_link_path || undefined,
appScheme: link.app_scheme || undefined,
iosUrl: link.ios_app_store_url || undefined,
androidUrl: link.android_app_store_url || undefined,
webUrl: link.web_fallback_url || undefined,
utmParameters: link.utm_parameters || undefined,
customParameters: link.deep_link_parameters || undefined,
clickedAt: new Date().toISOString(),
});
}
fastify.get('/api/sdk/v1/resolve/:shortCode', async (request, reply) => {
const { shortCode } = request.params as { shortCode: string };
return handleResolve(request, reply, shortCode);
});
fastify.get('/api/sdk/v1/resolve/:templateSlug/:shortCode', async (request, reply) => {
const { templateSlug, shortCode } = request.params as { templateSlug: string; shortCode: string };
return handleResolve(request, reply, shortCode, templateSlug);
});
/**
* GET /api/sdk/v1/health
* Health check endpoint for SDK connectivity testing
*/
fastify.get('/api/sdk/v1/health', async (request, reply) => {
return reply.status(200).send({
status: 'healthy',
version: 'v1',
timestamp: new Date().toISOString(),
});
});
}
+368
View File
@@ -0,0 +1,368 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { db } from '../lib/database.js';
import { customAlphabet } from 'nanoid';
const generateSlug = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 8);
const createTemplateSchema = z.object({
userId: z.string().uuid().optional(),
name: z.string().min(1, 'Template name is required').max(255, 'Name must be less than 255 characters'),
description: z.string().optional(),
settings: z.object({
defaultIosUrl: z.string().url('iOS URL must be a valid URL').optional(),
defaultAndroidUrl: z.string().url('Android URL must be a valid URL').optional(),
defaultWebFallbackUrl: z.string().url('Web Fallback URL must be a valid URL').optional(),
defaultAttributionWindowHours: z.number()
.min(1, 'Attribution window must be at least 1 hour')
.max(2160, 'Attribution window cannot exceed 2160 hours (90 days)')
.optional(),
utmParameters: z.object({
source: z.string().optional(),
medium: z.string().optional(),
campaign: z.string().optional(),
term: z.string().optional(),
content: z.string().optional(),
}).optional(),
targetingRules: z.object({
countries: z.array(z.string()).optional(),
devices: z.array(z.enum(['ios', 'android', 'web'])).optional(),
languages: z.array(z.string()).optional(),
}).optional(),
expiresAfterDays: z.number().optional(),
}).optional(),
isDefault: z.boolean().default(false),
});
const updateTemplateSchema = z.object({
name: z.string().min(1, 'Template name is required').max(255, 'Name must be less than 255 characters').optional(),
description: z.string().optional(),
settings: z.object({
defaultIosUrl: z.string().url('iOS URL must be a valid URL').optional(),
defaultAndroidUrl: z.string().url('Android URL must be a valid URL').optional(),
defaultWebFallbackUrl: z.string().url('Web Fallback URL must be a valid URL').optional(),
defaultAttributionWindowHours: z.number()
.min(1, 'Attribution window must be at least 1 hour')
.max(2160, 'Attribution window cannot exceed 2160 hours (90 days)')
.optional(),
utmParameters: z.object({
source: z.string().optional(),
medium: z.string().optional(),
campaign: z.string().optional(),
term: z.string().optional(),
content: z.string().optional(),
}).optional(),
targetingRules: z.object({
countries: z.array(z.string()).optional(),
devices: z.array(z.enum(['ios', 'android', 'web'])).optional(),
languages: z.array(z.string()).optional(),
}).optional(),
expiresAfterDays: z.number().optional(),
}).optional(),
isDefault: z.boolean().optional(),
});
export async function templateRoutes(fastify: FastifyInstance) {
// Get all templates (optionally filtered by userId)
fastify.get('/api/templates', async (request: FastifyRequest<{
Querystring: { userId?: string }
}>) => {
const { userId } = request.query;
let query: string;
let params: any[];
if (userId) {
query = `
SELECT id, user_id, name, slug, description, settings, is_default, created_at, updated_at
FROM link_templates
WHERE user_id = $1
ORDER BY is_default DESC, name ASC
`;
params = [userId];
} else {
query = `
SELECT id, user_id, name, slug, description, settings, is_default, created_at, updated_at
FROM link_templates
ORDER BY is_default DESC, name ASC
`;
params = [];
}
const result = await db.query(query, params);
return result.rows;
});
// Get single template
fastify.get('/api/templates/:id', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
let result;
if (userId) {
result = await db.query(
`SELECT id, user_id, name, slug, description, settings, is_default, created_at, updated_at
FROM link_templates
WHERE id = $1 AND user_id = $2`,
[id, userId]
);
} else {
result = await db.query(
`SELECT id, user_id, name, slug, description, settings, is_default, created_at, updated_at
FROM link_templates
WHERE id = $1`,
[id]
);
}
if (result.rows.length === 0) {
throw new Error('Template not found');
}
return result.rows[0];
});
// Create template
fastify.post('/api/templates', async (request) => {
const data = createTemplateSchema.parse(request.body);
// Generate unique slug
let slug = generateSlug();
let attempts = 0;
while (attempts < 10) {
const existing = await db.query(
'SELECT id FROM link_templates WHERE slug = $1',
[slug]
);
if (existing.rows.length === 0) {
break;
}
slug = generateSlug();
attempts++;
}
if (attempts >= 10) {
throw new Error('Unable to generate unique template slug. Please try again.');
}
// If setting as default, unset other defaults
if (data.isDefault) {
if (data.userId) {
await db.query(
'UPDATE link_templates SET is_default = false WHERE user_id = $1',
[data.userId]
);
} else {
await db.query(
'UPDATE link_templates SET is_default = false WHERE user_id IS NULL'
);
}
}
const result = await db.query(
`INSERT INTO link_templates (
user_id, name, slug, description, settings, is_default
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, name, slug, description, settings, is_default, created_at, updated_at`,
[
data.userId || null,
data.name,
slug,
data.description || null,
JSON.stringify(data.settings || {}),
data.isDefault,
]
);
return result.rows[0];
});
// Update template
fastify.put('/api/templates/:id', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
const data = updateTemplateSchema.parse(request.body);
// Verify template exists
let existingResult;
if (userId) {
existingResult = await db.query(
'SELECT id FROM link_templates WHERE id = $1 AND user_id = $2',
[id, userId]
);
} else {
existingResult = await db.query(
'SELECT id FROM link_templates WHERE id = $1',
[id]
);
}
if (existingResult.rows.length === 0) {
throw new Error('Template not found');
}
// If setting as default, unset other defaults
if (data.isDefault) {
if (userId) {
await db.query(
'UPDATE link_templates SET is_default = false WHERE user_id = $1 AND id != $2',
[userId, id]
);
} else {
await db.query(
'UPDATE link_templates SET is_default = false WHERE id != $1',
[id]
);
}
}
// Build update query dynamically
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (data.name !== undefined) {
updates.push(`name = $${paramIndex}`);
values.push(data.name);
paramIndex++;
}
if (data.description !== undefined) {
updates.push(`description = $${paramIndex}`);
values.push(data.description);
paramIndex++;
}
if (data.settings !== undefined) {
updates.push(`settings = $${paramIndex}`);
values.push(JSON.stringify(data.settings));
paramIndex++;
}
if (data.isDefault !== undefined) {
updates.push(`is_default = $${paramIndex}`);
values.push(data.isDefault);
paramIndex++;
}
if (updates.length === 0) {
throw new Error('No updates provided');
}
updates.push('updated_at = NOW()');
values.push(id);
let whereClause = `WHERE id = $${paramIndex}`;
if (userId) {
values.push(userId);
whereClause += ` AND user_id = $${paramIndex + 1}`;
}
const result = await db.query(
`UPDATE link_templates
SET ${updates.join(', ')}
${whereClause}
RETURNING id, user_id, name, slug, description, settings, is_default, created_at, updated_at`,
values
);
return result.rows[0];
});
// Delete template
fastify.delete('/api/templates/:id', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
// Check if template has any links
const linksCount = await db.query(
'SELECT COUNT(*) as count FROM links WHERE template_id = $1',
[id]
);
if (parseInt(linksCount.rows[0].count) > 0) {
throw new Error('Cannot delete template that has links assigned to it. Please reassign or delete the links first.');
}
let result;
if (userId) {
result = await db.query(
'DELETE FROM link_templates WHERE id = $1 AND user_id = $2 RETURNING id',
[id, userId]
);
} else {
result = await db.query(
'DELETE FROM link_templates WHERE id = $1 RETURNING id',
[id]
);
}
if (result.rows.length === 0) {
throw new Error('Template not found');
}
return { success: true };
});
// Set template as default
fastify.put('/api/templates/:id/set-default', async (request: FastifyRequest<{
Params: { id: string };
Querystring: { userId?: string };
}>) => {
const { id } = request.params;
const { userId } = request.query;
// Verify template exists
let existingResult;
if (userId) {
existingResult = await db.query(
'SELECT id FROM link_templates WHERE id = $1 AND user_id = $2',
[id, userId]
);
} else {
existingResult = await db.query(
'SELECT id FROM link_templates WHERE id = $1',
[id]
);
}
if (existingResult.rows.length === 0) {
throw new Error('Template not found');
}
// Unset all defaults (scoped by userId if provided)
if (userId) {
await db.query(
'UPDATE link_templates SET is_default = false WHERE user_id = $1',
[userId]
);
} else {
await db.query(
'UPDATE link_templates SET is_default = false'
);
}
// Set new default
const result = await db.query(
`UPDATE link_templates
SET is_default = true, updated_at = NOW()
WHERE id = $1
RETURNING id, user_id, name, slug, description, settings, is_default, created_at, updated_at`,
[id]
);
return result.rows[0];
});
}
+266
View File
@@ -0,0 +1,266 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { db } from '../lib/database.js';
import { generateWebhookSecret } from '../lib/webhook.js';
import type { Webhook, WebhookEvent } from '../types/index.js';
const webhookEventSchema = z.enum(['click_event', 'install_event', 'conversion_event']);
const createWebhookSchema = z.object({
userId: z.string().uuid().optional(),
name: z.string().min(1).max(255),
url: z.string().url(),
events: z.array(webhookEventSchema).min(1),
headers: z.record(z.string()).optional(),
retryCount: z.number().int().min(1).max(10).optional(),
timeoutMs: z.number().int().min(1000).max(60000).optional(),
});
const updateWebhookSchema = z.object({
name: z.string().min(1).max(255).optional(),
url: z.string().url().optional(),
events: z.array(webhookEventSchema).min(1).optional(),
isActive: z.boolean().optional(),
headers: z.record(z.string()).optional(),
retryCount: z.number().int().min(1).max(10).optional(),
timeoutMs: z.number().int().min(1000).max(60000).optional(),
});
export async function webhookRoutes(fastify: FastifyInstance) {
// Get all webhooks (optionally filtered by userId)
fastify.get('/api/webhooks', async (request: FastifyRequest<{
Querystring: { userId?: string }
}>) => {
const { userId } = request.query;
let result;
if (userId) {
result = await db.query(
`SELECT id, user_id, name, url, events, is_active, retry_count, timeout_ms, headers, created_at, updated_at
FROM webhooks
WHERE user_id = $1
ORDER BY created_at DESC`,
[userId]
);
} else {
result = await db.query(
`SELECT id, user_id, name, url, events, is_active, retry_count, timeout_ms, headers, created_at, updated_at
FROM webhooks
ORDER BY created_at DESC`
);
}
// Don't expose secrets in list view
return result.rows;
});
// Get a single webhook with secret
fastify.get('/api/webhooks/:id', async (request: FastifyRequest<{
Params: { id: string }
Querystring: { userId?: string }
}>) => {
const { id } = request.params;
const { userId } = request.query;
let result;
if (userId) {
result = await db.query(
'SELECT * FROM webhooks WHERE id = $1 AND user_id = $2',
[id, userId]
);
} else {
result = await db.query(
'SELECT * FROM webhooks WHERE id = $1',
[id]
);
}
if (result.rows.length === 0) {
throw new Error('Webhook not found');
}
return result.rows[0];
});
// Create a new webhook
fastify.post('/api/webhooks', async (request: FastifyRequest) => {
const data = createWebhookSchema.parse(request.body);
// Generate secure random secret
const secret = generateWebhookSecret();
const result = await db.query(
`INSERT INTO webhooks (user_id, name, url, secret, events, retry_count, timeout_ms, headers)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
data.userId || null,
data.name,
data.url,
secret,
data.events,
data.retryCount || 3,
data.timeoutMs || 10000,
JSON.stringify(data.headers || {}),
]
);
return result.rows[0];
});
// Update a webhook
fastify.put('/api/webhooks/:id', async (request: FastifyRequest<{
Params: { id: string }
Querystring: { userId?: string }
}>) => {
const { id } = request.params;
const { userId } = request.query;
const data = updateWebhookSchema.parse(request.body);
// Build dynamic update query
const updates: string[] = [];
const values: any[] = [];
let paramCount = 1;
if (data.name !== undefined) {
updates.push(`name = $${paramCount++}`);
values.push(data.name);
}
if (data.url !== undefined) {
updates.push(`url = $${paramCount++}`);
values.push(data.url);
}
if (data.events !== undefined) {
updates.push(`events = $${paramCount++}`);
values.push(data.events);
}
if (data.isActive !== undefined) {
updates.push(`is_active = $${paramCount++}`);
values.push(data.isActive);
}
if (data.headers !== undefined) {
updates.push(`headers = $${paramCount++}`);
values.push(JSON.stringify(data.headers));
}
if (data.retryCount !== undefined) {
updates.push(`retry_count = $${paramCount++}`);
values.push(data.retryCount);
}
if (data.timeoutMs !== undefined) {
updates.push(`timeout_ms = $${paramCount++}`);
values.push(data.timeoutMs);
}
if (updates.length === 0) {
throw new Error('No fields to update');
}
updates.push(`updated_at = NOW()`);
values.push(id);
let whereClause = `WHERE id = $${paramCount++}`;
if (userId) {
values.push(userId);
whereClause += ` AND user_id = $${paramCount}`;
}
const result = await db.query(
`UPDATE webhooks
SET ${updates.join(', ')}
${whereClause}
RETURNING *`,
values
);
if (result.rows.length === 0) {
throw new Error('Webhook not found');
}
return result.rows[0];
});
// Delete a webhook
fastify.delete('/api/webhooks/:id', async (request: FastifyRequest<{
Params: { id: string }
Querystring: { userId?: string }
}>) => {
const { id } = request.params;
const { userId } = request.query;
let result;
if (userId) {
result = await db.query(
'DELETE FROM webhooks WHERE id = $1 AND user_id = $2 RETURNING id',
[id, userId]
);
} else {
result = await db.query(
'DELETE FROM webhooks WHERE id = $1 RETURNING id',
[id]
);
}
if (result.rows.length === 0) {
throw new Error('Webhook not found');
}
return { success: true };
});
// Test a webhook
fastify.post('/api/webhooks/:id/test', async (request: FastifyRequest<{
Params: { id: string }
Querystring: { userId?: string }
}>) => {
const { id } = request.params;
const { userId } = request.query;
// Get webhook
let webhookResult;
if (userId) {
webhookResult = await db.query(
'SELECT * FROM webhooks WHERE id = $1 AND user_id = $2',
[id, userId]
);
} else {
webhookResult = await db.query(
'SELECT * FROM webhooks WHERE id = $1',
[id]
);
}
if (webhookResult.rows.length === 0) {
throw new Error('Webhook not found');
}
const webhook: Webhook = webhookResult.rows[0];
// Create test payload
const testPayload = {
event: 'click_event' as WebhookEvent,
event_id: '00000000-0000-0000-0000-000000000000',
timestamp: new Date().toISOString(),
data: {
id: '00000000-0000-0000-0000-000000000000',
linkId: '00000000-0000-0000-0000-000000000000',
clickedAt: new Date().toISOString(),
ipAddress: '127.0.0.1',
userAgent: 'LinkForty-Test/1.0',
deviceType: 'web',
platform: 'test',
countryCode: 'US',
},
};
// Deliver webhook synchronously for testing
const { deliverWebhook } = await import('../lib/webhook.js');
const result = await deliverWebhook(webhook, testPayload);
return {
success: result.success,
statusCode: result.responseStatus,
responseBody: result.responseBody,
error: result.errorMessage,
};
});
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Well-Known Routes for @linkforty/core
*
* Purpose: Serve domain verification files for iOS Universal Links and Android App Links
*
* Configuration via Environment Variables:
* - IOS_TEAM_ID: Your Apple Developer Team ID (e.g., "ABC123XYZ")
* - IOS_BUNDLE_ID: Your iOS app bundle identifier (e.g., "com.example.app")
* - ANDROID_PACKAGE_NAME: Your Android package name (e.g., "com.example.app")
* - ANDROID_SHA256_FINGERPRINTS: Comma-separated SHA-256 fingerprints (e.g., "AA:BB:CC:...,DD:EE:FF:...")
*/
import { FastifyInstance } from 'fastify';
export async function wellKnownRoutes(fastify: FastifyInstance) {
/**
* Apple App Site Association (AASA)
* Used by iOS to verify Universal Links
* https://developer.apple.com/documentation/xcode/supporting-associated-domains
*
* Endpoint: GET /.well-known/apple-app-site-association
*/
fastify.get('/.well-known/apple-app-site-association', async (request, reply) => {
const teamId = process.env.IOS_TEAM_ID;
const bundleId = process.env.IOS_BUNDLE_ID;
if (!teamId || !bundleId) {
return reply.status(404).send({
error: 'Configuration missing',
message: 'iOS app configuration not found. Please set IOS_TEAM_ID and IOS_BUNDLE_ID environment variables.',
docs: 'https://docs.linkforty.com/guides/sdk-integration#ios-universal-links'
});
}
const aasa = {
applinks: {
apps: [],
details: [
{
appID: `${teamId}.${bundleId}`,
paths: ['*'] // Match all paths
}
]
}
};
// AASA file must be served:
// 1. Without .json extension
// 2. With application/json content-type
// 3. Over HTTPS (in production)
return reply
.header('Content-Type', 'application/json')
.send(aasa);
});
/**
* Digital Asset Links (assetlinks.json)
* Used by Android to verify App Links
* https://developer.android.com/training/app-links/verify-android-applinks
*
* Endpoint: GET /.well-known/assetlinks.json
*/
fastify.get('/.well-known/assetlinks.json', async (request, reply) => {
const packageName = process.env.ANDROID_PACKAGE_NAME;
const fingerprintsEnv = process.env.ANDROID_SHA256_FINGERPRINTS;
if (!packageName || !fingerprintsEnv) {
return reply.status(404).send({
error: 'Configuration missing',
message: 'Android app configuration not found. Please set ANDROID_PACKAGE_NAME and ANDROID_SHA256_FINGERPRINTS environment variables.',
docs: 'https://docs.linkforty.com/guides/sdk-integration#android-app-links'
});
}
// Parse comma-separated fingerprints
const fingerprints = fingerprintsEnv
.split(',')
.map(fp => fp.trim())
.filter(Boolean);
if (fingerprints.length === 0) {
return reply.status(500).send({
error: 'Invalid configuration',
message: 'ANDROID_SHA256_FINGERPRINTS is empty or invalid. Must be comma-separated list of SHA-256 fingerprints.',
example: 'ANDROID_SHA256_FINGERPRINTS=AA:BB:CC:DD:EE:FF:...,11:22:33:44:55:66:...'
});
}
const assetlinks = [
{
relation: ['delegate_permission/common.handle_all_urls'],
target: {
namespace: 'android_app',
package_name: packageName,
sha256_cert_fingerprints: fingerprints
}
}
];
return reply
.header('Content-Type', 'application/json')
.send(assetlinks);
});
}
+15
View File
@@ -0,0 +1,15 @@
import { initializeDatabase } from '../lib/database.js';
async function runMigrations() {
try {
console.log('Starting database migration...');
await initializeDatabase();
console.log('Database migration completed successfully!');
process.exit(0);
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
}
runMigrations();
+280
View File
@@ -0,0 +1,280 @@
// Template types
/**
* Default settings applied to links created from a template.
*/
export interface LinkTemplateSettings {
defaultIosUrl?: string;
defaultAndroidUrl?: string;
defaultWebFallbackUrl?: string;
defaultAttributionWindowHours?: number;
utmParameters?: UTMParameters;
targetingRules?: TargetingRules;
expiresAfterDays?: number;
}
/**
* A reusable link template that pre-populates settings when creating new links.
*/
export interface LinkTemplate {
id: string;
userId?: string;
name: string;
slug: string;
description?: string;
settings: LinkTemplateSettings;
is_default: boolean;
created_at: string;
updated_at: string;
}
export interface CreateTemplateRequest {
name: string;
description?: string;
settings?: LinkTemplateSettings;
isDefault?: boolean;
}
export interface UpdateTemplateRequest extends Partial<CreateTemplateRequest> {}
/**
* A short link with routing, deep-linking, UTM, targeting, and Open Graph metadata.
*/
export interface Link {
id: string;
userId?: string;
template_id?: string;
template_slug?: string;
short_code: string;
original_url: string;
title?: string;
description?: string;
// App store URLs (renamed from ios_url/android_url for clarity)
ios_app_store_url?: string;
android_app_store_url?: string;
web_fallback_url?: string;
// App deep linking configuration
app_scheme?: string; // URI scheme (e.g., "myapp" or "com.company.app")
ios_universal_link?: string; // iOS Universal Link URL (HTTPS)
android_app_link?: string; // Android App Link URL (HTTPS)
deep_link_path?: string; // In-app destination path (e.g., "/product/123")
deep_link_parameters?: Record<string, any>; // Custom app parameters
// Existing fields
utmParameters?: UTMParameters;
targeting_rules?: TargetingRules;
og_title?: string;
og_description?: string;
og_image_url?: string;
og_type?: string;
attribution_window_hours?: number;
is_active: boolean;
expires_at?: string;
created_at: string;
updated_at: string;
click_count?: number;
}
/**
* Standard UTM tracking parameters appended to redirect URLs for campaign attribution.
*/
export interface UTMParameters {
source?: string;
medium?: string;
campaign?: string;
term?: string;
content?: string;
}
/**
* Rules that control which redirect URL a visitor receives based on their
* country, device type, or browser language.
*/
export interface TargetingRules {
countries?: string[];
devices?: ('ios' | 'android' | 'web')[];
languages?: string[];
}
/**
* A recorded click event on a short link, including device, location, and UTM data.
*/
export interface ClickEvent {
id: string;
linkId: string;
clickedAt: string;
ipAddress?: string;
userAgent?: string;
deviceType?: string;
platform?: string;
countryCode?: string;
countryName?: string;
region?: string;
city?: string;
latitude?: number;
longitude?: number;
timezone?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
referrer?: string;
}
export interface CreateLinkRequest {
templateId?: string;
originalUrl: string;
title?: string;
description?: string;
// App store URLs (renamed from iosUrl/androidUrl for clarity)
iosAppStoreUrl?: string;
androidAppStoreUrl?: string;
webFallbackUrl?: string;
// App deep linking configuration
appScheme?: string; // URI scheme (e.g., "myapp" or "com.company.app")
iosUniversalLink?: string; // iOS Universal Link URL (HTTPS)
androidAppLink?: string; // Android App Link URL (HTTPS)
deepLinkPath?: string; // In-app destination path (e.g., "/product/123")
deepLinkParameters?: Record<string, any>; // Custom app parameters
// Existing fields
utmParameters?: UTMParameters;
targetingRules?: TargetingRules;
ogTitle?: string;
ogDescription?: string;
ogImageUrl?: string;
ogType?: string;
attributionWindowHours?: number;
customCode?: string;
expiresAt?: string;
}
export interface UpdateLinkRequest extends Partial<CreateLinkRequest> {
isActive?: boolean;
}
/**
* Aggregated analytics for one or more links over a time period, broken down
* by date, geography, device, browser, UTM parameters, and referrer.
*/
export interface AnalyticsData {
totalClicks: number;
uniqueClicks: number;
clicksByDate: Array<{ date: string; clicks: number }>;
clicksByCountry: Array<{ country: string; countryCode: string; clicks: number }>;
clicksByCity: Array<{ city: string; countryCode: string; region: string; clicks: number }>;
clicksByRegion: Array<{ region: string; countryCode: string; clicks: number }>;
clicksByTimezone: Array<{ timezone: string; clicks: number }>;
clicksByDevice: Array<{ device: string; clicks: number }>;
clicksByPlatform: Array<{ platform: string; clicks: number }>;
clicksByBrowser: Array<{ browser: string; clicks: number }>;
clicksByHour: Array<{ hour: number; clicks: number }>;
clicksByUtmSource: Array<{ source: string; clicks: number }>;
clicksByUtmMedium: Array<{ medium: string; clicks: number }>;
clicksByUtmCampaign: Array<{ campaign: string; clicks: number }>;
clicksByReferrer: Array<{ source: string; clicks: number }>;
topLinks: Array<{
id: string;
shortCode: string;
title: string | null;
originalUrl: string;
totalClicks: number;
uniqueClicks: number;
}>;
}
// Webhook types
/**
* Discriminated event type sent in webhook payloads.
* Consumers should filter webhooks by subscribing to specific event types.
*/
export type WebhookEvent = 'click_event' | 'install_event' | 'conversion_event' | 'sdk_event';
/**
* A registered webhook endpoint that receives event notifications from LinkForty.
*/
export interface Webhook {
id: string;
user_id: string;
name: string;
url: string;
secret: string;
events: WebhookEvent[];
is_active: boolean;
retry_count: number;
timeout_ms: number;
headers: Record<string, string>;
created_at: string;
updated_at: string;
}
export interface CreateWebhookRequest {
name: string;
url: string;
events: WebhookEvent[];
headers?: Record<string, string>;
retryCount?: number;
timeoutMs?: number;
}
export interface UpdateWebhookRequest {
name?: string;
url?: string;
events?: WebhookEvent[];
isActive?: boolean;
headers?: Record<string, string>;
retryCount?: number;
timeoutMs?: number;
}
/**
* The JSON body delivered to a webhook endpoint for every event.
*/
export interface WebhookPayload {
event: WebhookEvent;
event_id: string;
timestamp: string;
data: ClickEvent | InstallEvent | ConversionEvent;
}
/**
* Outcome of a single webhook delivery attempt, including HTTP status and retry info.
*/
export interface WebhookDeliveryResult {
success: boolean;
webhookId: string;
eventType: WebhookEvent;
eventId: string;
responseStatus?: number;
responseBody?: string;
attemptNumber: number;
deliveredAt?: string;
errorMessage?: string;
}
/**
* An app install event, optionally attributed to a prior click via device fingerprinting.
*/
export interface InstallEvent {
id: string;
linkId?: string;
fingerprintHash: string;
confidenceScore?: number;
installedAt: string;
deepLinkData?: any;
ipAddress?: string;
userAgent?: string;
platform?: string;
}
/**
* A post-install in-app conversion event (e.g., purchase, sign-up) tied to an install.
*/
export interface ConversionEvent {
id: string;
installId: string;
eventName: string;
eventProperties: Record<string, any>;
revenue?: number;
currency?: string;
timestamp: string;
}