From d338b91e42e1d372517f682b72d31bfd9b58f5a4 Mon Sep 17 00:00:00 2001 From: "beomhee.lee" Date: Fri, 31 Jul 2026 11:44:56 +0900 Subject: [PATCH] code --- src/index.ts | 81 +++ src/lib/bot-detection.test.ts | 53 ++ src/lib/bot-detection.ts | 51 ++ src/lib/client-ip.test.ts | 129 +++++ src/lib/client-ip.ts | 40 ++ src/lib/database.ts | 534 ++++++++++++++++++ src/lib/event-emitter.test.ts | 211 +++++++ src/lib/event-emitter.ts | 70 +++ src/lib/fingerprint.test.ts | 364 +++++++++++++ src/lib/fingerprint.ts | 555 +++++++++++++++++++ src/lib/link-resolution-cache.test.ts | 46 ++ src/lib/link-resolution-cache.ts | 22 + src/lib/utils.test.ts | 187 +++++++ src/lib/utils.ts | 173 ++++++ src/lib/webhook.test.ts | 380 +++++++++++++ src/lib/webhook.ts | 184 +++++++ src/routes/analytics.ts | 233 ++++++++ src/routes/debug.ts | 363 +++++++++++++ src/routes/index.ts | 10 + src/routes/links.test.ts | 31 ++ src/routes/links.ts | 439 +++++++++++++++ src/routes/preview.ts | 252 +++++++++ src/routes/qr.ts | 144 +++++ src/routes/redirect.test.ts | 186 +++++++ src/routes/redirect.ts | 606 +++++++++++++++++++++ src/routes/sdk.event.test.ts | 192 +++++++ src/routes/sdk.ts | 754 ++++++++++++++++++++++++++ src/routes/templates.ts | 368 +++++++++++++ src/routes/webhooks.ts | 266 +++++++++ src/routes/well-known.ts | 104 ++++ src/scripts/migrate.ts | 15 + src/types/index.ts | 280 ++++++++++ 32 files changed, 7323 insertions(+) create mode 100644 src/index.ts create mode 100644 src/lib/bot-detection.test.ts create mode 100644 src/lib/bot-detection.ts create mode 100644 src/lib/client-ip.test.ts create mode 100644 src/lib/client-ip.ts create mode 100644 src/lib/database.ts create mode 100644 src/lib/event-emitter.test.ts create mode 100644 src/lib/event-emitter.ts create mode 100644 src/lib/fingerprint.test.ts create mode 100644 src/lib/fingerprint.ts create mode 100644 src/lib/link-resolution-cache.test.ts create mode 100644 src/lib/link-resolution-cache.ts create mode 100644 src/lib/utils.test.ts create mode 100644 src/lib/utils.ts create mode 100644 src/lib/webhook.test.ts create mode 100644 src/lib/webhook.ts create mode 100644 src/routes/analytics.ts create mode 100644 src/routes/debug.ts create mode 100644 src/routes/index.ts create mode 100644 src/routes/links.test.ts create mode 100644 src/routes/links.ts create mode 100644 src/routes/preview.ts create mode 100644 src/routes/qr.ts create mode 100644 src/routes/redirect.test.ts create mode 100644 src/routes/redirect.ts create mode 100644 src/routes/sdk.event.test.ts create mode 100644 src/routes/sdk.ts create mode 100644 src/routes/templates.ts create mode 100644 src/routes/webhooks.ts create mode 100644 src/routes/well-known.ts create mode 100644 src/scripts/migrate.ts create mode 100644 src/types/index.ts diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f656902 --- /dev/null +++ b/src/index.ts @@ -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'; diff --git a/src/lib/bot-detection.test.ts b/src/lib/bot-detection.test.ts new file mode 100644 index 0000000..1db283f --- /dev/null +++ b/src/lib/bot-detection.test.ts @@ -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(); + }); +}); diff --git a/src/lib/bot-detection.ts b/src/lib/bot-detection.ts new file mode 100644 index 0000000..21371dc --- /dev/null +++ b/src/lib/bot-detection.ts @@ -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'; +} diff --git a/src/lib/client-ip.test.ts b/src/lib/client-ip.test.ts new file mode 100644 index 0000000..f2bb9d8 --- /dev/null +++ b/src/lib/client-ip.test.ts @@ -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; + 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'); + }); +}); diff --git a/src/lib/client-ip.ts b/src/lib/client-ip.ts new file mode 100644 index 0000000..df59a2f --- /dev/null +++ b/src/lib/client-ip.ts @@ -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 ''; +} diff --git a/src/lib/database.ts b/src/lib/database.ts new file mode 100644 index 0000000..d4c0848 --- /dev/null +++ b/src/lib/database.ts @@ -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 { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Retry database connection with exponential backoff +async function connectWithRetry(maxRetries: number = 10, baseDelay: number = 1000): Promise { + 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= 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(); + } +} diff --git a/src/lib/event-emitter.test.ts b/src/lib/event-emitter.test.ts new file mode 100644 index 0000000..7165a34 --- /dev/null +++ b/src/lib/event-emitter.test.ts @@ -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); + }); +}); diff --git a/src/lib/event-emitter.ts b/src/lib/event-emitter.ts new file mode 100644 index 0000000..39671a7 --- /dev/null +++ b/src/lib/event-emitter.ts @@ -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); + }; +} diff --git a/src/lib/fingerprint.test.ts b/src/lib/fingerprint.test.ts new file mode 100644 index 0000000..89c8133 --- /dev/null +++ b/src/lib/fingerprint.test.ts @@ -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 + }); +}); diff --git a/src/lib/fingerprint.ts b/src/lib/fingerprint.ts new file mode 100644 index 0000000..4704513 --- /dev/null +++ b/src/lib/fingerprint.ts @@ -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 = [ + ['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 { + // 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 { + 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, + }; +} diff --git a/src/lib/link-resolution-cache.test.ts b/src/lib/link-resolution-cache.test.ts new file mode 100644 index 0000000..eb9aa93 --- /dev/null +++ b/src/lib/link-resolution-cache.test.ts @@ -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); + }); +}); diff --git a/src/lib/link-resolution-cache.ts b/src/lib/link-resolution-cache.ts new file mode 100644 index 0000000..0a83314 --- /dev/null +++ b/src/lib/link-resolution-cache.ts @@ -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 } | null | undefined, + shortCode: string, + templateSlug?: string | null, +): Promise { + 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. + } +} diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 0000000..15b8a6d --- /dev/null +++ b/src/lib/utils.test.ts @@ -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'); + } + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..dc2dc53 --- /dev/null +++ b/src/lib/utils.ts @@ -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 = { + 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 | 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'; +} diff --git a/src/lib/webhook.test.ts b/src/lib/webhook.test.ts new file mode 100644 index 0000000..98afde0 --- /dev/null +++ b/src/lib/webhook.test.ts @@ -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 { + 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 { + 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; + 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); + }); +}); diff --git a/src/lib/webhook.ts b/src/lib/webhook.ts new file mode 100644 index 0000000..23b081a --- /dev/null +++ b/src/lib/webhook.ts @@ -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 { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Attempt a single webhook delivery + */ +async function attemptWebhookDelivery( + webhook: Webhook, + payload: WebhookPayload, + attemptNumber: number +): Promise { + const payloadString = JSON.stringify(payload); + const signature = generateWebhookSignature(payloadString, webhook.secret); + + const headers: Record = { + '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 +): Promise { + 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 +): Promise { + // 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); + }); +} diff --git a/src/routes/analytics.ts b/src/routes/analytics.ts new file mode 100644 index 0000000..49ac486 --- /dev/null +++ b/src/routes/analytics.ts @@ -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), + })), + }; + }); +} diff --git a/src/routes/debug.ts b/src/routes/debug.ts new file mode 100644 index 0000000..7519a18 --- /dev/null +++ b/src/routes/debug.ts @@ -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; + +/** + * 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(); + }); + } + ); +} diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..d48bfcd --- /dev/null +++ b/src/routes/index.ts @@ -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'; diff --git a/src/routes/links.test.ts b/src/routes/links.test.ts new file mode 100644 index 0000000..4b579b0 --- /dev/null +++ b/src/routes/links.test.ts @@ -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']); + }); +}); diff --git a/src/routes/links.ts b/src/routes/links.ts new file mode 100644 index 0000000..58fa46d --- /dev/null +++ b/src/routes/links.ts @@ -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 { + 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 }; + }); +} diff --git a/src/routes/preview.ts b/src/routes/preview.ts new file mode 100644 index 0000000..61009f3 --- /dev/null +++ b/src/routes/preview.ts @@ -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 + ? `` + : ''; + + return ` + + + + + ${escapeHtml(ogTitle)} + + + + + + + ${ogImage ? `` : ''} + ${ogImage ? `` : ''} + + + + + + + ${ogImage ? `` : ''} + + + + + ${metaRefresh} + + + + +
+ ${ogImage ? `${escapeHtml(ogTitle)}` : ''} +

${escapeHtml(ogTitle)}

+ ${ogDescription ? `

${escapeHtml(ogDescription)}

` : ''} + ${autoRedirect ? '

Redirecting you...

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

Opening ${safeTitle}...

+

If the app doesn't open automatically:

+ Open App + Download App +
+ +`; +} + +export async function redirectRoutes(fastify: FastifyInstance) { + // Helper function to handle the actual redirect logic + async function handleRedirect(request: any, reply: any, shortCode: string, templateSlug?: string) { + let linkData: string | null = null; + + // Build cache key (include template if present) + const cacheKey = templateSlug ? `link:${templateSlug}:${shortCode}` : `link:${shortCode}`; + + // Try to get link from cache if Redis is available + if (fastify.redis) { + try { + linkData = await fastify.redis.get(cacheKey); + } catch (error) { + fastify.log.warn('Redis cache lookup failed, falling back to database'); + } + } + + if (!linkData) { + // Build query based on whether template slug is provided + let query: string; + let params: any[]; + + if (templateSlug) { + // Template-based URL: verify both template and link match + // Also fetch template settings and org settings for URL fallback chain + query = ` + SELECT l.*, t.settings AS template_settings, o.settings AS org_settings + FROM links l + LEFT JOIN link_templates t ON l.template_id = t.id + LEFT JOIN organizations o ON l.organization_id = o.id + WHERE l.short_code = $1 AND t.slug = $2 + AND l.is_active = true + AND (l.expires_at IS NULL OR l.expires_at > NOW()) + `; + params = [shortCode, templateSlug]; + } else { + // Legacy URL: just lookup by short code + // Also fetch template settings and org settings for URL fallback chain + query = ` + SELECT l.*, t.settings AS template_settings, o.settings AS org_settings + FROM links l + LEFT JOIN link_templates t ON l.template_id = t.id + LEFT JOIN organizations o ON l.organization_id = o.id + WHERE l.short_code = $1 AND l.is_active = true + AND (l.expires_at IS NULL OR l.expires_at > NOW()) + `; + params = [shortCode]; + } + + const result = await db.query(query, params); + + if (result.rows.length === 0) { + return reply.status(404).send({ error: 'Link not found' }); + } + + linkData = JSON.stringify(result.rows[0]); + + // Cache for 5 minutes if Redis is available + if (fastify.redis) { + try { + await fastify.redis.setex(cacheKey, 300, linkData); + } catch (error) { + fastify.log.warn('Redis cache set failed'); + } + } + } + + const link = JSON.parse(linkData); + + // Check targeting rules BEFORE redirecting + if (link.targeting_rules) { + const userAgent = request.headers['user-agent'] || ''; + const ip = getClientIp(request); + const acceptLanguage = request.headers['accept-language'] || ''; + + // Get user's actual data for targeting checks + const device = detectDevice(userAgent); + const { countryCode } = getLocationFromIP(ip); + + // Extract primary language from accept-language header (e.g., "en-US,en;q=0.9" -> "en") + const primaryLanguage = acceptLanguage.split(',')[0]?.split('-')[0]?.toLowerCase(); + + const rules = link.targeting_rules; + let isTargeted = true; + + // Check country targeting + if (rules.countries && rules.countries.length > 0) { + const targetCountries = rules.countries.map((c: string) => c.toUpperCase()); + if (!countryCode || !targetCountries.includes(countryCode.toUpperCase())) { + isTargeted = false; + } + } + + // Check device targeting + if (rules.devices && rules.devices.length > 0) { + if (!rules.devices.includes(device)) { + isTargeted = false; + } + } + + // Check language targeting + if (rules.languages && rules.languages.length > 0) { + const targetLanguages = rules.languages.map((l: string) => l.toLowerCase()); + if (!primaryLanguage || !targetLanguages.includes(primaryLanguage)) { + isTargeted = false; + } + } + + // If targeting rules exist but user doesn't match, return 404 + if (!isTargeted) { + return reply.status(404).send({ error: 'Link not found' }); + } + } + + // Generate the click id up front (rather than letting the DB default it on + // insert) so the synchronous redirect below can carry it on the destination + // URL while the click row is still written asynchronously with the same id. + const clickId = randomUUID(); + + // Track click asynchronously + setImmediate(async () => { + try { + const userAgent = request.headers['user-agent'] || ''; + const ip = getClientIp(request); + const referrer = request.headers.referer || null; + const acceptLanguage = request.headers['accept-language'] || ''; + + const deviceType = detectDevice(userAgent); + const { platform, platformVersion } = parseUserAgent(userAgent); + const { countryCode, countryName, region, city, latitude, longitude, timezone } = getLocationFromIP(ip); + + // Classify bots at ingestion (SIT-298) — persisted on the row so + // analytics reads a consistent flag instead of re-detecting from the + // stored user-agent. + const { isBot, reason: botReason } = classifyBot( + userAgent, + request.method, + edgeBotSignal(request.headers['x-lf-bot']) + ); + + // Extract UTM parameters from query string + const query = request.query as Record; + const utmSource = query?.utm_source; + const utmMedium = query?.utm_medium; + const utmCampaign = query?.utm_campaign; + + // Extract fingerprint data from query params (sent by SDK/client) + const fpTimezone = query?.fp_tz || timezone || undefined; + const fpLanguage = query?.fp_lang || acceptLanguage.split(',')[0]?.split(';')[0] || undefined; + const fpScreenWidth = query?.fp_sw ? parseInt(query.fp_sw, 10) : undefined; + const fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined; + + // 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); + }); +} diff --git a/src/routes/sdk.event.test.ts b/src/routes/sdk.event.test.ts new file mode 100644 index 0000000..51bffe5 --- /dev/null +++ b/src/routes/sdk.event.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/routes/sdk.ts b/src/routes/sdk.ts new file mode 100644 index 0000000..4146d04 --- /dev/null +++ b/src/routes/sdk.ts @@ -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; + 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(), + }); + }); +} diff --git a/src/routes/templates.ts b/src/routes/templates.ts new file mode 100644 index 0000000..806c284 --- /dev/null +++ b/src/routes/templates.ts @@ -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]; + }); +} diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts new file mode 100644 index 0000000..a27d574 --- /dev/null +++ b/src/routes/webhooks.ts @@ -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, + }; + }); +} diff --git a/src/routes/well-known.ts b/src/routes/well-known.ts new file mode 100644 index 0000000..2c691ec --- /dev/null +++ b/src/routes/well-known.ts @@ -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); + }); +} diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts new file mode 100644 index 0000000..c78c727 --- /dev/null +++ b/src/scripts/migrate.ts @@ -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(); diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..c7ac18f --- /dev/null +++ b/src/types/index.ts @@ -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 {} + +/** + * 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; // 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; // 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 { + 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; + created_at: string; + updated_at: string; +} + +export interface CreateWebhookRequest { + name: string; + url: string; + events: WebhookEvent[]; + headers?: Record; + retryCount?: number; + timeoutMs?: number; +} + +export interface UpdateWebhookRequest { + name?: string; + url?: string; + events?: WebhookEvent[]; + isActive?: boolean; + headers?: Record; + 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; + revenue?: number; + currency?: string; + timestamp: string; +} +