import crypto from 'crypto'; import { getPool } from './db.js'; import type { RowDataPacket } from 'mysql2'; const TOKEN_RE = /^[A-Za-z0-9_-]{32}$/; const BOT_RE = /bot|crawl|spider|slurp|facebookexternalhit/i; export interface TrackingLink { token: string; customerId: string; draftId: number | null; destinationUrl: string; clickCount: number; expiresAt: Date | null; createdAt: Date; } interface TrackingLinkRow extends RowDataPacket { token: string; customer_id: string; draft_id: number | null; destination_url: string; click_count: number; expires_at: Date | null; created_at: Date; } export function isValidTokenFormat(token: string): boolean { return TOKEN_RE.test(token); } export function isBot(userAgent: string): boolean { return BOT_RE.test(userAgent); } export function validateDestinationUrl(url: string): void { if (url.length > 2048) throw new Error('Destination URL too long'); let parsed: URL; try { parsed = new URL(url); } catch { throw new Error('Invalid destination URL'); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error('Destination URL must use http or https'); } if (parsed.username || parsed.password) { throw new Error('Destination URL must not contain credentials'); } } export async function createTrackingLink(params: { customerId: string; draftId: number | null; destinationUrl: string; expiresAt: Date | null; }): Promise { validateDestinationUrl(params.destinationUrl); for (let attempt = 0; attempt < 3; attempt++) { const token = crypto.randomBytes(24).toString('base64url'); try { await getPool().query( `INSERT INTO tracking_links (token, customer_id, draft_id, destination_url, expires_at) VALUES (?, ?, ?, ?, ?)`, [token, params.customerId, params.draftId ?? null, params.destinationUrl, params.expiresAt] ); return token; } catch (err: unknown) { const mysqlErr = err as { code?: string }; if (mysqlErr.code === 'ER_DUP_ENTRY' && attempt < 2) continue; throw err; } } throw new Error('Failed to generate unique tracking token after 3 attempts'); } export async function getTrackingLink(token: string): Promise { const [rows] = await getPool().query( 'SELECT token, customer_id, draft_id, destination_url, click_count, expires_at, created_at FROM tracking_links WHERE token = ?', [token] ); const row = rows[0]; if (!row) return null; if (row.expires_at && row.expires_at < new Date()) return null; return { token: row.token, customerId: row.customer_id, draftId: row.draft_id, destinationUrl: row.destination_url, clickCount: Number(row.click_count), expiresAt: row.expires_at, createdAt: row.created_at, }; } // Fire-and-forget — must be called AFTER res.redirect() is sent export function recordClick(token: string): void { getPool() .query('UPDATE tracking_links SET click_count = click_count + 1 WHERE token = ?', [token]) .catch(err => console.error('[tracking] click record failed:', err)); }