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_campaign VARCHAR(255),
|
||||
referrer TEXT,
|
||||
click_query_params JSONB DEFAULT '{}',
|
||||
is_bot BOOLEAN NOT NULL DEFAULT false,
|
||||
bot_reason VARCHAR(16)
|
||||
)
|
||||
@@ -429,6 +430,17 @@ export async function initializeDatabase(options: DatabaseOptions = {}) {
|
||||
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
|
||||
// attributed ('fingerprint' | 'none') and which fingerprint signals matched.
|
||||
// Makes attribution quality measurable. Backward compatible (NULL until set).
|
||||
|
||||
@@ -393,4 +393,55 @@ describe('recordInstallEvent', () => {
|
||||
expect(params[15]).toBeNull(); // sdk_name '' -> 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;
|
||||
matchedFactors: string[];
|
||||
clickedAt: Date;
|
||||
clickQueryParams?: Record<string, string>;
|
||||
}
|
||||
|
||||
type FingerprintFactorDebug = {
|
||||
@@ -250,6 +251,27 @@ function logAttributionDebug(event: string, payload: Record<string, unknown>): v
|
||||
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(
|
||||
fingerprint1: FingerprintData,
|
||||
fingerprint2: FingerprintData
|
||||
@@ -396,6 +418,7 @@ export async function matchInstallToClick(
|
||||
ce.id as click_id,
|
||||
ce.link_id,
|
||||
ce.clicked_at,
|
||||
ce.click_query_params,
|
||||
l.attribution_window_hours,
|
||||
df.ip_address,
|
||||
df.user_agent,
|
||||
@@ -445,6 +468,7 @@ export async function matchInstallToClick(
|
||||
platform: row.platform,
|
||||
platformVersion: row.platform_version,
|
||||
};
|
||||
const clickQueryParams = normalizeClickQueryParams(row.click_query_params);
|
||||
|
||||
const { score, matchedFactors, debug } = scoreFingerprintMatch(
|
||||
installFingerprint,
|
||||
@@ -476,6 +500,7 @@ export async function matchInstallToClick(
|
||||
screenHeight: clickFingerprint.screenHeight || null,
|
||||
},
|
||||
factors: debug,
|
||||
clickQueryParams,
|
||||
});
|
||||
|
||||
// Track the best match
|
||||
@@ -487,6 +512,7 @@ export async function matchInstallToClick(
|
||||
confidenceScore: score,
|
||||
matchedFactors,
|
||||
clickedAt: new Date(row.clicked_at),
|
||||
clickQueryParams,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -510,6 +536,7 @@ export async function matchInstallToClick(
|
||||
confidenceScore: bestMatch.confidenceScore,
|
||||
matchedFactors: bestMatch.matchedFactors,
|
||||
clickedAt: bestMatch.clickedAt.toISOString(),
|
||||
clickQueryParams: bestMatch.clickQueryParams || {},
|
||||
}
|
||||
: null,
|
||||
});
|
||||
@@ -649,6 +676,12 @@ export async function recordInstallEvent(
|
||||
|
||||
if (linkResult.rows.length > 0) {
|
||||
const link = linkResult.rows[0];
|
||||
const clickQueryParams = match.clickQueryParams || {};
|
||||
const deepLinkParameters = {
|
||||
...(link.deep_link_parameters || {}),
|
||||
...clickQueryParams,
|
||||
};
|
||||
|
||||
deepLinkData = {
|
||||
shortCode: link.short_code,
|
||||
originalUrl: link.original_url,
|
||||
@@ -657,7 +690,8 @@ export async function recordInstallEvent(
|
||||
webFallbackUrl: link.web_fallback_url,
|
||||
utmParameters: link.utm_parameters,
|
||||
targetingRules: link.targeting_rules,
|
||||
deepLinkParameters: link.deep_link_parameters,
|
||||
deepLinkParameters,
|
||||
clickQueryParameters: clickQueryParams,
|
||||
clickedAt: match.clickedAt,
|
||||
confidenceScore: match.confidenceScore,
|
||||
matchedFactors: match.matchedFactors,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { parseUserAgent, getLocationFromIP, buildRedirectUrl, detectDevice } fro
|
||||
import { storeFingerprintForClick, type FingerprintData } from '../lib/fingerprint.js';
|
||||
import { emitClickEvent } from '../lib/event-emitter.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.
|
||||
@@ -281,6 +282,7 @@ export async function redirectRoutes(fastify: FastifyInstance) {
|
||||
const utmSource = query?.utm_source;
|
||||
const utmMedium = query?.utm_medium;
|
||||
const utmCampaign = query?.utm_campaign;
|
||||
const clickQueryParams = extractAllowedClickQueryParams(query);
|
||||
|
||||
// Extract fingerprint data from query params (sent by SDK/client)
|
||||
const fpTimezone = query?.fp_tz || timezone || undefined;
|
||||
@@ -296,8 +298,8 @@ export async function redirectRoutes(fastify: FastifyInstance) {
|
||||
`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)`,
|
||||
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, $20)`,
|
||||
[
|
||||
clickId,
|
||||
link.id,
|
||||
@@ -316,6 +318,7 @@ export async function redirectRoutes(fastify: FastifyInstance) {
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
referrer,
|
||||
JSON.stringify(clickQueryParams),
|
||||
isBot,
|
||||
botReason,
|
||||
]
|
||||
|
||||
+5
-2
@@ -8,6 +8,7 @@ import {
|
||||
storeFingerprintForClick,
|
||||
type FingerprintData,
|
||||
} from '../lib/fingerprint.js';
|
||||
import { extractAllowedClickQueryParams } from '../lib/click-query-params.js';
|
||||
import { triggerWebhooks } from '../lib/webhook.js';
|
||||
import { parseUserAgent, getLocationFromIP, detectDevice } from '../lib/utils.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 fpPlatform = query?.fp_platform || deviceType;
|
||||
const fpPlatformVersion = query?.fp_pv || platformVersion;
|
||||
const clickQueryParams = extractAllowedClickQueryParams(query);
|
||||
|
||||
// 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)
|
||||
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)
|
||||
RETURNING id`,
|
||||
[
|
||||
link.id,
|
||||
@@ -637,6 +639,7 @@ export async function sdkRoutes(fastify: FastifyInstance) {
|
||||
query?.utm_medium || null,
|
||||
query?.utm_campaign || null,
|
||||
referrer,
|
||||
JSON.stringify(clickQueryParams),
|
||||
isBot,
|
||||
botReason,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user