allowlist에 u추가
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
const CLICK_QUERY_PARAM_ALLOWLIST = ['u'] as const;
|
||||||
|
|
||||||
|
type ClickQueryParamKey = typeof CLICK_QUERY_PARAM_ALLOWLIST[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract and sanitize click URL query params allowed to flow into deferred deep link payloads.
|
||||||
|
*/
|
||||||
|
export function extractAllowedClickQueryParams(
|
||||||
|
query: Record<string, string | undefined>
|
||||||
|
): Partial<Record<ClickQueryParamKey, string>> {
|
||||||
|
const out: Partial<Record<ClickQueryParamKey, string>> = {};
|
||||||
|
|
||||||
|
for (const key of CLICK_QUERY_PARAM_ALLOWLIST) {
|
||||||
|
const value = query[key];
|
||||||
|
if (typeof value === 'string' && value.length > 0) {
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -129,6 +129,7 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
|
|||||||
utm_medium VARCHAR(255),
|
utm_medium VARCHAR(255),
|
||||||
utm_campaign VARCHAR(255),
|
utm_campaign VARCHAR(255),
|
||||||
referrer TEXT,
|
referrer TEXT,
|
||||||
|
click_query_params JSONB DEFAULT '{}',
|
||||||
is_bot BOOLEAN NOT NULL DEFAULT false,
|
is_bot BOOLEAN NOT NULL DEFAULT false,
|
||||||
bot_reason VARCHAR(16)
|
bot_reason VARCHAR(16)
|
||||||
)
|
)
|
||||||
@@ -429,6 +430,17 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
|
|||||||
END $$;
|
END $$;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Allowlisted click query parameters persisted for deferred deep linking.
|
||||||
|
// Backward compatible: legacy rows default to an empty object.
|
||||||
|
await client.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='click_events' AND column_name='click_query_params') THEN
|
||||||
|
ALTER TABLE click_events ADD COLUMN click_query_params JSONB DEFAULT '{}';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
// Attribution metadata on install_events (SIT-296): how the install was
|
// Attribution metadata on install_events (SIT-296): how the install was
|
||||||
// attributed ('fingerprint' | 'none') and which fingerprint signals matched.
|
// attributed ('fingerprint' | 'none') and which fingerprint signals matched.
|
||||||
// Makes attribution quality measurable. Backward compatible (NULL until set).
|
// Makes attribution quality measurable. Backward compatible (NULL until set).
|
||||||
|
|||||||
@@ -393,4 +393,55 @@ describe('recordInstallEvent', () => {
|
|||||||
expect(params[15]).toBeNull(); // sdk_name '' -> null
|
expect(params[15]).toBeNull(); // sdk_name '' -> null
|
||||||
expect(params[16]).toBeNull(); // sdk_version undefined -> null
|
expect(params[16]).toBeNull(); // sdk_version undefined -> null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('merges allowlisted click query params into deferred deepLinkParameters on attribution', async () => {
|
||||||
|
const clickTime = new Date().toISOString();
|
||||||
|
|
||||||
|
mockDbQuery.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
click_id: 'click-merge',
|
||||||
|
link_id: 'link-merge',
|
||||||
|
clicked_at: clickTime,
|
||||||
|
click_query_params: { u: 'abc' },
|
||||||
|
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: [{ id: 'install-merged', deep_link_data: {} }] });
|
||||||
|
mockDbQuery.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
short_code: 'kLOepkou',
|
||||||
|
original_url: 'https://angkorlifes.com',
|
||||||
|
ios_app_store_url: null,
|
||||||
|
android_app_store_url: null,
|
||||||
|
web_fallback_url: null,
|
||||||
|
utm_parameters: {},
|
||||||
|
targeting_rules: {},
|
||||||
|
deep_link_parameters: { pid: 'recommend_user' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockDbQuery.mockResolvedValueOnce({ rows: [] }); // UPDATE install_events
|
||||||
|
mockDbQuery.mockResolvedValueOnce({ rows: [] }); // SELECT user_id FROM links
|
||||||
|
|
||||||
|
const result = await fingerprint.recordInstallEvent(baseFingerprint, 'device-1');
|
||||||
|
|
||||||
|
expect(result.match).not.toBeNull();
|
||||||
|
expect(result.deepLinkData.deepLinkParameters).toEqual({
|
||||||
|
pid: 'recommend_user',
|
||||||
|
u: 'abc',
|
||||||
|
});
|
||||||
|
expect(result.deepLinkData.clickQueryParameters).toEqual({ u: 'abc' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+35
-1
@@ -24,6 +24,7 @@ export interface FingerprintMatch {
|
|||||||
confidenceScore: number;
|
confidenceScore: number;
|
||||||
matchedFactors: string[];
|
matchedFactors: string[];
|
||||||
clickedAt: Date;
|
clickedAt: Date;
|
||||||
|
clickQueryParams?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FingerprintFactorDebug = {
|
type FingerprintFactorDebug = {
|
||||||
@@ -250,6 +251,27 @@ function logAttributionDebug(event: string, payload: Record<string, unknown>): v
|
|||||||
console.info(`[linkforty][attribution] ${event} ${JSON.stringify(payload)}`);
|
console.info(`[linkforty][attribution] ${event} ${JSON.stringify(payload)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeClickQueryParams(value: unknown): Record<string, string> {
|
||||||
|
if (value == null) return {};
|
||||||
|
const source = typeof value === 'string' ? (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})() : value;
|
||||||
|
|
||||||
|
if (typeof source !== 'object' || Array.isArray(source)) return {};
|
||||||
|
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(source as Record<string, unknown>)) {
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
result[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function scoreFingerprintMatch(
|
function scoreFingerprintMatch(
|
||||||
fingerprint1: FingerprintData,
|
fingerprint1: FingerprintData,
|
||||||
fingerprint2: FingerprintData
|
fingerprint2: FingerprintData
|
||||||
@@ -396,6 +418,7 @@ export async function matchInstallToClick(
|
|||||||
ce.id as click_id,
|
ce.id as click_id,
|
||||||
ce.link_id,
|
ce.link_id,
|
||||||
ce.clicked_at,
|
ce.clicked_at,
|
||||||
|
ce.click_query_params,
|
||||||
l.attribution_window_hours,
|
l.attribution_window_hours,
|
||||||
df.ip_address,
|
df.ip_address,
|
||||||
df.user_agent,
|
df.user_agent,
|
||||||
@@ -445,6 +468,7 @@ export async function matchInstallToClick(
|
|||||||
platform: row.platform,
|
platform: row.platform,
|
||||||
platformVersion: row.platform_version,
|
platformVersion: row.platform_version,
|
||||||
};
|
};
|
||||||
|
const clickQueryParams = normalizeClickQueryParams(row.click_query_params);
|
||||||
|
|
||||||
const { score, matchedFactors, debug } = scoreFingerprintMatch(
|
const { score, matchedFactors, debug } = scoreFingerprintMatch(
|
||||||
installFingerprint,
|
installFingerprint,
|
||||||
@@ -476,6 +500,7 @@ export async function matchInstallToClick(
|
|||||||
screenHeight: clickFingerprint.screenHeight || null,
|
screenHeight: clickFingerprint.screenHeight || null,
|
||||||
},
|
},
|
||||||
factors: debug,
|
factors: debug,
|
||||||
|
clickQueryParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track the best match
|
// Track the best match
|
||||||
@@ -487,6 +512,7 @@ export async function matchInstallToClick(
|
|||||||
confidenceScore: score,
|
confidenceScore: score,
|
||||||
matchedFactors,
|
matchedFactors,
|
||||||
clickedAt: new Date(row.clicked_at),
|
clickedAt: new Date(row.clicked_at),
|
||||||
|
clickQueryParams,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,6 +536,7 @@ export async function matchInstallToClick(
|
|||||||
confidenceScore: bestMatch.confidenceScore,
|
confidenceScore: bestMatch.confidenceScore,
|
||||||
matchedFactors: bestMatch.matchedFactors,
|
matchedFactors: bestMatch.matchedFactors,
|
||||||
clickedAt: bestMatch.clickedAt.toISOString(),
|
clickedAt: bestMatch.clickedAt.toISOString(),
|
||||||
|
clickQueryParams: bestMatch.clickQueryParams || {},
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
@@ -649,6 +676,12 @@ export async function recordInstallEvent(
|
|||||||
|
|
||||||
if (linkResult.rows.length > 0) {
|
if (linkResult.rows.length > 0) {
|
||||||
const link = linkResult.rows[0];
|
const link = linkResult.rows[0];
|
||||||
|
const clickQueryParams = match.clickQueryParams || {};
|
||||||
|
const deepLinkParameters = {
|
||||||
|
...(link.deep_link_parameters || {}),
|
||||||
|
...clickQueryParams,
|
||||||
|
};
|
||||||
|
|
||||||
deepLinkData = {
|
deepLinkData = {
|
||||||
shortCode: link.short_code,
|
shortCode: link.short_code,
|
||||||
originalUrl: link.original_url,
|
originalUrl: link.original_url,
|
||||||
@@ -657,7 +690,8 @@ export async function recordInstallEvent(
|
|||||||
webFallbackUrl: link.web_fallback_url,
|
webFallbackUrl: link.web_fallback_url,
|
||||||
utmParameters: link.utm_parameters,
|
utmParameters: link.utm_parameters,
|
||||||
targetingRules: link.targeting_rules,
|
targetingRules: link.targeting_rules,
|
||||||
deepLinkParameters: link.deep_link_parameters,
|
deepLinkParameters,
|
||||||
|
clickQueryParameters: clickQueryParams,
|
||||||
clickedAt: match.clickedAt,
|
clickedAt: match.clickedAt,
|
||||||
confidenceScore: match.confidenceScore,
|
confidenceScore: match.confidenceScore,
|
||||||
matchedFactors: match.matchedFactors,
|
matchedFactors: match.matchedFactors,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { parseUserAgent, getLocationFromIP, buildRedirectUrl, detectDevice } fro
|
|||||||
import { storeFingerprintForClick, type FingerprintData } from '../lib/fingerprint.js';
|
import { storeFingerprintForClick, type FingerprintData } from '../lib/fingerprint.js';
|
||||||
import { emitClickEvent } from '../lib/event-emitter.js';
|
import { emitClickEvent } from '../lib/event-emitter.js';
|
||||||
import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js';
|
import { classifyBot, edgeBotSignal } from '../lib/bot-detection.js';
|
||||||
|
import { extractAllowedClickQueryParams } from '../lib/click-query-params.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect iOS in-app browsers where Universal Links don't fire.
|
* Detect iOS in-app browsers where Universal Links don't fire.
|
||||||
@@ -281,6 +282,7 @@ export async function redirectRoutes(fastify: FastifyInstance) {
|
|||||||
const utmSource = query?.utm_source;
|
const utmSource = query?.utm_source;
|
||||||
const utmMedium = query?.utm_medium;
|
const utmMedium = query?.utm_medium;
|
||||||
const utmCampaign = query?.utm_campaign;
|
const utmCampaign = query?.utm_campaign;
|
||||||
|
const clickQueryParams = extractAllowedClickQueryParams(query);
|
||||||
|
|
||||||
// Extract fingerprint data from query params (sent by SDK/client)
|
// Extract fingerprint data from query params (sent by SDK/client)
|
||||||
const fpTimezone = query?.fp_tz || timezone || undefined;
|
const fpTimezone = query?.fp_tz || timezone || undefined;
|
||||||
@@ -296,8 +298,8 @@ export async function redirectRoutes(fastify: FastifyInstance) {
|
|||||||
`INSERT INTO click_events (
|
`INSERT INTO click_events (
|
||||||
id, link_id, ip_address, user_agent, device_type, platform,
|
id, link_id, ip_address, user_agent, device_type, platform,
|
||||||
country_code, country_name, region, city, latitude, longitude, timezone,
|
country_code, country_name, region, city, latitude, longitude, timezone,
|
||||||
utm_source, utm_medium, utm_campaign, referrer, is_bot, bot_reason
|
utm_source, utm_medium, utm_campaign, referrer, click_query_params, is_bot, bot_reason
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)`,
|
||||||
[
|
[
|
||||||
clickId,
|
clickId,
|
||||||
link.id,
|
link.id,
|
||||||
@@ -316,6 +318,7 @@ export async function redirectRoutes(fastify: FastifyInstance) {
|
|||||||
utmMedium,
|
utmMedium,
|
||||||
utmCampaign,
|
utmCampaign,
|
||||||
referrer,
|
referrer,
|
||||||
|
JSON.stringify(clickQueryParams),
|
||||||
isBot,
|
isBot,
|
||||||
botReason,
|
botReason,
|
||||||
]
|
]
|
||||||
|
|||||||
+5
-2
@@ -8,6 +8,7 @@ import {
|
|||||||
storeFingerprintForClick,
|
storeFingerprintForClick,
|
||||||
type FingerprintData,
|
type FingerprintData,
|
||||||
} from '../lib/fingerprint.js';
|
} from '../lib/fingerprint.js';
|
||||||
|
import { extractAllowedClickQueryParams } from '../lib/click-query-params.js';
|
||||||
import { triggerWebhooks } from '../lib/webhook.js';
|
import { triggerWebhooks } from '../lib/webhook.js';
|
||||||
import { parseUserAgent, getLocationFromIP, detectDevice } from '../lib/utils.js';
|
import { parseUserAgent, getLocationFromIP, detectDevice } from '../lib/utils.js';
|
||||||
import { emitClickEvent } from '../lib/event-emitter.js';
|
import { emitClickEvent } from '../lib/event-emitter.js';
|
||||||
@@ -611,14 +612,15 @@ export async function sdkRoutes(fastify: FastifyInstance) {
|
|||||||
const fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined;
|
const fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined;
|
||||||
const fpPlatform = query?.fp_platform || deviceType;
|
const fpPlatform = query?.fp_platform || deviceType;
|
||||||
const fpPlatformVersion = query?.fp_pv || platformVersion;
|
const fpPlatformVersion = query?.fp_pv || platformVersion;
|
||||||
|
const clickQueryParams = extractAllowedClickQueryParams(query);
|
||||||
|
|
||||||
// Insert click event
|
// Insert click event
|
||||||
const clickResult = await db.query(
|
const clickResult = await db.query(
|
||||||
`INSERT INTO click_events (
|
`INSERT INTO click_events (
|
||||||
link_id, ip_address, user_agent, device_type, platform,
|
link_id, ip_address, user_agent, device_type, platform,
|
||||||
country_code, country_name, region, city, latitude, longitude, timezone,
|
country_code, country_name, region, city, latitude, longitude, timezone,
|
||||||
utm_source, utm_medium, utm_campaign, referrer, is_bot, bot_reason
|
utm_source, utm_medium, utm_campaign, referrer, click_query_params, is_bot, bot_reason
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[
|
[
|
||||||
link.id,
|
link.id,
|
||||||
@@ -637,6 +639,7 @@ export async function sdkRoutes(fastify: FastifyInstance) {
|
|||||||
query?.utm_medium || null,
|
query?.utm_medium || null,
|
||||||
query?.utm_campaign || null,
|
query?.utm_campaign || null,
|
||||||
referrer,
|
referrer,
|
||||||
|
JSON.stringify(clickQueryParams),
|
||||||
isBot,
|
isBot,
|
||||||
botReason,
|
botReason,
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user